Compare commits

..
Author SHA1 Message Date
修雨 fdb461155f Merge branch 'main' into bugfix/shortcut-fixes 2026-07-23 00:08:11 +08:00
修雨 7e38f9fbba test(chat): cover missing download message id 2026-07-22 23:57:16 +08:00
修雨 067b3e6e51 test(chat): cover download media aliases 2026-07-22 23:45:37 +08:00
修雨 bcac84987f fix(chat): preserve message and schema contracts 2026-07-22 23:34:35 +08:00
DennisandClaude Opus 4.8 8dd87108f1 fix(chat): make message-read shortcuts render cards, forwards & ciphertext
The message-list shortcuts projected raw message content, so several message
kinds came out unreadable:

- speaker showed as null (real key is the bare "sender"; forwardMessages carry
  the literal string "null")
- out-of-office auto-replies / cards dumped raw rich-content JSON
- forwarded chat records ("聊天记录") collapsed to a lossy "[卡片]" summary,
  dropping their nested forwardMessages
- encrypted card/robot messages dumped raw base64 ciphertext (dws holds no key)

Extract the projection logic into a shared internal/shortcut/chatmsg package and
fix all five projecting read commands to reuse it (+chat-messages,
+messages-list, +messages-list-direct, +at-me, +search-msg, +thread-replies),
each keeping its own output fields:

- Sender: probe the bare "sender" key first, treat "null"/"" as absent
- CleanText: extract readable text from rich-content JSON, mark ciphertext as
  [加密消息,无法解码] instead of leaking base64
- Forwarded: recursively expand forwardMessages so chat records are readable
- RecoverEncryptedContents: best-effort re-query of search_messages_by_sender
  (which returns server-rendered plaintext) matched back by openMessageId

Also add --msg-id / --open-message-id aliases to `chat message download-media`
so agents that copy the openMessageId/msgId field don't hit "unknown flag".

Verified live: +messages-list-direct expands a 44-message forwarded record;
+messages-list / +at-me render clean text; download-media accepts --msg-id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 20:46:45 +08:00
修雨 412e77f215 Merge pull request #763 from DingTalk-Real-AI/codex/retry-gitee-transient-outages
fix: retry transient Gitee read outages safely
2026-07-22 17:56:50 +08:00
修雨 2a0bf1ebea fix: retry transient Gitee read outages safely 2026-07-22 17:46:03 +08:00
修雨 9ce13da6ed Merge pull request #762 from DingTalk-Real-AI/codex/extend-gitee-upload-window
fix: extend Gitee upload window
2026-07-22 16:50:49 +08:00
修雨 0e5731166b fix: extend Gitee upload window 2026-07-22 16:39:55 +08:00
修雨 92edd8ea53 Merge pull request #761 from DingTalk-Real-AI/codex/fix-gitee-slow-upload-timeout
fix: allow slow Gitee binary uploads
2026-07-22 16:08:28 +08:00
修雨 931d75beaf fix: allow slow Gitee binary uploads 2026-07-22 15:57:21 +08:00
修雨 7667cb30a3 Merge pull request #759 from DingTalk-Real-AI/codex/fix-gitee-upload-expect
fix: disable Expect for Gitee uploads
2026-07-22 15:13:33 +08:00
修雨 015daae064 fix: disable Expect for Gitee uploads 2026-07-22 15:02:13 +08:00
修雨 e7510ea5f0 Merge pull request #758 from DingTalk-Real-AI/codex/fix-gitee-upload-timeouts
fix: harden Gitee release repair
2026-07-22 14:39:15 +08:00
修雨 582b73cb40 fix: harden Gitee release repair 2026-07-22 14:27:47 +08:00
修雨 04ea184ff6 Merge pull request #752 from DingTalk-Real-AI/automation/homebrew-beta-v1.0.54-beta.2
chore: update Homebrew beta formula for v1.0.54-beta.2
2026-07-22 14:12:31 +08:00
修雨 0908b2ca6e Merge branch 'main' into automation/homebrew-beta-v1.0.54-beta.2 2026-07-22 11:48:29 +08:00
修雨 070febd7bf Merge pull request #755 from DingTalk-Real-AI/automation/homebrew-v1.0.54
chore: update Homebrew formula for v1.0.54
2026-07-22 11:47:19 +08:00
DWS Release Bot e3782231be chore: update formula for v1.0.54 2026-07-21 16:07:10 +00:00
DWS Release Bot 167a547a65 chore: update beta formula for v1.0.54-beta.2 2026-07-21 15:55:51 +00:00
修雨 8f62c19104 Merge pull request #749 from DingTalk-Real-AI/release/changelog-v1.0.54-beta.2
docs(changelog): add v1.0.54-beta.2 section
2026-07-21 23:37:15 +08:00
修雨 82798dc7fc docs(changelog): add v1.0.54-beta.2 section 2026-07-21 23:35:36 +08:00
修雨 3319cf62d5 Merge pull request #748 from DingTalk-Real-AI/release/changelog-v1.0.54
docs(changelog): fold v1.0.54-beta.1 into v1.0.54 stable section
2026-07-21 23:28:45 +08:00
修雨 1626818a98 docs(changelog): retain released v1.0.54-beta.1 section under v1.0.54 2026-07-21 23:26:23 +08:00
修雨 a03d6ebacc docs(changelog): fold v1.0.54-beta.1 into v1.0.54 stable section 2026-07-21 23:23:40 +08:00
修雨 4ee4a44e16 Merge pull request #745 from DingTalk-Real-AI/release/changelog-v1.0.54-beta.1
docs(changelog): add v1.0.54-beta.1 section
2026-07-21 23:00:03 +08:00
修雨 40181f8c0c docs(changelog): add v1.0.54-beta.1 section 2026-07-21 22:57:59 +08:00
修雨 14ff02ebe1 Merge pull request #743 from wxianfeng/fix/event-data-format-compat
fix(event): make flattened output opt-in
2026-07-21 22:50:21 +08:00
wxianfeng 55574fe12e Merge upstream/main into fix/event-data-format-compat 2026-07-21 22:37:26 +08:00
修雨 222ee16d51 test(event): close changed-code coverage gaps for flatten output mode
Drop the unreachable defensive tag-skip branch in transportEnvelopeSchema
(every transport.Event field carries a non-empty JSON tag) and add a unit
test for the validatePersonalEventOutputMode success path so the changed
code coverage gate reaches 100%.
2026-07-21 22:28:54 +08:00
修雨 27ced3ee18 Merge pull request #701 from DingTalk-Real-AI/codex/fix-plugin-command-registration
fix: restore plugin CLI overlay commands
2026-07-21 22:03:08 +08:00
修雨 129e8a10ef Merge remote-tracking branch 'origin/main' into codex/fix-plugin-command-registration
# Conflicts:
#	CHANGELOG.md
2026-07-21 21:50:37 +08:00
修雨 02fba09c1e fix(plugin): let replaceable fallbacks pass distribution conflict checks
pluginDescriptorConflictsWithDistribution and the identity-owner seeding
both treated conference as distribution-owned, so the whole plugin server
was skipped before the replaceable-fallback merge in addPluginCommandsSafe
could run. Skip replaceablePluginFallbacks names in both early gates while
keeping reserved-command protection and plugin-vs-plugin ownership intact.
2026-07-21 21:49:47 +08:00
修雨 94b64f74ac Merge pull request #738 from typefield/fix/schema-cli-path-compat
fix(schema): accept compatible CLI path separators
2026-07-21 21:37:10 +08:00
wxianfeng cefcf5b409 fix(event): make flattened output opt-in 2026-07-21 21:25:10 +08:00
玉澜 441289cdfe Merge remote-tracking branch 'upstream/main' into fix/schema-cli-path-compat 2026-07-21 20:50:37 +08:00
玉澜 d03823d772 test(schema): cover unknown compatibility query 2026-07-21 20:50:34 +08:00
修雨 c16a377863 Merge branch 'main' into codex/fix-plugin-command-registration 2026-07-21 20:47:48 +08:00
修雨 31c3acc94b Merge pull request #718 from DingTalk-Real-AI/cleanup/remove-shortcut-eval-pii
chore: 移除含真实 PII 的 shortcut 评测产物
2026-07-21 20:47:19 +08:00
修雨 f7e702df8d Merge branch 'main' into codex/fix-plugin-command-registration 2026-07-21 20:35:02 +08:00
玉澜 bee246e62c Merge remote-tracking branch 'upstream/main' into fix/schema-cli-path-compat 2026-07-21 19:50:20 +08:00
玉澜 089caa92a8 test(schema): cover prefixed compatibility query 2026-07-21 19:41:39 +08:00
玉澜 21cd3f8bc4 fix(schema): accept compatible CLI path separators 2026-07-21 19:18:07 +08:00
修雨 6c0cf3438b fix: address plugin review blockers 2026-07-21 11:33:03 +08:00
修雨 e3f30420fb fix: restore plugin overlay commands 2026-07-21 11:33:03 +08:00
95 changed files with 6765 additions and 550 deletions
+13 -4
View File
@@ -1735,7 +1735,7 @@ jobs:
if: ${{ !cancelled() && vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' && needs.release-contract.result == 'success' && needs.release.result == 'success' && needs.publish-channels.result == 'success' }}
needs: [release-contract, release, publish-channels]
runs-on: ubuntu-latest
timeout-minutes: 120
timeout-minutes: 360
permissions:
contents: read
env:
@@ -1752,12 +1752,14 @@ jobs:
- name: Check out trusted release tooling
uses: actions/checkout@v4
timeout-minutes: 5
with:
ref: ${{ github.sha }}
path: tmp/trusted-release-tooling
persist-credentials: false
- name: Fetch and verify sealed release tag
timeout-minutes: 5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
@@ -1776,7 +1778,7 @@ jobs:
path: dist
- name: Mirror release to Gitee (China)
timeout-minutes: 100
timeout-minutes: 318
run: ./scripts/release/sync-to-gitee.sh
env:
VERSION: ${{ needs.release-contract.outputs.release_version }}
@@ -2180,13 +2182,14 @@ jobs:
needs: dispatch-contract
if: ${{ !cancelled() && needs.dispatch-contract.result == 'success' && (needs.dispatch-contract.outputs.mode == 'repair_gitee' || needs.dispatch-contract.outputs.mode == 'repair_oss') && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' }}
runs-on: ubuntu-latest
timeout-minutes: 120
timeout-minutes: 360
permissions:
actions: read
contents: read
steps:
- name: Check out trusted release tooling
uses: actions/checkout@v4
timeout-minutes: 5
with:
ref: ${{ github.sha }}
path: tooling
@@ -2194,6 +2197,7 @@ jobs:
fetch-depth: 0
- name: Validate repair version
timeout-minutes: 2
working-directory: tooling
env:
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
@@ -2208,6 +2212,7 @@ jobs:
- name: Verify immutable release authority
id: authority
uses: actions/github-script@v7
timeout-minutes: 5
env:
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
with:
@@ -2330,12 +2335,14 @@ jobs:
- name: Check out sealed release source
uses: actions/checkout@v4
timeout-minutes: 5
with:
ref: ${{ steps.authority.outputs.commit_sha }}
path: release-source
persist-credentials: false
- name: Fetch and verify sealed release tag
timeout-minutes: 5
working-directory: tooling
env:
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
@@ -2352,6 +2359,7 @@ jobs:
"$VERSION" "$RELEASE_COMMIT" "$RELEASE_TAG_OBJECT"
- name: Require successful Release workflow delivery
timeout-minutes: 5
working-directory: tooling
env:
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
@@ -2368,6 +2376,7 @@ jobs:
--channel-repair "$target" "$VERSION" "$RELEASE_COMMIT"
- name: Download and verify immutable GitHub Release assets
timeout-minutes: 10
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.repair_gitee_version || inputs.repair_oss_version }}
@@ -2385,7 +2394,7 @@ jobs:
- name: Mirror release to Gitee (China)
if: ${{ needs.dispatch-contract.outputs.mode == 'repair_gitee' }}
timeout-minutes: 100
timeout-minutes: 318
working-directory: tooling
run: |
"$GITHUB_WORKSPACE/tooling/scripts/release/sync-to-gitee.sh"
+4
View File
@@ -62,3 +62,7 @@ dwsbin
/docs/shortcut-comparison.html
/docs/shortcut-gsb-eval.*
/scripts/run_shortcut_real_read_matrix.py
# Local coverage artifacts
coverage-shortcut.txt
coverage-*.txt
+38
View File
@@ -6,6 +6,44 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
### Fixed
- **Message-read shortcut projection** (#706) — the message-list shortcuts (`chat +chat-messages` / `+messages-list` / `+messages-list-direct` / `+at-me` / `+search-msg` / `+thread-replies`) now render card and out-of-office rich-content JSON as readable text (without ever rewriting ordinary text that merely embeds a JSON fragment), expand a forwarded chat record's nested `forwardMessages` instead of collapsing to a "[卡片]" summary, and mark undecryptable encrypted card messages as `[加密消息]`; the speaker is read from the bare `sender` key, nested `{name:…}` sender objects yield their display name, and the literal string `"null"` is treated as absent. Shared projection helpers now live in `internal/shortcut/chatmsg`. `chat message download-media` also gains `--msg-id` / `--open-message-id` aliases for its `--message-id` flag so agents copying the `openMessageId`/`msgId` output field no longer hit "unknown flag".
## [1.0.54] - 2026-07-21
This release promotes the validated `v1.0.54-beta.2` baseline to stable. It restores the default transport envelope for personal event output with opt-in flattening, plus Schema CLI path and plugin overlay compatibility fixes.
### Changed
- **Personal event output compatibility** (#743) — `event consume` once again preserves the transport envelope by default for `ndjson`/`json`/`pretty`, while retaining the existing `compact` processor. New Agent workflows opt into the event-specific top-level DTO with `--flatten`, which is mutually exclusive with `-f raw` and `--debug-raw-events`; `event schema --flatten` describes that DTO, while the default schema describes `type/event_type/data/headers` and points to `.data | fromjson`.
### Fixed
- **Schema CLI path compatibility** (#738) — user-facing Schema lookups once again accept space-, dot-, and slash-separated CLI paths without weakening strict canonical identity resolution.
- **Plugin CLI overlays** (#701) — installed plugins register their manifest-authored command trees again for HTTP and stdio servers, and a plugin may now replace a hidden compatibility fallback (for example `conference`) instead of being skipped as a distribution conflict.
## [1.0.54-beta.2] - 2026-07-21
This beta revalidates the same `v1.0.54-beta.1` source through the cloud release path with a sealed `OSS-Mirror: deferred` policy, because the manually tagged `v1.0.54-beta.1` push run failed on the unavailable OSS mirror channel after GitHub and npm delivery.
### Changed
- **Release delivery only** — no source changes since `v1.0.54-beta.1`; see that section for the user-visible changes under validation (#743, #738, #701).
## [1.0.54-beta.1] - 2026-07-21
This beta validates the restored default transport envelope for personal event output with opt-in flattening, plus Schema CLI path and plugin overlay compatibility fixes, on top of the validated `v1.0.53-beta.7` baseline.
### Changed
- **Personal event output compatibility** (#743) — `event consume` once again preserves the transport envelope by default for `ndjson`/`json`/`pretty`, while retaining the existing `compact` processor. New Agent workflows opt into the event-specific top-level DTO with `--flatten`, which is mutually exclusive with `-f raw` and `--debug-raw-events`; `event schema --flatten` describes that DTO, while the default schema describes `type/event_type/data/headers` and points to `.data | fromjson`.
### Fixed
- **Schema CLI path compatibility** (#738) — user-facing Schema lookups once again accept space-, dot-, and slash-separated CLI paths without weakening strict canonical identity resolution.
- **Plugin CLI overlays** (#701) — installed plugins register their manifest-authored command trees again for HTTP and stdio servers, and a plugin may now replace a hidden compatibility fallback (for example `conference`) instead of being skipped as a distribution conflict.
## [1.0.53] - 2026-07-21
This release promotes the validated `v1.0.53-beta.7` baseline to stable. It adds enterprise onboarding, declarative shortcuts, Sheet/Aitable writes, multi-account profiles, and broader personal IM events, while hardening authentication and the guarded release path.
+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.53-beta.4"
version "1.0.54-beta.2"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-darwin-arm64.tar.gz"
sha256 "32a442d5b42dfed8512a695a7cef513722db6912f3c3168954cfaefb68e0b075"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54-beta.2/dws-darwin-arm64.tar.gz"
sha256 "46b57bed1f6e9f7ba007d8a86a6f5eb280fdeb557fc9bb5946f14f9b1f8f0c9f"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-darwin-amd64.tar.gz"
sha256 "00c694677b9ce2e1a711535740681defe0a5f83d6b72140f305483501628faf8"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54-beta.2/dws-darwin-amd64.tar.gz"
sha256 "1b7fd08e64b1c86bbcee217604ffe07e0e8f1b3b5c4de518534386972bcf0f9b"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-linux-arm64.tar.gz"
sha256 "98516620e861e516cf846cf418f1a0ad5c5eb9a8681bd066b1fd29f4847aca6a"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54-beta.2/dws-linux-arm64.tar.gz"
sha256 "108d3861ef606519f9934530d29654eab55a73607d1ee6775461f98ef5a6acd4"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-linux-amd64.tar.gz"
sha256 "266df80e8a989971789a157dd134685a7c9eda01dd1d082719ec9e09d5a07bf0"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54-beta.2/dws-linux-amd64.tar.gz"
sha256 "6cb96ee09419bbbcc1eb336218ac2aa1d9ca0ed5cbd5a80c79bc20e1e1f03ff7"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-skills.zip"
sha256 "6770511ab9b04b4d97da1858069ff694830ba91d47c1e8564fd85856f95b016e"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54-beta.2/dws-skills.zip"
sha256 "572b93f04a10268d185ad1f8e70e0d412949ae056be8494a9949387076fd14bc"
end
def install
+13 -11
View File
@@ -1,32 +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.52"
version "1.0.54"
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.52/dws-darwin-arm64.tar.gz"
sha256 "4f6b4d064a76bcefac42feb5f356253fe43f9499b8cec9d2cdf202e7d3b9b60c"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-darwin-arm64.tar.gz"
sha256 "8ae0e52cf973f6fb3df61c67a41fd11e2df417a0c815762b6060cbcb5e600c08"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-darwin-amd64.tar.gz"
sha256 "abc87128f4b98d0a01ea99235449031971db8fa4ce94167403e3b736c4b81e9a"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-darwin-amd64.tar.gz"
sha256 "11b711b9d70dea62304bf5f8206c56b4e7ea91148dafe97fb7c0f844a2a61da3"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-linux-arm64.tar.gz"
sha256 "0d357ef0535f99f2f63b5ecbfdee9c32448be2a2c24f3096c03126b3b7570bc5"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-linux-arm64.tar.gz"
sha256 "9c7ecb4c8cd55644b2faa73f6ce7843c0279b23793e23deb5061692ea71a0cf1"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-linux-amd64.tar.gz"
sha256 "b7dfd9a4b3489211359261747ed0cb9c8c261434bb762ad3f76df33bdbabd5cb"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-linux-amd64.tar.gz"
sha256 "8a0bc245747fc3facf98c8103c06da46852a30bff31ac93b0aa874e8c7e46db7"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-skills.zip"
sha256 "0fa3c8dec500c1659e6480d6772ae901b2d12d24322dd5d7283f016024290c21"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.54/dws-skills.zip"
sha256 "7450fd0115c75bfe6820c7099f348973d9353cca9d8d647c9cddcd70978a7ec0"
end
def install
@@ -52,6 +53,7 @@ class DingtalkWorkspaceCli < Formula
<<~EOS
Agent Skills are bundled in #{pkgshare}/skills/dws.
Run `dws skill setup` to install them into your Agent directories.
EOS
end
+7 -5
View File
@@ -476,6 +476,8 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog currently covers messages that mention the current user, one-to-one messages with a specified user, and messages in a specified group.
The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`.
> **Prerequisite**: run `dws auth login`. Personal identity is resolved from the OAuth token and cannot be supplied through command-line identity flags.
For an event-focused installation, use the official convenience installer:
@@ -487,19 +489,19 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
```bash
# Inspect the public personal event catalog and schema
dws event list
dws event schema user_im_message_receive_o2o
dws event schema user_im_message_receive_o2o --flatten
# Listen for messages that mention the current user
dws event consume user_im_message_receive_at -f ndjson
dws event consume user_im_message_receive_at --flatten -f ndjson
# Listen for one-to-one messages with a specified user
dws event consume user_im_message_receive_o2o --user <userId> -f ndjson
dws event consume user_im_message_receive_o2o --user <userId> --flatten -f ndjson
# Listen by openDingtalkId (external contact, bot, or cross-organization identity)
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> -f ndjson
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> --flatten -f ndjson
# Listen for messages in a specified group
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
# Inspect local consumers and cancel a subscription
dws event status
+7 -5
View File
@@ -470,6 +470,8 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录包括:当前用户被 @ 的消息、与指定用户的单聊消息、指定群的消息。
默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。
> **前置条件**:先运行 `dws auth login`。个人身份从 OAuth token 解析,不允许通过命令行伪造。
只需要 event 能力时,可以使用官方便捷安装脚本:
@@ -481,19 +483,19 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
```bash
# 查看公开个人事件目录和 schema
dws event list
dws event schema user_im_message_receive_o2o
dws event schema user_im_message_receive_o2o --flatten
# 监听当前用户被 @ 的消息
dws event consume user_im_message_receive_at -f ndjson
dws event consume user_im_message_receive_at --flatten -f ndjson
# 监听与指定用户的单聊消息
dws event consume user_im_message_receive_o2o --user <userId> -f ndjson
dws event consume user_im_message_receive_o2o --user <userId> --flatten -f ndjson
# 使用 openDingtalkId 监听外部联系人、机器人或跨组织身份
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> -f ndjson
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> --flatten -f ndjson
# 监听指定群的消息
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
# 查看本地 consume,并取消指定订阅
dws event status
+9
View File
@@ -49,6 +49,15 @@ func RegisterPluginAuth(productID string, auth *PluginAuth) {
pluginAuthRegistry[productID] = auth
}
// ClearPluginAuth removes credentials for a plugin product. Registration uses
// this before applying an accepted descriptor so a descriptor without custom
// auth cannot inherit stale credentials from an earlier root construction.
func ClearPluginAuth(productID string) {
pluginAuthMu.Lock()
defer pluginAuthMu.Unlock()
delete(pluginAuthRegistry, productID)
}
// LookupPluginAuth returns the authentication credentials registered
// for the given product ID, or nil if none exists.
func LookupPluginAuth(productID string) (*PluginAuth, bool) {
+2 -2
View File
@@ -1469,10 +1469,10 @@ func TestCrossPlatformCoveragePersonalEventPureCoverage(t *testing.T) {
if !ok {
t.Fatal("mention definition missing")
}
if err := renderPersonalSchema(io.Discard, def, ""); err != nil {
if err := renderPersonalSchema(io.Discard, def, "", false); err != nil {
t.Fatal(err)
}
if err := renderPersonalSchema(io.Discard, def, "yaml"); err == nil {
if err := renderPersonalSchema(io.Discard, def, "yaml", true); err == nil {
t.Fatal("unsupported schema format succeeded")
}
for _, key := range []string{"", "unknown", personal.EventMention, personal.EventFromUser} {
+10 -1
View File
@@ -112,6 +112,7 @@ func newEventConsumeCommand() *cobra.Command {
dryRun bool
foreground bool
asIdentity string
flatten bool
personalOpts personalConsumeOptions
streamOpts eventStreamTicketOptions
)
@@ -127,7 +128,11 @@ func newEventConsumeCommand() *cobra.Command {
json 每事件多行美化 JSON(必须配 --max-events 或 --duration)
pretty 同 json,未来加颜色
raw 仅 SDK 原始 payload,无外层封装
compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好)
compact 单行紧凑 JSON;不传 --flatten 时沿用原 compact processor
数据结构:
ndjson/json/pretty 默认保持 transport envelope(type/event_type/data/headers)
--flatten 结构化格式输出稳定的顶层业务字段,适合 Agent / 脚本直接消费
默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加
--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用
@@ -144,6 +149,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
}
if as == "user" {
personalOpts.EventKey = firstArg(args)
personalOpts.Flatten = flatten
personalOpts.Common = commonConsumeOptions{
EventTypes: eventTypes,
Filter: filter,
@@ -167,6 +173,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
return fmt.Errorf("event consume: --debug-raw-events is only supported with --as user")
}
if err := rejectChangedFlags(c, "user",
"flatten",
"subscribe-id",
"rule",
"name",
@@ -280,6 +287,8 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
"提示 bus 客户端期望 compact 渲染(语义透传,bus 仍按原 payload 投递)")
f.StringVarP(&formatRaw, "format", "f", "ndjson",
"输出格式 (ndjson/json/pretty/raw/compact);事件流默认 ndjson")
f.BoolVar(&flatten, "flatten", false,
"将个人事件 transport envelope 投影为稳定的顶层业务字段")
f.StringVar(&outputDir, "output-dir", "",
"每事件写一个文件到该目录 ({type}_{id}_{ts}.json);与 stdout 互斥")
f.StringArrayVar(&routesRaw, "route", nil,
+39 -15
View File
@@ -61,6 +61,7 @@ type commonConsumeOptions struct {
type personalConsumeOptions struct {
Common commonConsumeOptions
EventKey string
Flatten bool
DebugRawEvents bool
SubscribeID string
Rule string
@@ -140,6 +141,7 @@ var (
func newEventSchemaCommand() *cobra.Command {
var asIdentity string
var formatRaw string
var flatten bool
cmd := &cobra.Command{
Use: "schema <event_key>",
Short: "显示事件 schema",
@@ -157,11 +159,12 @@ func newEventSchemaCommand() *cobra.Command {
if !def.Public {
return personal.PublicAvailabilityError(args[0])
}
return renderPersonalSchema(c.OutOrStdout(), def, formatRaw)
return renderPersonalSchema(c.OutOrStdout(), def, formatRaw, flatten)
},
}
cmd.Flags().StringVar(&asIdentity, "as", "user", "事件身份: user")
cmd.Flags().StringVarP(&formatRaw, "format", "f", "json", "输出格式: json")
cmd.Flags().BoolVar(&flatten, "flatten", false, "显示 --flatten 消费模式对应的顶层业务字段 schema")
hideEventInternalFlags(cmd, "as")
cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{
Name: "event_key",
@@ -189,7 +192,7 @@ func runPersonalEventList(c *cobra.Command, opts personalListOptions) error {
return tw.Flush()
}
func renderPersonalSchema(w io.Writer, def personal.Definition, format string) error {
func renderPersonalSchema(w io.Writer, def personal.Definition, format string, flatten bool) error {
format = strings.ToLower(strings.TrimSpace(format))
if format == "" {
format = "json"
@@ -199,7 +202,7 @@ func renderPersonalSchema(w io.Writer, def personal.Definition, format string) e
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(personal.BuildSchemaDocument(def))
return enc.Encode(personal.BuildSchemaDocumentForMode(def, flatten))
}
func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) error {
@@ -207,6 +210,19 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return err
}
rawFormat := ""
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
rawFormat = opts.Common.FormatRaw
}
normalised, fellback := consume.NormalizeFormat(rawFormat)
if fellback && !opts.Common.Quiet {
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
}
if err := validatePersonalEventOutputMode(opts.Flatten, opts.DebugRawEvents, normalised); err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
projector := personalEventProjector(opts.DebugRawEvents, opts.Flatten)
configDir := defaultConfigDir()
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
if err != nil {
@@ -221,16 +237,6 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
rawFormat := ""
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
rawFormat = opts.Common.FormatRaw
}
normalised, fellback := consume.NormalizeFormat(rawFormat)
if fellback && !opts.Common.Quiet {
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
}
projector := personalEventProjector(opts.DebugRawEvents)
if opts.Common.DryRun {
if strings.TrimSpace(opts.SubscribeID) == "" {
if err := validatePersonalSubscriptionOptions(opts); err != nil {
@@ -247,6 +253,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
Duration: opts.Common.Duration,
EventKey: opts.EventKey,
Format: normalised,
Flatten: opts.Flatten,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Projector: projector,
@@ -303,6 +310,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
Duration: opts.Common.Duration,
EventKey: eventKey,
Format: normalised,
Flatten: opts.Flatten,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Projector: projector,
@@ -366,11 +374,27 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
return err
}
func personalEventProjector(debugRawEvents bool) consume.Projector {
func personalEventProjector(debugRawEvents, flatten bool) consume.Projector {
if debugRawEvents {
return func(ev transport.Event) (any, error) { return ev, nil }
}
return personal.ProjectOutput
if flatten {
return personal.ProjectOutput
}
return nil
}
func validatePersonalEventOutputMode(flatten, debugRawEvents bool, format consume.Format) error {
if !flatten {
return nil
}
if debugRawEvents {
return fmt.Errorf("--flatten and --debug-raw-events are mutually exclusive")
}
if format == consume.FormatRaw {
return fmt.Errorf("--flatten and --format raw are mutually exclusive")
}
return nil
}
func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOptions, subscribeID, eventKey string) {
+71 -4
View File
@@ -20,6 +20,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
"github.com/spf13/cobra"
)
func TestApplyPersonalConsumeFiltersDebugRawEvents(t *testing.T) {
@@ -52,11 +53,14 @@ func TestApplyPersonalConsumeFiltersDefault(t *testing.T) {
}
}
func TestPersonalEventProjectorUsesRawEnvelopeForDebug(t *testing.T) {
if personalEventProjector(false) == nil {
t.Fatal("normal personal consume projector = nil")
func TestPersonalEventProjectorSelectsExplicitModes(t *testing.T) {
if personalEventProjector(false, false) != nil {
t.Fatal("default personal consume should preserve transport envelope")
}
projector := personalEventProjector(true)
if personalEventProjector(false, true) == nil {
t.Fatal("flatten personal consume projector = nil")
}
projector := personalEventProjector(true, false)
if projector == nil {
t.Fatal("debug raw personal consume projector = nil")
}
@@ -74,6 +78,69 @@ func TestPersonalEventProjectorUsesRawEnvelopeForDebug(t *testing.T) {
}
}
func TestEventConsumeFlattenRejectsRawModesBeforeIdentityResolution(t *testing.T) {
for _, tc := range []struct {
name string
args []string
want string
}{
{
name: "raw format",
args: []string{personal.EventMention, "--flatten", "--format", "raw"},
want: "--flatten and --format raw are mutually exclusive",
},
{
name: "raw debug",
args: []string{personal.EventMention, "--flatten", "--debug-raw-events"},
want: "--flatten and --debug-raw-events are mutually exclusive",
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs(tc.args)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Execute() error = %v, want %q", err, tc.want)
}
if strings.Contains(err.Error(), "login") || strings.Contains(err.Error(), "token") {
t.Fatalf("output-mode validation ran after identity resolution: %v", err)
}
})
}
}
func TestValidatePersonalEventOutputModeAllowsFlattenStructuredFormats(t *testing.T) {
for _, format := range []consume.Format{consume.FormatNDJSON, consume.FormatJSON, consume.FormatPretty, consume.FormatCompact} {
if err := validatePersonalEventOutputMode(true, false, format); err != nil {
t.Fatalf("validatePersonalEventOutputMode(true, false, %q) error = %v", format, err)
}
}
}
func TestEventConsumeFlattenFlagIsForwarded(t *testing.T) {
oldRun := eventRunPersonalConsume
t.Cleanup(func() { eventRunPersonalConsume = oldRun })
var got personalConsumeOptions
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
got = opts
return nil
}
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{personal.EventMention, "--flatten", "--format", "compact"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if !got.Flatten || got.Common.FormatRaw != "compact" {
t.Fatalf("forwarded options = %#v", got)
}
}
func TestEventConsumeDebugRawEventsRequiresUserMode(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
+46 -3
View File
@@ -188,7 +188,44 @@ func TestPersonalEventSchemaHidesSchemaIDs(t *testing.T) {
}
}
func TestPersonalEventSchemaUsesSingleJSONSchema(t *testing.T) {
func TestPersonalEventSchemaDefaultsToTransportEnvelope(t *testing.T) {
cmd := newEventSchemaCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{personal.EventSingleChat})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
var doc map[string]any
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
t.Fatalf("schema output is not JSON: %v\n%s", err, out.String())
}
if doc["jq_root_path"] != ".data | fromjson" {
t.Fatalf("jq_root_path = %#v, want .data | fromjson", doc["jq_root_path"])
}
schema, ok := doc["schema"].(map[string]any)
if !ok {
t.Fatalf("schema = %#v, want object", doc["schema"])
}
props, ok := schema["properties"].(map[string]any)
if !ok {
t.Fatalf("schema.properties = %#v, want object", schema["properties"])
}
for _, field := range []string{"type", "seq", "event_type", "data", "headers", "subscribe_id"} {
if _, ok := props[field]; !ok {
t.Fatalf("default envelope schema missing %q: %#v", field, props)
}
}
for _, field := range []string{"content", "sender", "conversation_id", "timestamp"} {
if _, ok := props[field]; ok {
t.Fatalf("default envelope schema unexpectedly contains flat field %q", field)
}
}
}
func TestPersonalEventFlattenedSchemaUsesSingleJSONSchema(t *testing.T) {
for _, eventKey := range []string{
personal.EventMention,
personal.EventSingleChat,
@@ -200,7 +237,7 @@ func TestPersonalEventSchemaUsesSingleJSONSchema(t *testing.T) {
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{eventKey})
cmd.SetArgs([]string{eventKey, "--flatten"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
@@ -316,7 +353,7 @@ func TestPersonalActionEventSchemaMatchesFlatOutput(t *testing.T) {
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{eventKey})
cmd.SetArgs([]string{eventKey, "--flatten"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
@@ -367,6 +404,9 @@ func TestEventSchemaDefaultsToUser(t *testing.T) {
if doc["event_key"] != personal.EventSingleChat {
t.Fatalf("event_key = %#v, want %s", doc["event_key"], personal.EventSingleChat)
}
if doc["jq_root_path"] != ".data | fromjson" {
t.Fatalf("jq_root_path = %#v, want default envelope path", doc["jq_root_path"])
}
}
func TestPersonalEventFromUserIsPubliclyAvailable(t *testing.T) {
@@ -469,6 +509,9 @@ func TestEventConsumeCobraSchemaIncludesOpenDingTalkID(t *testing.T) {
if _, ok := params["odid"]; ok {
t.Fatalf("schema parameters unexpectedly include odid alias: %#v", params)
}
if _, ok := params["flatten"]; !ok {
t.Fatalf("schema parameters missing flatten: %#v", params)
}
for _, name := range []string{"user", "open-dingtalk-id", "group"} {
param, ok := params[name].(map[string]any)
if !ok {
+10 -4
View File
@@ -27,21 +27,27 @@ import (
"github.com/spf13/cobra"
)
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller) []*cobra.Command {
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller, loadUserShortcuts bool) []*cobra.Command {
injectStaticServers()
helpers.InitDeps(caller)
commands := helpers.NewPublicCommands(runner)
// Load user-defined shortcuts (~/.dws/shortcuts/*.yaml) BEFORE compiling the
// command tree, so distilled high-frequency operations mount alongside the
// built-ins. Conflicts with built-ins are skipped inside Load.
if _, err := userdef.Load(); err != nil {
slog.Warn("shortcut: failed to load user-defined shortcuts", "error", err)
if loadUserShortcuts {
if _, err := userdef.Load(); err != nil {
slog.Warn("shortcut: failed to load user-defined shortcuts", "error", err)
}
}
// Built-in + user shortcuts (`dws <service> +<command>`) share the same
// command tree; mergeTopLevelCommands folds each shortcut's service parent
// into the matching helper command so the `+leaf` sits alongside existing
// subcommands.
commands = append(commands, builtin.Commands()...)
if loadUserShortcuts {
commands = append(commands, builtin.Commands()...)
} else {
commands = append(commands, builtin.BaseCommands()...)
}
return mergeTopLevelCommands(commands)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,675 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
type pluginFailRunner struct{}
func (pluginFailRunner) Run(context.Context, executor.Invocation) (executor.Result, error) {
return executor.Result{}, errors.New("runner failed")
}
type pluginWrongFlagValue struct{}
func (pluginWrongFlagValue) String() string { return "" }
func (pluginWrongFlagValue) Set(string) error { return nil }
func (pluginWrongFlagValue) Type() string { return "wrong" }
func TestPluginCompilerRejectsInvalidDuplicateAndEmptyDefinitions(t *testing.T) {
invalidRoot := conferencePluginDescriptor()
invalidRoot.CLI.Command = "Invalid Root"
if commands := buildPluginCommands([]mcptypes.ServerDescriptor{invalidRoot}, executor.EchoRunner{}, nil); len(commands) != 0 {
t.Fatalf("invalid root produced commands %#v", commands)
}
descriptor := conferencePluginDescriptor()
descriptor.CLI.Groups = map[string]mcptypes.CLIGroupDef{
"empty": {Description: "removed when no leaf survives"},
}
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"": {CLIName: "blank-tool"},
"hidden": {CLIName: "hidden", Hidden: true},
"invalid": {CLIName: "Invalid Leaf"},
"first": {CLIName: "same"},
"second": {CLIName: "same"},
}
commands := buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, executor.EchoRunner{}, nil)
if len(commands) != 1 {
t.Fatalf("commands = %#v", commands)
}
if requireOptionalPluginChild(commands[0], "same") == nil {
t.Fatal("valid leaf was not retained")
}
if requireOptionalPluginChild(commands[0], "empty") != nil {
t.Fatal("empty group was not pruned")
}
empty := conferencePluginDescriptor()
empty.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"hidden": {CLIName: "hidden", Hidden: true},
}
if commands := buildPluginCommands([]mcptypes.ServerDescriptor{empty}, executor.EchoRunner{}, nil); len(commands) != 0 {
t.Fatalf("empty overlay produced commands %#v", commands)
}
}
func TestPluginLeafExecutionErrorsAndBodyWrapper(t *testing.T) {
base := conferencePluginDescriptor()
base.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"wrapped": {
CLIName: "wrapped",
BodyWrapper: "body",
Flags: map[string]mcptypes.CLIFlagOverride{
"value": {Required: true},
},
},
}
runner := &pluginCaptureRunner{}
root := pluginTestRoot(buildPluginCommands([]mcptypes.ServerDescriptor{base}, runner, nil)...)
root.SetArgs([]string{"conference", "wrapped", "--value", "ok", "--params", `{"body":{"old":1},"_meta":"kept"}`})
if err := root.Execute(); err != nil {
t.Fatalf("wrapped command: %v", err)
}
want := map[string]any{
"_meta": "kept",
"body": map[string]any{"old": float64(1), "value": "ok"},
}
if !reflect.DeepEqual(runner.invocations[0].Params, want) {
t.Fatalf("wrapped params = %#v, want %#v", runner.invocations[0].Params, want)
}
for _, testCase := range []struct {
name string
runner executor.Runner
args []string
}{
{name: "invalid json", runner: executor.EchoRunner{}, args: []string{"conference", "wrapped", "--json", "["}},
{name: "missing required", runner: executor.EchoRunner{}, args: []string{"conference", "wrapped"}},
{name: "missing runner", runner: nil, args: []string{"conference", "wrapped", "--value", "ok"}},
{name: "runner error", runner: pluginFailRunner{}, args: []string{"conference", "wrapped", "--value", "ok"}},
} {
t.Run(testCase.name, func(t *testing.T) {
commandRoot := pluginTestRoot(buildPluginCommands([]mcptypes.ServerDescriptor{base}, testCase.runner, nil)...)
commandRoot.SetArgs(testCase.args)
if err := commandRoot.Execute(); err == nil {
t.Fatal("expected command error")
}
})
}
for _, flagName := range []string{"json", "params"} {
t.Run("unreadable "+flagName, func(t *testing.T) {
commands := buildPluginCommands([]mcptypes.ServerDescriptor{base}, executor.EchoRunner{}, nil)
leaf := requirePluginChild(t, commands[0], "wrapped")
leaf.Flags().Lookup(flagName).Value = pluginWrongFlagValue{}
commandRoot := pluginTestRoot(commands...)
commandRoot.SetArgs([]string{"conference", "wrapped", "--value", "ok"})
if err := commandRoot.Execute(); err == nil {
t.Fatal("expected unreadable flag error")
}
})
}
}
func TestPluginBindingCompilerCoversAliasesAndPositionalValidators(t *testing.T) {
reservations := pluginFlagReservations{
names: map[string]bool{"reserved": true},
shorthands: map[string]bool{},
}
bindings, _, _, ok := registerPluginBindings("alias", mcptypes.CLIToolOverride{
Flags: map[string]mcptypes.CLIFlagOverride{
"value": {Alias: "value", Aliases: []string{"", "Bad", "value", "other"}},
},
}, reservations)
if !ok || !reflect.DeepEqual(bindings[0].names, []string{"value", "other"}) {
t.Fatalf("alias bindings = (%#v, %v)", bindings, ok)
}
if _, _, _, ok := registerPluginBindings("conflict", mcptypes.CLIToolOverride{
Flags: map[string]mcptypes.CLIFlagOverride{"value": {Alias: "reserved"}},
}, reservations); ok {
t.Fatal("reserved flag was accepted")
}
if _, _, _, ok := registerPluginBindings("negative", mcptypes.CLIToolOverride{
Flags: map[string]mcptypes.CLIFlagOverride{"value": {Positional: true, PositionalIndex: -1}},
}, reservations); ok {
t.Fatal("negative positional index was accepted")
}
if _, _, _, ok := registerPluginBindings("duplicate", mcptypes.CLIToolOverride{
Flags: map[string]mcptypes.CLIFlagOverride{
"first": {Positional: true, PositionalIndex: 0},
"second": {Positional: true, PositionalIndex: 0},
},
}, reservations); ok {
t.Fatal("duplicate positional index was accepted")
}
if _, _, _, ok := registerPluginBindings("gap", mcptypes.CLIToolOverride{
Flags: map[string]mcptypes.CLIFlagOverride{
"second": {Positional: true, PositionalIndex: 1},
},
}, reservations); ok {
t.Fatal("non-contiguous positional indexes were accepted")
}
for _, testCase := range []struct {
name string
flags map[string]mcptypes.CLIFlagOverride
wantUse string
valid []string
invalid []string
}{
{
name: "exact",
flags: map[string]mcptypes.CLIFlagOverride{
"second": {Positional: true, PositionalIndex: 1, Required: true},
"first": {Positional: true, PositionalIndex: 0, Required: true},
},
wantUse: "exact [first] [second]", valid: []string{"a", "b"}, invalid: []string{"a"},
},
{
name: "range",
flags: map[string]mcptypes.CLIFlagOverride{
"first": {Positional: true, PositionalIndex: 0, Required: true},
"second": {Positional: true, PositionalIndex: 1},
},
wantUse: "range [first] [second]", valid: []string{"a"}, invalid: []string{},
},
{
name: "maximum",
flags: map[string]mcptypes.CLIFlagOverride{
"first": {Positional: true, PositionalIndex: 0},
"second": {Positional: true, PositionalIndex: 1},
},
wantUse: "maximum [first] [second]", valid: []string{}, invalid: []string{"a", "b", "c"},
},
} {
t.Run(testCase.name, func(t *testing.T) {
_, use, validator, ok := registerPluginBindings(testCase.name, mcptypes.CLIToolOverride{Flags: testCase.flags}, reservations)
if !ok || use != testCase.wantUse {
t.Fatalf("binding contract = (%q, %v)", use, ok)
}
cmd := &cobra.Command{Use: testCase.name}
if err := validator(cmd, testCase.valid); err != nil {
t.Fatalf("valid args: %v", err)
}
if err := validator(cmd, testCase.invalid); err == nil {
t.Fatal("invalid args were accepted")
}
})
}
}
func TestPluginFlagRegistrationAndReadingCoversAllKinds(t *testing.T) {
cmd := &cobra.Command{Use: "leaf"}
override := mcptypes.CLIToolOverride{Flags: map[string]mcptypes.CLIFlagOverride{
"integer": {Default: "2", Shorthand: "i", Hidden: true},
"float": {Default: "1.5"},
"boolean": {Default: "true"},
"slice": {Default: "one, ,two"},
"json": {Default: `{"old":true}`},
"string": {Default: "text"},
}}
bindings := []pluginFlagBinding{
{property: "integer", names: []string{"integer", "integer-alias"}, kind: pluginFlagInt},
{property: "float", names: []string{"float"}, kind: pluginFlagFloat},
{property: "boolean", names: []string{"boolean"}, kind: pluginFlagBool},
{property: "slice", names: []string{"slice"}, kind: pluginFlagStringSlice},
{property: "json", names: []string{"json-value"}, kind: pluginFlagJSON},
{property: "string", names: []string{"string"}, kind: pluginFlagString},
}
registerPluginFlags(cmd, bindings, override, pluginFlagReservations{shorthands: map[string]bool{}})
for name, raw := range map[string]string{
"integer": "3", "float": "2.5", "boolean": "false",
"slice": "three,four", "json-value": `{"ok":true}`, "string": "changed",
} {
if err := cmd.Flags().Set(name, raw); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
wants := map[string]any{
"integer": 3,
"float": 2.5,
"boolean": false,
"slice": []string{"three", "four"},
"json": map[string]any{"ok": true},
"string": "changed",
}
for _, binding := range bindings {
value, err := readPluginFlag(cmd.Flags(), binding.names[0], binding.kind)
if err != nil || !reflect.DeepEqual(value, wants[binding.property]) {
t.Fatalf("read %s = (%#v, %v), want %#v", binding.property, value, err, wants[binding.property])
}
}
if !cmd.Flags().Lookup("integer").Hidden || !cmd.Flags().Lookup("integer-alias").Hidden {
t.Fatal("hidden primary or alias flag was exposed")
}
if err := cmd.Flags().Set("json-value", "{"); err != nil {
t.Fatal(err)
}
if _, err := readPluginFlag(cmd.Flags(), "json-value", pluginFlagJSON); err == nil {
t.Fatal("invalid JSON flag was accepted")
}
cmd.Flags().Lookup("json-value").Value = pluginWrongFlagValue{}
if _, err := readPluginFlag(cmd.Flags(), "json-value", pluginFlagJSON); err == nil {
t.Fatal("wrong JSON flag type was accepted")
}
}
func TestCollectPluginBindingsCoversEveryValueSourceAndFailure(t *testing.T) {
t.Run("sources", func(t *testing.T) {
cmd := &cobra.Command{Use: "leaf"}
registerPluginFlag(cmd.Flags(), "flag", "", "", pluginFlagString, "")
if err := cmd.Flags().Set("flag", "from-flag"); err != nil {
t.Fatal(err)
}
t.Setenv("PLUGIN_COVERAGE_ENV", "7")
params := map[string]any{"existing": "from-json"}
bindings := []pluginFlagBinding{
{property: "flag", names: []string{"flag"}, kind: pluginFlagString},
{property: "existing", kind: pluginFlagString},
{property: "positional", kind: pluginFlagBool, positional: true, positionalIndex: 0},
{property: "default", kind: pluginFlagFloat, defaultProvided: true, defaultValue: "1.5"},
{property: "env", kind: pluginFlagInt, envDefault: "PLUGIN_COVERAGE_ENV"},
{property: "optional", kind: pluginFlagString},
}
if err := collectPluginBindings(cmd, []string{"true"}, bindings, params); err != nil {
t.Fatal(err)
}
want := map[string]any{
"flag": "from-flag", "existing": "from-json", "positional": true,
"default": 1.5, "env": 7,
}
if !reflect.DeepEqual(params, want) {
t.Fatalf("params = %#v, want %#v", params, want)
}
})
for _, testCase := range []struct {
name string
prepare func(t *testing.T, cmd *cobra.Command)
args []string
binding pluginFlagBinding
params map[string]any
}{
{
name: "wrong flag type",
prepare: func(t *testing.T, cmd *cobra.Command) {
cmd.Flags().String("value", "", "")
if err := cmd.Flags().Set("value", "x"); err != nil {
t.Fatal(err)
}
},
binding: pluginFlagBinding{property: "value", names: []string{"value"}, kind: pluginFlagInt},
},
{name: "invalid positional", args: []string{"maybe"}, binding: pluginFlagBinding{property: "value", kind: pluginFlagBool, positional: true, positionalIndex: 0}},
{name: "invalid default", binding: pluginFlagBinding{property: "value", kind: pluginFlagInt, defaultProvided: true, defaultValue: "bad"}},
{
name: "invalid env",
prepare: func(t *testing.T, _ *cobra.Command) { t.Setenv("PLUGIN_COVERAGE_BAD_ENV", "bad") },
binding: pluginFlagBinding{property: "value", kind: pluginFlagInt, envDefault: "PLUGIN_COVERAGE_BAD_ENV"},
},
{name: "missing named required", binding: pluginFlagBinding{property: "value", names: []string{"value"}, required: true}},
{name: "missing positional required", binding: pluginFlagBinding{property: "value", required: true, positional: true, positionalIndex: 0}},
{name: "required omitted", binding: pluginFlagBinding{property: "value", required: true, defaultProvided: true, defaultValue: "", omitWhen: "empty"}},
} {
t.Run(testCase.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "leaf"}
if testCase.prepare != nil {
testCase.prepare(t, cmd)
}
if err := collectPluginBindings(cmd, testCase.args, []pluginFlagBinding{testCase.binding}, testCase.params); err == nil {
t.Fatal("expected binding error")
}
})
}
params := map[string]any{"value": ""}
if err := collectPluginBindings(&cobra.Command{Use: "leaf"}, nil, []pluginFlagBinding{{
property: "value", kind: pluginFlagString, omitWhen: "empty",
}}, params); err != nil {
t.Fatal(err)
}
if _, exists := params["value"]; exists {
t.Fatal("optional empty value was not omitted")
}
}
func TestPluginValueAndNamingHelpers(t *testing.T) {
parseCases := []struct {
kind pluginFlagKind
raw string
want any
}{
{pluginFlagInt, " 2 ", 2},
{pluginFlagFloat, " 2.5 ", 2.5},
{pluginFlagBool, "true", true},
{pluginFlagStringSlice, "one, ,two", []string{"one", "two"}},
{pluginFlagJSON, `{"ok":true}`, map[string]any{"ok": true}},
{pluginFlagString, " raw ", " raw "},
}
for _, testCase := range parseCases {
got, err := parsePluginValue(testCase.raw, testCase.kind)
if err != nil || !reflect.DeepEqual(got, testCase.want) {
t.Fatalf("parse %q = (%#v, %v), want %#v", testCase.raw, got, err, testCase.want)
}
}
for _, testCase := range []struct {
kind pluginFlagKind
raw string
}{
{pluginFlagInt, "bad"}, {pluginFlagFloat, "bad"}, {pluginFlagBool, "bad"}, {pluginFlagJSON, "{"},
} {
if _, err := parsePluginValue(testCase.raw, testCase.kind); err == nil {
t.Fatalf("invalid %q was accepted", testCase.raw)
}
}
omitCases := []struct {
value any
mode string
want bool
}{
{nil, "", true}, {" ", "", true}, {[]string{}, "", true},
{"", "never", false}, {false, "zero", true}, {0, "zero", true},
{float64(0), "zero", true}, {true, "zero", false}, {1, "zero", false},
{float64(1), "zero", false}, {[]any{}, "zero", true}, {map[string]any{}, "zero", true},
{[]any{"value"}, "zero", false}, {map[string]any{"value": true}, "zero", false},
{struct{}{}, "zero", false}, {false, "", false},
}
for _, testCase := range omitCases {
if got := shouldOmitPluginValue(testCase.value, testCase.mode); got != testCase.want {
t.Fatalf("omit (%#v, %q) = %v, want %v", testCase.value, testCase.mode, got, testCase.want)
}
}
wrapPluginParams(nil, "body")
untouched := map[string]any{"value": 1}
wrapPluginParams(untouched, " ")
wrapped := map[string]any{"body": map[string]any{"old": 1}, "value": 2, "_meta": 3}
wrapPluginParams(wrapped, "body")
wantWrapped := map[string]any{"body": map[string]any{"old": 1, "value": 2}, "_meta": 3}
if !reflect.DeepEqual(wrapped, wantWrapped) {
t.Fatalf("wrapped = %#v, want %#v", wrapped, wantWrapped)
}
kinds := map[string]pluginFlagKind{
"int": pluginFlagInt, "integer": pluginFlagInt,
"float": pluginFlagFloat, "float64": pluginFlagFloat, "number": pluginFlagFloat,
"bool": pluginFlagBool, "boolean": pluginFlagBool,
"stringSlice": pluginFlagStringSlice, "string_slice": pluginFlagStringSlice,
"array": pluginFlagStringSlice, "[]string": pluginFlagStringSlice,
"json": pluginFlagJSON, "object": pluginFlagJSON, "unknown": pluginFlagString,
}
for raw, want := range kinds {
if got := pluginFlagKindFromString(raw); got != want {
t.Fatalf("kind %q = %v, want %v", raw, got, want)
}
}
used := map[string]bool{}
reserved := map[string]bool{"r": true}
if got := safePluginShorthand(" x ", used, reserved); got != "x" || !used["x"] {
t.Fatalf("safe shorthand = %q / %#v", got, used)
}
for _, raw := range []string{"", "xy", "x", "r"} {
if got := safePluginShorthand(raw, used, reserved); got != "" {
t.Fatalf("unsafe shorthand %q = %q", raw, got)
}
}
baseReservations := pluginReservedFlags(nil)
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().StringP("custom", "c", "", "")
rootReservations := pluginReservedFlags(root)
if !baseReservations.names["yes"] || !rootReservations.names["custom"] || !rootReservations.shorthands["c"] {
t.Fatalf("reservations = %#v / %#v", baseReservations, rootReservations)
}
if got := safePluginAliases([]string{"", "help", "auth", "cmd", "cmd", "ok", "Bad"}, "cmd"); !reflect.DeepEqual(got, []string{"ok"}) {
t.Fatalf("aliases = %#v", got)
}
if got := derivePluginCommandName("conference_getCurrent2Status", []string{"other", "conference"}); got != "get-current2-status" {
t.Fatalf("derived name = %q", got)
}
if got := pluginKebabName(" HTTP2.Foo_bar baz@ "); got != "http2-foo-bar-baz@" {
t.Fatalf("kebab name = %q", got)
}
for _, name := range []string{"", "1bad", "bad-", "bad--name", "bad_name", "bad@name"} {
if validPluginKebabName(name) {
t.Fatalf("invalid kebab name %q was accepted", name)
}
}
if !validPluginKebabName("good-name2") || validPluginCommandName("help") || validPluginFlagName("json") || validPluginFlagName("params") {
t.Fatal("name validation contract failed")
}
if got := firstNonEmptyPluginString(" ", " value "); got != "value" || firstNonEmptyPluginString("", " ") != "" {
t.Fatal("first non-empty string contract failed")
}
}
func TestPluginConstraintGroupAndRootHelpers(t *testing.T) {
cmd := &cobra.Command{Use: "leaf"}
for _, name := range []string{"a", "b", "c"} {
cmd.Flags().String(name, "", "")
}
applyPluginFlagConstraints(cmd, mcptypes.CLIToolOverride{
MutuallyExclusive: [][]string{{"a", "b"}, {"a", "missing"}},
RequireOneOf: [][]string{{"a", "b"}, {"missing"}},
RequireTogether: [][]string{{"b", "c"}, {"c", "missing"}},
})
bindings := []pluginFlagBinding{{names: []string{"a"}}, {names: []string{"b"}}, {names: []string{"c"}}}
if !validPluginFlagConstraints(bindings, mcptypes.CLIToolOverride{
MutuallyExclusive: [][]string{{"a", "b"}},
RequireOneOf: [][]string{{"a"}},
RequireTogether: [][]string{{"b", "c"}},
}) {
t.Fatal("valid plugin constraints were rejected")
}
for _, invalid := range []mcptypes.CLIToolOverride{
{MutuallyExclusive: [][]string{{"a"}}},
{RequireOneOf: [][]string{{"missing"}}},
{RequireTogether: [][]string{{"a", "a"}}},
} {
if validPluginFlagConstraints(bindings, invalid) {
t.Fatalf("invalid plugin constraints were accepted: %#v", invalid)
}
}
groups := map[string]*cobra.Command{}
root := &cobra.Command{Use: "root"}
group := ensurePluginGroup(root, "parent.child", "child description", groups)
if group.Name() != "child" || group.Short != "child description" || !cmdutil.IsPluginSourced(group) {
t.Fatalf("group = %#v", group)
}
if again := ensurePluginGroup(root, "parent.child", "ignored", groups); again != group {
t.Fatal("existing group was not reused")
}
for _, invalid := range []string{"safe.bad_name", "_bad", ".parent", "parent."} {
if got := ensurePluginGroup(root, invalid, "invalid", groups); got != nil {
t.Fatalf("invalid group path %q produced %#v", invalid, got)
}
}
mergePluginRoot(nil, root)
mergePluginRoot(root, nil)
destination := &cobra.Command{Use: "plugin", Aliases: []string{"one"}}
source := &cobra.Command{Use: "plugin", Aliases: []string{"one", "two"}}
source.AddCommand(&cobra.Command{Use: "leaf"})
mergePluginRoot(destination, source)
if !reflect.DeepEqual(destination.Aliases, []string{"one", "two"}) || requireOptionalPluginChild(destination, "leaf") == nil {
t.Fatalf("merged root = %#v", destination)
}
pruneEmptyPluginGroups(nil)
pruneRoot := &cobra.Command{Use: "root"}
empty := cobracmd.NewGroupCommand("empty", "empty")
nonEmpty := cobracmd.NewGroupCommand("non-empty", "non-empty")
nonEmpty.AddCommand(&cobra.Command{Use: "leaf"})
pruneRoot.AddCommand(empty, nonEmpty)
pruneEmptyPluginGroups(pruneRoot)
if requireOptionalPluginChild(pruneRoot, "empty") != nil || requireOptionalPluginChild(pruneRoot, "non-empty") == nil {
t.Fatal("empty plugin groups were not pruned correctly")
}
if pluginRootBoolFlag(nil, "yes") {
t.Fatal("nil command reported a root flag")
}
noFlag := &cobra.Command{Use: "root"}
if pluginRootBoolFlag(noFlag, "yes") {
t.Fatal("missing flag reported true")
}
wrongType := &cobra.Command{Use: "root"}
wrongType.PersistentFlags().String("yes", "true", "")
if pluginRootBoolFlag(wrongType, "yes") {
t.Fatal("wrong flag type reported true")
}
boolRoot := &cobra.Command{Use: "root"}
boolRoot.PersistentFlags().Bool("yes", false, "")
if err := boolRoot.PersistentFlags().Set("yes", "true"); err != nil {
t.Fatal(err)
}
if !pluginRootBoolFlag(boolRoot, "yes") {
t.Fatal("true root flag was not observed")
}
if err := pluginConfirmationRequired("dws plugin"); err == nil || !strings.Contains(err.Error(), "sensitive") {
t.Fatalf("confirmation error = %v", err)
}
}
func TestUnsupportedPluginSemanticsReportEveryField(t *testing.T) {
overlays := []struct {
value mcptypes.CLIOverlay
want string
}{
{mcptypes.CLIOverlay{Parent: "root"}, "parent"},
{mcptypes.CLIOverlay{Group: "group"}, "group"},
{mcptypes.CLIOverlay{ServerDeps: []string{"other"}}, "serverDeps"},
{mcptypes.CLIOverlay{Hints: map[string]json.RawMessage{"x": json.RawMessage(`{}`)}}, "hintCommands"},
{mcptypes.CLIOverlay{RedirectTo: "other"}, "redirectTo"},
{mcptypes.CLIOverlay{}, ""},
}
for _, testCase := range overlays {
if got := unsupportedPluginOverlay(testCase.value); got != testCase.want {
t.Fatalf("unsupported overlay = %q, want %q", got, testCase.want)
}
}
tools := []struct {
value mcptypes.CLIToolOverride
want string
}{
{mcptypes.CLIToolOverride{CLIAliases: []string{"x"}}, "cliAliases"},
{mcptypes.CLIToolOverride{OutputFormat: map[string]any{"x": true}}, "outputFormat"},
{mcptypes.CLIToolOverride{ServerOverride: "other"}, "serverOverride"},
{mcptypes.CLIToolOverride{RedirectTo: "x"}, "redirectTo"},
{mcptypes.CLIToolOverride{Pipeline: []json.RawMessage{json.RawMessage(`{}`)}}, "pipeline"},
{mcptypes.CLIToolOverride{}, ""},
}
for _, testCase := range tools {
if got := unsupportedPluginToolOverride(testCase.value); got != testCase.want {
t.Fatalf("unsupported tool = %q, want %q", got, testCase.want)
}
}
flags := []struct {
value mcptypes.CLIFlagOverride
want string
}{
{mcptypes.CLIFlagOverride{MapsTo: "x"}, "mapsTo"},
{mcptypes.CLIFlagOverride{Transform: "x"}, "transform"},
{mcptypes.CLIFlagOverride{TransformArgs: map[string]any{"x": true}}, "transformArgs"},
{mcptypes.CLIFlagOverride{RuntimeDefault: "x"}, "runtimeDefault"},
{mcptypes.CLIFlagOverride{PipelineLocal: true}, "pipelineLocal"},
{mcptypes.CLIFlagOverride{Type: "mystery"}, "type"},
{mcptypes.CLIFlagOverride{OmitWhen: "sometimes"}, "omitWhen"},
{mcptypes.CLIFlagOverride{}, ""},
}
for _, testCase := range flags {
if got := unsupportedPluginFlagOverride(testCase.value); got != testCase.want {
t.Fatalf("unsupported flag = %q, want %q", got, testCase.want)
}
}
for _, value := range []string{"", "string", "integer", "float64", "boolean", "stringSlice", "array", "json", "object"} {
if !supportedPluginFlagType(value) {
t.Fatalf("supported plugin flag type %q was rejected", value)
}
}
for _, value := range []string{"", "empty", "zero", "never"} {
if !supportedPluginOmitMode(value) {
t.Fatalf("supported plugin omit mode %q was rejected", value)
}
}
}
func TestUnsupportedPluginDescriptorRejectsEveryInvalidLayer(t *testing.T) {
testCases := []struct {
name string
mutate func(*mcptypes.ServerDescriptor)
want string
}{
{name: "overlay", mutate: func(value *mcptypes.ServerDescriptor) { value.CLI.Parent = "root" }, want: "parent"},
{name: "no tools", mutate: func(value *mcptypes.ServerDescriptor) { value.CLI.ToolOverrides = nil }, want: ""},
{name: "root", mutate: func(value *mcptypes.ServerDescriptor) { value.CLI.Command = "Bad" }, want: "command"},
{name: "declared group", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.Groups = map[string]mcptypes.CLIGroupDef{"bad_name": {}}
}, want: "groups"},
{name: "blank tool", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"": {}}
}, want: "tool"},
{name: "tool semantics", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {ServerOverride: "drive"}}
}, want: "serverOverride"},
{name: "hidden tool", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {Hidden: true, ServerOverride: "drive"}}
}, want: ""},
{name: "derived leaf", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"conference_derived_tool": {}}
}, want: ""},
{name: "leaf", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {CLIName: "Bad"}}
}, want: "cliName"},
{name: "leaf group", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {CLIName: "leaf", Group: "bad_name"}}
}, want: "group"},
{name: "flags", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {
CLIName: "leaf",
Flags: map[string]mcptypes.CLIFlagOverride{"value": {Alias: "yes"}},
}}
}, want: "flags"},
{name: "constraints", mutate: func(value *mcptypes.ServerDescriptor) {
value.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{"tool": {
CLIName: "leaf",
Flags: map[string]mcptypes.CLIFlagOverride{"value": {}},
RequireTogether: [][]string{{"value", "missing"}},
}}
}, want: "constraints"},
{name: "valid", want: ""},
}
root := pluginTestRoot()
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
descriptor := conferencePluginDescriptor()
if testCase.mutate != nil {
testCase.mutate(&descriptor)
}
if got := unsupportedPluginDescriptor(root, descriptor); got != testCase.want {
t.Fatalf("unsupported descriptor = %q, want %q", got, testCase.want)
}
})
}
}
+809
View File
@@ -0,0 +1,809 @@
// 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"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"sync/atomic"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"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/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
type pluginCaptureRunner struct {
invocations []executor.Invocation
}
func (r *pluginCaptureRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.invocations = append(r.invocations, invocation)
return executor.Result{Invocation: invocation}, nil
}
func conferencePluginDescriptor() mcptypes.ServerDescriptor {
return mcptypes.ServerDescriptor{
Key: "conference-local",
DisplayName: "conference/conference-local",
Description: "conference plugin",
Endpoint: "stdio://conference/conference-local",
Source: "plugin",
HasCLIMeta: true,
CLI: mcptypes.CLIOverlay{
ID: "conference-local",
Command: "conference",
Description: "视频会议:发起/邀请入会/会中控制",
Prefixes: []string{"conference"},
Groups: map[string]mcptypes.CLIGroupDef{
"camera": {Description: "摄像头控制"},
"mic": {Description: "麦克风控制"},
"share": {Description: "屏幕共享"},
},
ToolOverrides: map[string]mcptypes.CLIToolOverride{
"create_conference": {
CLIName: "start",
Description: "发起即时会议",
Flags: map[string]mcptypes.CLIFlagOverride{
"title": {Description: "会议标题"},
},
},
"get_conference_status": {
CLIName: "status",
Description: "查询当前会议状态",
},
"ai_end_meeting_for_all": {
CLIName: "end",
Description: "结束会议(所有人)",
IsSensitive: true,
},
"ai_open_camera": {
CLIName: "open",
Group: "camera",
Description: "打开摄像头",
},
"ai_mute_mic": {
CLIName: "mute",
Group: "mic",
Description: "静音自己",
},
"ai_share_desktop": {
CLIName: "start",
Group: "share",
Description: "开始共享桌面",
Flags: map[string]mcptypes.CLIFlagOverride{
"capture_speaker": {Description: "是否共享电脑音频"},
},
},
},
},
}
}
func pluginTestRoot(commands ...*cobra.Command) *cobra.Command {
root := &cobra.Command{
Use: "dws",
SilenceErrors: true,
SilenceUsage: true,
}
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().StringP("format", "f", "json", "")
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.AddCommand(commands...)
return root
}
func requirePluginChild(t *testing.T, parent *cobra.Command, names ...string) *cobra.Command {
t.Helper()
current := parent
for _, name := range names {
var next *cobra.Command
for _, child := range current.Commands() {
if child.Name() == name {
next = child
break
}
}
if next == nil {
t.Fatalf("missing plugin command %q below %q", name, current.CommandPath())
}
current = next
}
return current
}
func TestPluginOverlayBuildsConferenceTreeAndDispatchesOriginalProperties(t *testing.T) {
runner := &pluginCaptureRunner{}
commands := buildPluginCommands([]mcptypes.ServerDescriptor{conferencePluginDescriptor()}, runner, nil)
if len(commands) != 1 {
t.Fatalf("plugin roots = %d, want 1", len(commands))
}
conference := commands[0]
if conference.Name() != "conference" || conference.Short != "视频会议:发起/邀请入会/会中控制" {
t.Fatalf("conference root = %q / %q", conference.Name(), conference.Short)
}
if !cmdutil.IsPluginSourced(conference) {
t.Fatal("conference root is missing plugin provenance")
}
if got := requirePluginChild(t, conference, "camera").Short; got != "摄像头控制" {
t.Fatalf("camera group short = %q", got)
}
if got := requirePluginChild(t, conference, "camera", "open").Short; got != "打开摄像头" {
t.Fatalf("camera open short = %q", got)
}
requirePluginChild(t, conference, "mic", "mute")
requirePluginChild(t, conference, "status")
share := requirePluginChild(t, conference, "share", "start")
flag := share.Flags().Lookup("capture-speaker")
if flag == nil || flag.Usage != "是否共享电脑音频" {
t.Fatalf("capture-speaker flag = %#v", flag)
}
root := pluginTestRoot(commands...)
root.SetArgs([]string{
"conference", "start",
"--json", `{"from_json":"kept","title":"json"}`,
"--params", `{"from_params":2,"title":"params"}`,
"--title", "验证会议",
"--dry-run",
})
if err := root.Execute(); err != nil {
t.Fatalf("conference start: %v", err)
}
if len(runner.invocations) != 1 {
t.Fatalf("runner calls = %d, want 1", len(runner.invocations))
}
invocation := runner.invocations[0]
if invocation.Kind != "compat_invocation" ||
invocation.CanonicalProduct != "conference-local" ||
invocation.Tool != "create_conference" ||
!invocation.DryRun {
t.Fatalf("conference invocation = %#v", invocation)
}
wantParams := map[string]any{
"from_json": "kept",
"from_params": float64(2),
"title": "验证会议",
}
if !reflect.DeepEqual(invocation.Params, wantParams) {
t.Fatalf("conference params = %#v, want %#v", invocation.Params, wantParams)
}
precedenceRunner := &pluginCaptureRunner{}
precedenceRoot := pluginTestRoot(buildPluginCommands(
[]mcptypes.ServerDescriptor{conferencePluginDescriptor()},
precedenceRunner,
nil,
)...)
precedenceRoot.SetArgs([]string{
"conference", "start",
"--json", `{"title":"json"}`,
"--params", `{"title":"params"}`,
"--dry-run",
})
if err := precedenceRoot.Execute(); err != nil {
t.Fatalf("conference payload precedence: %v", err)
}
if got := precedenceRunner.invocations[0].Params["title"]; got != "params" {
t.Fatalf("conference payload title = %#v, want --params value", got)
}
}
func TestPluginOverlayTypedFlags(t *testing.T) {
descriptor := conferencePluginDescriptor()
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"typed_tool": {
CLIName: "typed",
Flags: map[string]mcptypes.CLIFlagOverride{
"conversationId": {Required: true, Description: "conversation"},
"enabled": {Type: "bool"},
"limit": {Type: "int"},
"tags": {Type: "stringSlice"},
},
},
}
runner := &pluginCaptureRunner{}
root := pluginTestRoot(buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, runner, nil)...)
root.SetArgs([]string{
"conference", "typed",
"--conversation-id", "cid",
"--enabled=false",
"--limit", "3",
"--tags", "one,two",
"--dry-run",
})
if err := root.Execute(); err != nil {
t.Fatalf("typed plugin command: %v", err)
}
if len(runner.invocations) != 1 {
t.Fatalf("runner calls = %d", len(runner.invocations))
}
invocation := runner.invocations[0]
if invocation.CanonicalProduct != "conference-local" {
t.Fatalf("canonical product = %q", invocation.CanonicalProduct)
}
want := map[string]any{
"conversationId": "cid",
"enabled": false,
"limit": 3,
"tags": []string{"one", "two"},
}
if !reflect.DeepEqual(invocation.Params, want) {
t.Fatalf("typed params = %#v, want %#v", invocation.Params, want)
}
}
func TestPluginSensitiveCommandRequiresConfirmation(t *testing.T) {
for _, testCase := range []struct {
name string
args []string
wantCalls int
wantDry bool
wantError bool
}{
{name: "blocked", args: []string{"conference", "end"}, wantError: true},
{name: "preview", args: []string{"conference", "end", "--dry-run"}, wantCalls: 1, wantDry: true},
{name: "confirmed", args: []string{"conference", "end", "--yes"}, wantCalls: 1},
} {
t.Run(testCase.name, func(t *testing.T) {
runner := &pluginCaptureRunner{}
root := pluginTestRoot(buildPluginCommands(
[]mcptypes.ServerDescriptor{conferencePluginDescriptor()}, runner, nil)...)
root.SetArgs(testCase.args)
err := root.Execute()
if testCase.wantError {
var appErr *apperrors.Error
if !errors.As(err, &appErr) ||
appErr.Category != apperrors.CategoryValidation ||
appErr.Reason != "confirmation_required" {
t.Fatalf("sensitive error = %#v", err)
}
} else if err != nil {
t.Fatalf("sensitive command: %v", err)
}
if len(runner.invocations) != testCase.wantCalls {
t.Fatalf("runner calls = %d, want %d", len(runner.invocations), testCase.wantCalls)
}
if testCase.wantCalls == 1 && runner.invocations[0].DryRun != testCase.wantDry {
t.Fatalf("dry-run = %v, want %v", runner.invocations[0].DryRun, testCase.wantDry)
}
})
}
}
func TestPluginOverlayMergesServersWithoutProbingHTTP(t *testing.T) {
isolatePluginRuntime(t)
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
calls.Add(1)
}))
defer server.Close()
first := conferencePluginDescriptor()
first.Endpoint = server.URL
first.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"one": {CLIName: "one"},
}
second := first
second.Key = "conference-extra"
second.DisplayName = "conference/conference-extra"
second.Endpoint = server.URL + "/extra"
second.CLI.ID = "conference-extra"
second.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"two": {CLIName: "two"},
}
registerPluginHTTPServer(first)
registerPluginHTTPServer(second)
runner := &pluginCaptureRunner{}
commands := buildPluginCommands([]mcptypes.ServerDescriptor{second, first}, runner, nil)
if len(commands) != 1 {
t.Fatalf("merged roots = %d, want 1", len(commands))
}
requirePluginChild(t, commands[0], "one")
requirePluginChild(t, commands[0], "two")
root := pluginTestRoot(commands...)
root.SetArgs([]string{"conference", "--help"})
if err := root.Execute(); err != nil {
t.Fatalf("conference help: %v", err)
}
if got := calls.Load(); got != 0 {
t.Fatalf("HTTP calls while building help = %d, want 0", got)
}
for _, command := range []string{"one", "two"} {
root.SetArgs([]string{"conference", command, "--dry-run"})
if err := root.Execute(); err != nil {
t.Fatalf("conference %s: %v", command, err)
}
}
if len(runner.invocations) != 2 ||
runner.invocations[0].CanonicalProduct != "conference-local" ||
runner.invocations[1].CanonicalProduct != "conference-extra" {
t.Fatalf("merged routes = %#v", runner.invocations)
}
}
func TestPluginCanReplaceHiddenFallbackButNotVisibleDistributionCommand(t *testing.T) {
root := &cobra.Command{Use: "dws"}
fallback := &cobra.Command{Use: "conference", Hidden: true}
fallback.AddCommand(&cobra.Command{Use: "meeting"})
distribution := &cobra.Command{Use: "drive"}
root.AddCommand(fallback, distribution)
conference := buildPluginCommands(
[]mcptypes.ServerDescriptor{conferencePluginDescriptor()},
executor.EchoRunner{},
nil,
)[0]
drive := &cobra.Command{Use: "drive"}
cmdutil.MarkPluginSource(drive)
addPluginCommandsSafe(root, []*cobra.Command{conference, drive})
gotConference := requirePluginChild(t, root, "conference")
if gotConference == fallback || gotConference.Hidden {
t.Fatalf("conference fallback was not replaced: %#v", gotConference)
}
requirePluginChild(t, gotConference, "status")
if gotDrive := requirePluginChild(t, root, "drive"); gotDrive != distribution {
t.Fatal("visible distribution command was replaced by a plugin")
}
}
func TestConflictingPluginDescriptorCannotReplaceDistributionEndpoint(t *testing.T) {
isolatePluginRuntime(t)
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
pluginDir := filepath.Join(configDir, "plugins", "user", "drive-hijack")
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}
manifest := `{
"name":"drive-hijack",
"version":"1.0.0",
"mcpServers":{
"drive":{
"type":"streamable-http",
"endpoint":"https://plugin.invalid/mcp",
"cli":{
"id":"drive-service",
"command":"drive-hijack",
"toolOverrides":{"plugin_tool":{"cliName":"plugin-tool"}}
}
}
}
}`
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifest), 0o600); err != nil {
t.Fatal(err)
}
AppendDynamicServer(mcptypes.ServerDescriptor{
Key: "drive",
Endpoint: "https://distribution.invalid/mcp",
CLI: mcptypes.CLIOverlay{ID: "drive-service", Command: "drive"},
})
root := &cobra.Command{Use: "dws"}
root.AddCommand(&cobra.Command{Use: "drive"})
if commands := loadPlugins(root, nil, executor.EchoRunner{}); len(commands) != 0 {
t.Fatalf("conflicting plugin commands = %#v", commands)
}
if endpoint, ok := directRuntimeEndpoint("drive-service", "plugin_tool"); !ok ||
endpoint != "https://distribution.invalid/mcp" {
t.Fatalf("drive endpoint after rejected plugin = (%q, %v)", endpoint, ok)
}
}
func TestSchemaSourceRootDoesNotLoadRuntimePlugins(t *testing.T) {
isolatePluginRuntime(t)
previous := rootLoadPlugins
t.Cleanup(func() { rootLoadPlugins = previous })
var calls atomic.Int32
rootLoadPlugins = func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command {
calls.Add(1)
AppendDynamicServer(conferencePluginDescriptor())
return buildPluginCommands(
[]mcptypes.ServerDescriptor{conferencePluginDescriptor()},
executor.EchoRunner{},
nil,
)
}
base := NewSchemaSourceRootCommand()
if calls.Load() != 0 {
t.Fatalf("Schema source root loaded plugins %d times", calls.Load())
}
baseConference := requirePluginChild(t, base, "conference")
if !baseConference.Hidden || requireOptionalPluginChild(baseConference, "status") != nil {
t.Fatal("Schema source root contains installed conference plugin commands")
}
runtime := NewRootCommand()
if calls.Load() != 1 {
t.Fatalf("runtime root plugin loads = %d, want 1", calls.Load())
}
runtimeConference := requirePluginChild(t, runtime, "conference")
if runtimeConference.Hidden {
t.Fatal("runtime conference plugin is hidden")
}
requirePluginChild(t, runtimeConference, "status")
}
func requireOptionalPluginChild(parent *cobra.Command, name string) *cobra.Command {
for _, child := range parent.Commands() {
if child.Name() == name {
return child
}
}
return nil
}
func TestPluginDerivedNamesAndReservedAliases(t *testing.T) {
descriptor := conferencePluginDescriptor()
descriptor.CLI.Aliases = []string{"auth", "conf", "conf"}
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"conference_getCurrentStatus": {},
}
commands := buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, executor.EchoRunner{}, nil)
if len(commands) != 1 || !reflect.DeepEqual(commands[0].Aliases, []string{"conf"}) {
t.Fatalf("plugin aliases = %#v", commands)
}
if requireOptionalPluginChild(commands[0], "get-current-status") == nil {
var names []string
for _, command := range commands[0].Commands() {
names = append(names, command.Name())
}
t.Fatalf("derived command missing, got %s", strings.Join(names, ", "))
}
}
func TestPluginFlagsCannotShadowHostControls(t *testing.T) {
host := pluginTestRoot()
host.PersistentFlags().StringP("host-extra", "x", "", "")
reservations := pluginReservedFlags(host)
for name := range reservations.names {
t.Run(name, func(t *testing.T) {
descriptor := conferencePluginDescriptor()
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {
CLIName: "unsafe",
IsSensitive: true,
Flags: map[string]mcptypes.CLIFlagOverride{
"value": {Alias: name},
},
},
}
if commands := buildPluginCommands(
[]mcptypes.ServerDescriptor{descriptor},
executor.EchoRunner{},
host,
); len(commands) != 0 {
t.Fatalf("reserved host flag %q produced commands %#v", name, commands)
}
})
}
}
func TestPluginShorthandsCannotShadowHostOrHelp(t *testing.T) {
host := pluginTestRoot()
host.PersistentFlags().StringP("host-extra", "x", "", "")
descriptor := conferencePluginDescriptor()
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"safe": {
CLIName: "safe",
Flags: map[string]mcptypes.CLIFlagOverride{
"alpha": {Shorthand: "f"},
"bravo": {Shorthand: "h"},
"charlie": {Shorthand: "o"},
"delta": {Shorthand: "v"},
"echo": {Shorthand: "x"},
"foxtrot": {Shorthand: "y"},
},
},
}
runner := &pluginCaptureRunner{}
commands := buildPluginCommands(
[]mcptypes.ServerDescriptor{descriptor},
runner,
host,
)
if len(commands) != 1 {
t.Fatalf("plugin commands = %#v", commands)
}
host.AddCommand(commands...)
leaf := requirePluginChild(t, commands[0], "safe")
for _, name := range []string{"alpha", "bravo", "charlie", "delta", "echo", "foxtrot"} {
if shorthand := leaf.Flags().Lookup(name).Shorthand; shorthand != "" {
t.Fatalf("--%s shorthand = %q, want empty", name, shorthand)
}
}
host.SetArgs([]string{"conference", "safe", "-h"})
if err := host.Execute(); err != nil {
t.Fatalf("plugin help: %v", err)
}
if len(runner.invocations) != 0 {
t.Fatalf("help executed plugin: %#v", runner.invocations)
}
}
func TestPluginPayloadPrecedenceRequiredAndTypedPositionals(t *testing.T) {
descriptor := conferencePluginDescriptor()
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"payload": {
CLIName: "payload",
Flags: map[string]mcptypes.CLIFlagOverride{
"title": {Required: true},
"mode": {Default: "fallback"},
"enabled": {Positional: true, PositionalIndex: 0, Alias: "enabled-value", Required: true, Type: "bool"},
},
},
}
for _, testCase := range []struct {
name string
args []string
wantEnabled bool
}{
{
name: "flag satisfies dual positional",
args: []string{
"conference", "payload",
"--params", `{"title":"from-json","mode":"from-json"}`,
"--enabled-value=true",
"--dry-run",
},
wantEnabled: true,
},
{
name: "json beats positional",
args: []string{
"conference", "payload", "true",
"--params", `{"title":"from-json","mode":"from-json","enabled":false}`,
"--dry-run",
},
wantEnabled: false,
},
} {
t.Run(testCase.name, func(t *testing.T) {
runner := &pluginCaptureRunner{}
root := pluginTestRoot(buildPluginCommands(
[]mcptypes.ServerDescriptor{descriptor},
runner,
nil,
)...)
root.SetArgs(testCase.args)
if err := root.Execute(); err != nil {
t.Fatalf("payload command: %v", err)
}
if len(runner.invocations) != 1 {
t.Fatalf("runner calls = %d", len(runner.invocations))
}
params := runner.invocations[0].Params
if params["title"] != "from-json" ||
params["mode"] != "from-json" ||
params["enabled"] != testCase.wantEnabled {
t.Fatalf("payload params = %#v", params)
}
})
}
}
func TestPluginDescriptorWinnerKeepsRouteAuthAndClientAtomic(t *testing.T) {
isolatePluginRuntime(t)
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
writeManifest := func(name, manifest string) {
t.Helper()
directory := filepath.Join(configDir, "plugins", "user", name)
if err := os.MkdirAll(directory, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(directory, "plugin.json"), []byte(manifest), 0o600); err != nil {
t.Fatal(err)
}
}
writeManifest("alpha-plugin", `{
"name":"alpha-plugin",
"version":"1.0.0",
"mcpServers":{
"alpha":{
"type":"streamable-http",
"endpoint":"https://alpha.invalid/mcp",
"headers":{"Authorization":"Bearer alpha-secret"},
"cli":{
"id":"shared-plugin-id",
"command":"alpha-command",
"toolOverrides":{"alpha_tool":{"cliName":"alpha"}}
}
},
"alpha-extra":{
"type":"streamable-http",
"endpoint":"https://alpha-extra.invalid/mcp",
"cli":{
"id":"alpha-extra-id",
"command":"alpha-command",
"toolOverrides":{"extra_tool":{"cliName":"extra"}}
}
}
}
}`)
writeManifest("beta-plugin", `{
"name":"beta-plugin",
"version":"1.0.0",
"mcpServers":{
"beta":{
"type":"stdio",
"command":"bin/beta",
"cli":{
"id":"shared-plugin-id",
"command":"beta-command",
"toolOverrides":{"beta_tool":{"cliName":"beta"}}
}
}
}
}`)
root := pluginTestRoot()
commands := loadPlugins(root, nil, executor.EchoRunner{})
if len(commands) != 1 || commands[0].Name() != "alpha-command" {
t.Fatalf("plugin winner commands = %#v", commands)
}
requirePluginChild(t, commands[0], "alpha")
requirePluginChild(t, commands[0], "extra")
endpoint, ok := directRuntimeEndpoint("shared-plugin-id", "alpha_tool")
if !ok || endpoint != "https://alpha.invalid/mcp" {
t.Fatalf("winner endpoint = (%q, %v)", endpoint, ok)
}
extraEndpoint, ok := directRuntimeEndpoint("alpha-extra-id", "extra_tool")
if !ok || extraEndpoint != "https://alpha-extra.invalid/mcp" {
t.Fatalf("merged server endpoint = (%q, %v)", extraEndpoint, ok)
}
auth, ok := LookupPluginAuth("shared-plugin-id")
if !ok || auth.Token != "alpha-secret" {
t.Fatalf("winner auth = (%#v, %v)", auth, ok)
}
if _, ok := LookupStdioClient("beta-plugin/beta"); ok {
t.Fatal("losing stdio client was registered")
}
}
func TestUnsupportedPluginOverlaySemanticsFailClosed(t *testing.T) {
for _, mutate := range []func(*mcptypes.ServerDescriptor){
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.RedirectTo = "drive"
},
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {
CLIName: "unsafe",
Flags: map[string]mcptypes.CLIFlagOverride{
"source": {MapsTo: "target"},
},
},
}
},
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {
CLIName: "unsafe",
Pipeline: []json.RawMessage{json.RawMessage(`{"tool":"one"}`)},
},
}
},
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {CLIName: "unsafe", ServerOverride: "drive"},
}
},
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {
CLIName: "unsafe",
Flags: map[string]mcptypes.CLIFlagOverride{
"Body.query": {},
},
},
}
},
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {CLIName: "unsafe", Group: "safe.bad_name"},
}
},
func(descriptor *mcptypes.ServerDescriptor) {
descriptor.CLI.ToolOverrides = map[string]mcptypes.CLIToolOverride{
"unsafe": {
CLIName: "unsafe",
Flags: map[string]mcptypes.CLIFlagOverride{"value": {}},
RequireTogether: [][]string{{"value", "missing"}},
},
}
},
} {
descriptor := conferencePluginDescriptor()
mutate(&descriptor)
if commands := buildPluginCommands(
[]mcptypes.ServerDescriptor{descriptor},
executor.EchoRunner{},
nil,
); len(commands) != 0 {
t.Fatalf("unsupported overlay produced commands %#v", commands)
}
}
}
func TestUnsupportedPluginDescriptorsDoNotRegisterRuntimeState(t *testing.T) {
isolatePluginRuntime(t)
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
writeManifest := func(name, manifest string) {
t.Helper()
directory := filepath.Join(configDir, "plugins", "user", name)
if err := os.MkdirAll(directory, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(directory, "plugin.json"), []byte(manifest), 0o600); err != nil {
t.Fatal(err)
}
}
writeManifest("unsafe-http", `{
"name":"unsafe-http",
"version":"1.0.0",
"mcpServers":{"unsafe":{
"type":"streamable-http",
"endpoint":"https://unsafe.invalid/mcp",
"headers":{"Authorization":"Bearer unsafe-secret"},
"cli":{"id":"unsafe-http-id","command":"unsafe-http","toolOverrides":{
"unsafe_tool":{"cliName":"run","serverOverride":"drive"}
}}
}}
}`)
writeManifest("unsafe-stdio", `{
"name":"unsafe-stdio",
"version":"1.0.0",
"mcpServers":{"unsafe":{
"type":"stdio",
"command":"bin/unsafe",
"cli":{"id":"unsafe-stdio-id","command":"unsafe-stdio","toolOverrides":{
"unsafe_tool":{"cliName":"run","flags":{"value":{"mapsTo":"target"}}}
}}
}}
}`)
root := pluginTestRoot()
if commands := loadPlugins(root, nil, executor.EchoRunner{}); len(commands) != 0 {
t.Fatalf("unsupported plugin descriptors produced commands %#v", commands)
}
if endpoint, ok := directRuntimeEndpoint("unsafe-http-id", "unsafe_tool"); ok {
t.Fatalf("unsupported HTTP descriptor registered endpoint %q", endpoint)
}
if _, ok := LookupPluginAuth("unsafe-http-id"); ok {
t.Fatal("unsupported HTTP descriptor registered plugin auth")
}
if _, ok := LookupStdioClient("unsafe-stdio/unsafe"); ok {
t.Fatal("unsupported stdio descriptor registered a client")
}
}
+239
View File
@@ -0,0 +1,239 @@
// 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 (
"encoding/json"
"fmt"
"strconv"
"strings"
"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/transport"
)
func pluginToolInputSchema(
tools transport.ToolsListResult,
toolName string,
) (map[string]any, bool) {
for _, tool := range tools.Tools {
if strings.TrimSpace(tool.Name) == strings.TrimSpace(toolName) {
return tool.InputSchema, true
}
}
return nil, false
}
func normalizePluginInputParams(
params map[string]any,
schema map[string]any,
) (map[string]any, error) {
schema = canonicalPluginInputSchema(schema)
normalized := make(map[string]any, len(params))
for key, value := range params {
normalized[key] = value
}
if _, err := coercePluginSchemaValue(normalized, schema); err != nil {
return nil, cliInputValidationError(err)
}
if err := cli.ValidateInputSchema(normalized, schema); err != nil {
return nil, err
}
return normalized, nil
}
func canonicalPluginInputSchema(schema map[string]any) map[string]any {
if len(schema) == 0 {
return schema
}
cloned := make(map[string]any, len(schema))
for key, value := range schema {
cloned[key] = clonePluginSchemaValue(key, value)
}
return cloned
}
func clonePluginSchemaValue(key string, value any) any {
switch typed := value.(type) {
case map[string]any:
cloned := make(map[string]any, len(typed))
for childKey, childValue := range typed {
cloned[childKey] = clonePluginSchemaValue(childKey, childValue)
}
return cloned
case []any:
cloned := make([]any, len(typed))
for index, item := range typed {
cloned[index] = clonePluginSchemaValue(key, item)
}
return cloned
case []string:
cloned := make([]string, len(typed))
for index, item := range typed {
if key == "type" {
item = canonicalPluginSchemaType(item)
}
cloned[index] = item
}
return cloned
case string:
if key == "type" {
return canonicalPluginSchemaType(typed)
}
return typed
default:
return value
}
}
func canonicalPluginSchemaType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "bool":
return "boolean"
case "int":
return "integer"
case "float":
return "number"
default:
return value
}
}
func cliInputValidationError(err error) error {
if err == nil {
return nil
}
return apperrors.NewValidation(
fmt.Sprintf("input schema normalization failed: %v", err),
apperrors.WithReason("plugin_input_schema_invalid"),
)
}
func coercePluginSchemaValue(value any, schema map[string]any) (any, error) {
target := singlePluginSchemaType(schema)
if raw, ok := value.(string); ok {
trimmed := strings.TrimSpace(raw)
switch target {
case "bool", "boolean":
parsed, err := strconv.ParseBool(trimmed)
if err != nil {
return nil, fmt.Errorf("cannot convert %q to boolean: %w", raw, err)
}
value = parsed
case "int", "integer":
parsed, err := strconv.Atoi(trimmed)
if err != nil {
return nil, fmt.Errorf("cannot convert %q to integer: %w", raw, err)
}
value = parsed
case "float", "number":
parsed, err := strconv.ParseFloat(trimmed, 64)
if err != nil {
return nil, fmt.Errorf("cannot convert %q to number: %w", raw, err)
}
value = parsed
case "object":
var parsed map[string]any
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
return nil, fmt.Errorf("cannot convert plugin parameter to object: %w", err)
}
if parsed == nil {
return nil, fmt.Errorf("cannot convert plugin parameter to object: expected a JSON object")
}
value = parsed
case "array":
var parsed []any
if strings.HasPrefix(trimmed, "[") {
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
return nil, fmt.Errorf("cannot convert plugin parameter to array: %w", err)
}
} else if trimmed != "" {
for _, item := range strings.Split(trimmed, ",") {
if item = strings.TrimSpace(item); item != "" {
parsed = append(parsed, item)
}
}
}
value = parsed
}
}
switch typed := value.(type) {
case map[string]any:
properties, _ := schema["properties"].(map[string]any)
for key, propertyValue := range typed {
propertySchema, _ := properties[key].(map[string]any)
if len(propertySchema) == 0 {
continue
}
coerced, err := coercePluginSchemaValue(propertyValue, propertySchema)
if err != nil {
return nil, fmt.Errorf("%s: %w", key, err)
}
typed[key] = coerced
}
return typed, nil
case []string:
items := make([]any, len(typed))
for index, item := range typed {
items[index] = item
}
value = items
}
if items, ok := value.([]any); ok {
itemSchema, _ := schema["items"].(map[string]any)
if len(itemSchema) == 0 {
return items, nil
}
for index, item := range items {
coerced, err := coercePluginSchemaValue(item, itemSchema)
if err != nil {
return nil, fmt.Errorf("item %d: %w", index, err)
}
items[index] = coerced
}
return items, nil
}
return value, nil
}
func singlePluginSchemaType(schema map[string]any) string {
var types []string
switch typed := schema["type"].(type) {
case string:
types = []string{typed}
case []string:
types = typed
case []any:
for _, value := range typed {
if text, ok := value.(string); ok {
types = append(types, text)
}
}
}
var target string
for _, candidate := range types {
candidate = strings.TrimSpace(candidate)
if candidate == "" || candidate == "null" {
continue
}
if target != "" && target != candidate {
return ""
}
target = candidate
}
return target
}
@@ -0,0 +1,229 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"errors"
"reflect"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func TestPluginToolInputSchemaMatchesTrimmedName(t *testing.T) {
want := map[string]any{"type": "object"}
tools := transport.ToolsListResult{Tools: []transport.ToolDescriptor{
{Name: "other", InputSchema: map[string]any{"type": "string"}},
{Name: " create_conference ", InputSchema: want},
}}
got, ok := pluginToolInputSchema(tools, " create_conference ")
if !ok || !reflect.DeepEqual(got, want) {
t.Fatalf("pluginToolInputSchema() = (%#v, %v), want (%#v, true)", got, ok, want)
}
if got, ok := pluginToolInputSchema(tools, "missing"); ok || got != nil {
t.Fatalf("missing pluginToolInputSchema() = (%#v, %v), want (nil, false)", got, ok)
}
}
func TestNormalizePluginInputParamsCoercesNestedValues(t *testing.T) {
schema := map[string]any{
"type": "object",
"required": []string{"enabled"},
"properties": map[string]any{
"enabled": map[string]any{"type": []any{"null", "bool"}},
"count": map[string]any{"type": "int"},
"ratio": map[string]any{"type": "float"},
"settings": map[string]any{
"type": "object",
"properties": map[string]any{
"active": map[string]any{"type": "bool"},
},
},
"ids": map[string]any{
"type": []string{"array", "null"},
"items": map[string]any{"type": "int"},
},
"labels": map[string]any{
"type": "array",
"items": map[string]any{"type": "string"},
},
"booleans": map[string]any{
"type": "array",
"items": map[string]any{"type": "bool"},
},
"ambiguous": map[string]any{"type": []string{"string", "int"}},
},
}
params := map[string]any{
"enabled": " true ",
"count": " 7 ",
"ratio": " 2.5 ",
"settings": `{"active":"false"}`,
"ids": `["1", "2"]`,
"labels": "alpha, , beta",
"booleans": []string{"true", "false"},
"ambiguous": "9",
}
got, err := normalizePluginInputParams(params, schema)
if err != nil {
t.Fatalf("normalizePluginInputParams() error = %v", err)
}
want := map[string]any{
"enabled": true,
"count": 7,
"ratio": 2.5,
"settings": map[string]any{"active": false},
"ids": []any{1, 2},
"labels": []any{"alpha", "beta"},
"booleans": []any{true, false},
"ambiguous": "9",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("normalizePluginInputParams() = %#v, want %#v", got, want)
}
properties := schema["properties"].(map[string]any)
if gotType := properties["enabled"].(map[string]any)["type"].([]any)[1]; gotType != "bool" {
t.Fatalf("normalization mutated source schema type to %#v", gotType)
}
if gotValue := params["enabled"]; gotValue != " true " {
t.Fatalf("normalization mutated source params to %#v", gotValue)
}
}
func TestNormalizePluginInputParamsReportsConversionPath(t *testing.T) {
tests := []struct {
name string
value any
fieldSchema map[string]any
wantText string
}{
{name: "boolean", value: "sometimes", fieldSchema: map[string]any{"type": "bool"}, wantText: "cannot convert"},
{name: "integer", value: "1.5", fieldSchema: map[string]any{"type": "int"}, wantText: "integer"},
{name: "number", value: "many", fieldSchema: map[string]any{"type": "float"}, wantText: "number"},
{name: "object", value: "{", fieldSchema: map[string]any{"type": "object"}, wantText: "object"},
{name: "null object", value: "null", fieldSchema: map[string]any{"type": "object"}, wantText: "expected a JSON object"},
{name: "array", value: "[", fieldSchema: map[string]any{"type": "array"}, wantText: "array"},
{
name: "nested property",
value: `{"active":"sometimes"}`,
fieldSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"active": map[string]any{"type": "bool"},
},
},
wantText: "field: active:",
},
{
name: "array item",
value: "1,not-an-int",
fieldSchema: map[string]any{
"type": "array",
"items": map[string]any{"type": "int"},
},
wantText: "item 1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
schema := map[string]any{
"type": "object",
"properties": map[string]any{"field": tt.fieldSchema},
}
_, err := normalizePluginInputParams(map[string]any{"field": tt.value}, schema)
if err == nil {
t.Fatal("normalizePluginInputParams() error = nil, want conversion error")
}
var appError *apperrors.Error
if !errors.As(err, &appError) ||
appError.Category != apperrors.CategoryValidation ||
appError.Reason != "plugin_input_schema_invalid" {
t.Fatalf("conversion error = %#v, want categorized plugin schema validation error", err)
}
if !strings.Contains(err.Error(), tt.wantText) {
t.Fatalf("conversion error = %q, want text %q", err, tt.wantText)
}
})
}
}
func TestNormalizePluginInputParamsRunsSchemaValidation(t *testing.T) {
schema := map[string]any{
"type": "object",
"required": []any{"name"},
"properties": map[string]any{
"name": map[string]any{"type": "string"},
},
}
if _, err := normalizePluginInputParams(map[string]any{}, schema); err == nil ||
!strings.Contains(err.Error(), "$.name is required") {
t.Fatalf("required-field validation error = %v", err)
}
}
func TestPluginInputSchemaHelperEdges(t *testing.T) {
if got := canonicalPluginInputSchema(nil); got != nil {
t.Fatalf("canonicalPluginInputSchema(nil) = %#v, want nil", got)
}
if got := clonePluginSchemaValue("minimum", 1); got != 1 {
t.Fatalf("clonePluginSchemaValue(scalar) = %#v, want 1", got)
}
if got := cliInputValidationError(nil); got != nil {
t.Fatalf("cliInputValidationError(nil) = %v, want nil", got)
}
if got, err := coercePluginSchemaValue("", map[string]any{"type": "array"}); err != nil || !reflect.DeepEqual(got, []any(nil)) {
t.Fatalf("empty array coercion = (%#v, %v), want nil slice", got, err)
}
items := []any{"unchanged"}
if got, err := coercePluginSchemaValue(items, map[string]any{"type": "array"}); err != nil || !reflect.DeepEqual(got, items) {
t.Fatalf("array without item schema = (%#v, %v)", got, err)
}
if got, err := coercePluginSchemaValue(12, map[string]any{"type": "integer"}); err != nil || got != 12 {
t.Fatalf("non-string scalar coercion = (%#v, %v), want (12, nil)", got, err)
}
unknown := map[string]any{"unknown": "unchanged"}
if got, err := coercePluginSchemaValue(unknown, map[string]any{
"type": "object",
"properties": map[string]any{},
}); err != nil || !reflect.DeepEqual(got, unknown) {
t.Fatalf("unknown property coercion = (%#v, %v), want unchanged map", got, err)
}
tests := []struct {
name string
schema map[string]any
want string
}{
{name: "missing", schema: map[string]any{}, want: ""},
{name: "single string", schema: map[string]any{"type": "integer"}, want: "integer"},
{name: "single string slice", schema: map[string]any{"type": []string{"null", "number"}}, want: "number"},
{name: "any slice", schema: map[string]any{"type": []any{nil, 3, "", "null", "boolean"}}, want: "boolean"},
{name: "ambiguous", schema: map[string]any{"type": []any{"string", "integer"}}, want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := singlePluginSchemaType(tt.schema); got != tt.want {
t.Fatalf("singlePluginSchemaType(%#v) = %q, want %q", tt.schema, got, tt.want)
}
})
}
for raw, want := range map[string]string{
" BOOL ": "boolean",
"Int": "integer",
"FLOAT": "number",
"custom": "custom",
} {
if got := canonicalPluginSchemaType(raw); got != want {
t.Errorf("canonicalPluginSchemaType(%q) = %q, want %q", raw, got, want)
}
}
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"errors"
"reflect"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func TestPluginStdioExecutionNormalizesAndValidatesLiveSchema(t *testing.T) {
isolatePluginRuntime(t)
previousInit := runnerStdioEnsureInitialized
previousList := runnerStdioListTools
previousCall := runnerStdioCallTool
t.Cleanup(func() {
runnerStdioEnsureInitialized = previousInit
runnerStdioListTools = previousList
runnerStdioCallTool = previousCall
})
client := transport.NewStdioClient("unused", nil, nil)
RegisterStdioClient("conference/local", client)
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error {
return nil
}
runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) {
return transport.ToolsListResult{
Tools: []transport.ToolDescriptor{{
Name: "create_conference",
InputSchema: map[string]any{
"type": "object",
"required": []any{"title"},
"properties": map[string]any{
"title": map[string]any{"type": "string"},
"capture_speaker": map[string]any{"type": "bool"},
},
"additionalProperties": false,
},
}},
}, nil
}
var calledParams map[string]any
runnerStdioCallTool = func(
_ *transport.StdioClient,
_ context.Context,
_ string,
params map[string]any,
) (transport.ToolCallResult, error) {
calledParams = params
return transport.ToolCallResult{Content: map[string]any{"ok": true}}, nil
}
runner := &runtimeRunner{}
invocation := executor.Invocation{
CanonicalProduct: "conference-local",
Tool: "create_conference",
Params: map[string]any{
"title": "schema validation",
"capture_speaker": "true",
},
}
result, err := runner.executeInvocation(
context.Background(),
"stdio://conference/local",
invocation,
)
if err != nil {
t.Fatalf("stdio plugin execution: %v", err)
}
wantParams := map[string]any{
"title": "schema validation",
"capture_speaker": true,
}
if !reflect.DeepEqual(calledParams, wantParams) ||
!reflect.DeepEqual(result.Invocation.Params, wantParams) {
t.Fatalf("normalized wire params = %#v, result = %#v", calledParams, result.Invocation.Params)
}
calledParams = nil
invocation.Params = map[string]any{"capture_speaker": "true"}
_, err = runner.executeInvocation(
context.Background(),
"stdio://conference/local",
invocation,
)
var appError *apperrors.Error
if !errors.As(err, &appError) ||
appError.Category != apperrors.CategoryValidation ||
calledParams != nil {
t.Fatalf("missing required schema validation = %#v, call params = %#v", err, calledParams)
}
}
@@ -0,0 +1,369 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package app
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"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/shortcut/userdef"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
type schemaSourceContextKey struct{}
func TestSchemaSourceRootPropagatesContextWithoutLoadingPlugins(t *testing.T) {
previous := rootLoadPlugins
t.Cleanup(func() { rootLoadPlugins = previous })
pluginLoads := 0
rootLoadPlugins = func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command {
pluginLoads++
return nil
}
wantContext := context.WithValue(context.Background(), schemaSourceContextKey{}, "schema")
root := NewSchemaSourceRootCommand(wantContext)
if root.Context() != wantContext {
t.Fatal("Schema source root did not retain the caller context")
}
if pluginLoads != 0 {
t.Fatalf("Schema source root loaded runtime plugins %d times", pluginLoads)
}
}
func TestCollectPluginServerCandidatesSortsAndSkipsInvalidStdio(t *testing.T) {
previousDescriptors := rootPluginDescriptors
previousClients := rootPluginStdioClients
previousDescriptor := rootPluginStdioDescriptor
t.Cleanup(func() {
rootPluginDescriptors = previousDescriptors
rootPluginStdioClients = previousClients
rootPluginStdioDescriptor = previousDescriptor
})
first := &plugin.Plugin{Manifest: plugin.Manifest{Name: "first"}}
second := &plugin.Plugin{Manifest: plugin.Manifest{Name: "second"}}
wantContext := &plugin.UserContext{UserID: "user", CorpID: "corp"}
client := transport.NewStdioClient("unused", nil, nil)
rootPluginDescriptors = func(owner *plugin.Plugin) []mcptypes.ServerDescriptor {
if owner == first {
return []mcptypes.ServerDescriptor{{Key: "same"}, {Key: " beta "}}
}
return []mcptypes.ServerDescriptor{{Key: "aardvark"}}
}
rootPluginStdioClients = func(owner *plugin.Plugin, gotContext *plugin.UserContext) []plugin.StdioServerClient {
if gotContext != wantContext {
t.Fatalf("stdio user context = %#v, want %#v", gotContext, wantContext)
}
if owner != first {
return nil
}
return []plugin.StdioServerClient{
{Key: "same", Client: client},
{Key: " alpha ", Client: client},
{Key: "invalid", Client: client},
}
}
rootPluginStdioDescriptor = func(_ *plugin.Plugin, stdio plugin.StdioServerClient) (mcptypes.ServerDescriptor, bool) {
if stdio.Key == "invalid" {
return mcptypes.ServerDescriptor{}, false
}
return mcptypes.ServerDescriptor{Key: stdio.Key}, true
}
candidates := collectPluginServerCandidates([]*plugin.Plugin{first, second}, wantContext)
if len(candidates) != 5 {
t.Fatalf("candidate count = %d, want 5", len(candidates))
}
gotKeys := make([]string, 0, len(candidates))
gotKinds := make([]string, 0, len(candidates))
for _, candidate := range candidates {
gotKeys = append(gotKeys, candidate.descriptor.Key)
if candidate.stdioClient == nil {
gotKinds = append(gotKinds, "http")
} else {
gotKinds = append(gotKinds, "stdio")
if candidate.stdioClient.Client != client {
t.Fatal("stdio candidate did not retain its client")
}
}
}
if want := []string{" alpha ", " beta ", "same", "same", "aardvark"}; !reflect.DeepEqual(gotKeys, want) {
t.Fatalf("candidate keys = %#v, want %#v", gotKeys, want)
}
if want := []string{"stdio", "http", "http", "stdio", "http"}; !reflect.DeepEqual(gotKinds, want) {
t.Fatalf("candidate transports = %#v, want %#v", gotKinds, want)
}
}
func TestPluginDescriptorBlankIdentityAndDistributionOwnership(t *testing.T) {
isolatePluginRuntime(t)
blank := mcptypes.ServerDescriptor{
Key: " ",
CLI: mcptypes.CLIOverlay{
ID: " ",
Command: " ",
Aliases: []string{"", " "},
},
}
if claims := pluginDescriptorIdentityClaims(blank); len(claims) != 0 {
t.Fatalf("blank descriptor claims = %#v, want none", claims)
}
if rootName := pluginDescriptorRootName(blank); rootName != "" {
t.Fatalf("blank descriptor root = %q", rootName)
}
owner := &plugin.Plugin{Manifest: plugin.Manifest{Name: "blank"}}
accepted := selectPluginServerCandidates(
&cobra.Command{Use: "dws"},
[]pluginServerCandidate{
{owner: owner, descriptor: mcptypes.ServerDescriptor{CLI: mcptypes.CLIOverlay{Skip: true}}},
{owner: owner, descriptor: blank},
},
)
if len(accepted) != 1 {
t.Fatalf("blank descriptor candidates = %#v, want one accepted candidate", accepted)
}
if distributionRootOwns(nil, "visible") {
t.Fatal("nil root claimed a command")
}
root := &cobra.Command{Use: "dws"}
visible := &cobra.Command{Use: "visible", Aliases: []string{" visible-alias "}}
hiddenFallback := &cobra.Command{Use: "conference", Hidden: true}
hiddenOwned := &cobra.Command{Use: "hidden-owned", Hidden: true}
pluginOwned := &cobra.Command{Use: "plugin-owned", Aliases: []string{"plugin-alias"}}
cmdutil.MarkPluginSource(pluginOwned)
root.AddCommand(visible, hiddenFallback, hiddenOwned, pluginOwned)
for _, name := range []string{"visible", "visible-alias", "hidden-owned"} {
if !distributionRootOwns(root, name) {
t.Errorf("distribution root did not claim %q", name)
}
}
for _, name := range []string{"conference", "plugin-owned", "plugin-alias", "missing"} {
if distributionRootOwns(root, name) {
t.Errorf("distribution root unexpectedly claimed %q", name)
}
}
}
func TestReplaceableFallbackIdentitySurvivesDistributionConflictChecks(t *testing.T) {
isolatePluginRuntime(t)
SetDynamicServers([]mcptypes.ServerDescriptor{
{
Key: "conference",
Endpoint: "https://example.com/conference/mcp",
CLI: mcptypes.CLIOverlay{ID: "conference"},
},
{
Key: "chat",
Endpoint: "https://example.com/chat/mcp",
CLI: mcptypes.CLIOverlay{ID: "chat"},
},
})
root := &cobra.Command{Use: "dws"}
root.AddCommand(&cobra.Command{Use: "conference", Hidden: true})
distributionProducts := DirectRuntimeProductIDs()
conferenceDescriptor := mcptypes.ServerDescriptor{
Key: "conference-local",
DisplayName: "conference/conference-local",
CLI: mcptypes.CLIOverlay{ID: "conference-local", Command: "conference"},
}
if pluginDescriptorConflictsWithDistribution(root, conferenceDescriptor, distributionProducts) {
t.Fatal("replaceable fallback identity blocked plugin server selection")
}
chatDescriptor := mcptypes.ServerDescriptor{
Key: "chat-local",
DisplayName: "chat/chat-local",
CLI: mcptypes.CLIOverlay{ID: "chat-local", Command: "chat"},
}
if !pluginDescriptorConflictsWithDistribution(root, chatDescriptor, distributionProducts) {
t.Fatal("non-replaceable distribution product no longer conflicts")
}
reservedDescriptor := mcptypes.ServerDescriptor{
Key: "auth-local",
DisplayName: "auth/auth-local",
CLI: mcptypes.CLIOverlay{ID: "auth-local", Command: "auth"},
}
if !pluginDescriptorConflictsWithDistribution(root, reservedDescriptor, distributionProducts) {
t.Fatal("reserved command name no longer conflicts")
}
first := &plugin.Plugin{Manifest: plugin.Manifest{Name: "conference"}}
second := &plugin.Plugin{Manifest: plugin.Manifest{Name: "other"}}
accepted := selectPluginServerCandidates(root, []pluginServerCandidate{
{owner: first, descriptor: conferenceDescriptor},
{
owner: second,
descriptor: mcptypes.ServerDescriptor{
Key: "conference-other",
DisplayName: "other/conference-other",
CLI: mcptypes.CLIOverlay{ID: "conference-other", Command: "conference"},
},
},
})
if len(accepted) != 1 {
t.Fatalf("accepted candidates = %d, want the first conference plugin only", len(accepted))
}
if accepted[0].owner != first {
t.Fatalf("accepted owner = %q, want the first conference plugin", accepted[0].owner.Manifest.Name)
}
}
func TestAddPluginCommandsSafeFiltersConflictingAliases(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.AddCommand(&cobra.Command{Use: "taken"})
command := &cobra.Command{
Use: "extension",
Aliases: []string{"", "extension", "auth", "taken", "shared", " shared ", " okay "},
}
addPluginCommandsSafe(root, []*cobra.Command{
command,
{Use: "shared"},
{Use: "other", Aliases: []string{"extension"}},
})
if want := []string{"shared", "okay"}; !reflect.DeepEqual(command.Aliases, want) {
t.Fatalf("filtered aliases = %#v, want %#v", command.Aliases, want)
}
if child := findDirectChild(root, "shared"); child != nil {
t.Fatal("an accepted alias was also registered as a plugin primary command")
}
other := findDirectChild(root, "other")
if other == nil || len(other.Aliases) != 0 {
t.Fatalf("later plugin aliases = %#v", other)
}
}
func TestStdioRunnerReportsToolsListFailureAndMissingTool(t *testing.T) {
isolatePluginRuntime(t)
previousInit := runnerStdioEnsureInitialized
previousList := runnerStdioListTools
previousCall := runnerStdioCallTool
t.Cleanup(func() {
runnerStdioEnsureInitialized = previousInit
runnerStdioListTools = previousList
runnerStdioCallTool = previousCall
})
client := transport.NewStdioClient("unused", nil, nil)
RegisterStdioClient("plugin/server", client)
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return nil }
toolCalls := 0
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
toolCalls++
return transport.ToolCallResult{}, nil
}
runner := &runtimeRunner{}
invocation := executor.Invocation{CanonicalProduct: "overlay-id", Tool: "wanted"}
listFailure := errors.New("list failed")
runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) {
return transport.ToolsListResult{}, listFailure
}
_, err := runner.executeStdioInvocationAtEndpoint(context.Background(), "stdio://plugin/server", invocation)
assertPluginRuntimeError(t, err, apperrors.CategoryAPI, "tools/list", "stdio_tools_list_error")
runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) {
return transport.ToolsListResult{Tools: []transport.ToolDescriptor{{Name: "other"}}}, nil
}
_, err = runner.executeStdioInvocationAtEndpoint(context.Background(), "stdio://plugin/server", invocation)
assertPluginRuntimeError(t, err, apperrors.CategoryValidation, "", "plugin_tool_not_found")
if toolCalls != 0 {
t.Fatalf("tools/call attempts after tools/list failures = %d", toolCalls)
}
}
func TestStdioManifestDescriptorAndRegistrationFailClosed(t *testing.T) {
isolatePluginRuntime(t)
p := &plugin.Plugin{
Manifest: plugin.Manifest{
Name: "broken-plugin",
MCPServers: map[string]*plugin.MCPServer{
"local": {CLI: json.RawMessage(`{`)},
},
},
}
server := plugin.StdioServerClient{
Key: "local",
Client: transport.NewStdioClient("unused", nil, nil),
}
if descriptor, ok := stdioServerDescriptorFromManifest(p, server); ok || !reflect.ValueOf(descriptor).IsZero() {
t.Fatalf("invalid descriptor = (%#v, %v), want zero, false", descriptor, ok)
}
if descriptor := registerStdioServerFromManifest(p, server); !reflect.ValueOf(descriptor).IsZero() {
t.Fatalf("invalid registered descriptor = %#v, want zero", descriptor)
}
if _, ok := LookupStdioClient("broken-plugin/local"); ok {
t.Fatal("invalid stdio manifest registered a client")
}
}
func TestLegacyCommandsContinueWhenUserShortcutLoadFails(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
shortcutDir := filepath.Join(configDir, "shortcuts")
if err := os.MkdirAll(shortcutDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(shortcutDir, "broken.yaml"), []byte("version: ["), 0o600); err != nil {
t.Fatal(err)
}
if _, loadErrors := userdef.Load(); len(loadErrors) == 0 {
t.Fatal("malformed shortcut fixture did not fail to load")
}
runner := executor.EchoRunner{}
caller := newToolCallerAdapter(runner, &GlobalFlags{})
if commands := newLegacyPublicCommands(runner, caller, true); len(commands) == 0 {
t.Fatal("legacy commands were dropped after a user shortcut load error")
}
}
func findDirectChild(root *cobra.Command, name string) *cobra.Command {
for _, command := range root.Commands() {
if command.Name() == name {
return command
}
}
return nil
}
func assertPluginRuntimeError(
t *testing.T,
err error,
wantCategory apperrors.Category,
wantOperation string,
wantReason string,
) {
t.Helper()
var appError *apperrors.Error
if !errors.As(err, &appError) {
t.Fatalf("runtime error = %#v, want structured app error", err)
}
if appError.Category != wantCategory ||
appError.Operation != wantOperation ||
appError.Reason != wantReason {
t.Fatalf("runtime error = %#v, want category=%q operation=%q reason=%q", appError, wantCategory, wantOperation, wantReason)
}
}
+38 -2
View File
@@ -5,6 +5,7 @@
package app
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
@@ -12,6 +13,7 @@ import (
"sync/atomic"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
@@ -35,6 +37,11 @@ func isolatePluginRuntime(t *testing.T) {
stdioClients = make(map[string]*transport.StdioClient)
stdioMu.Unlock()
pluginAuthMu.Lock()
previousPluginAuth := pluginAuthRegistry
pluginAuthRegistry = make(map[string]*PluginAuth)
pluginAuthMu.Unlock()
t.Cleanup(func() {
StopAllStdioClients()
dynamicMu.Lock()
@@ -46,6 +53,9 @@ func isolatePluginRuntime(t *testing.T) {
stdioMu.Lock()
stdioClients = previousStdio
stdioMu.Unlock()
pluginAuthMu.Lock()
pluginAuthRegistry = previousPluginAuth
pluginAuthMu.Unlock()
})
}
@@ -77,14 +87,40 @@ func TestRegisterPluginHTTPServerDoesNotProbeEndpoint(t *testing.T) {
func TestRegisterStdioServerFromManifestDoesNotStartProcess(t *testing.T) {
isolatePluginRuntime(t)
marker := t.TempDir() + "/started"
pluginRoot := t.TempDir()
if err := os.WriteFile(pluginRoot+"/overlay.json", []byte(`{
"id":"local",
"command":"lazy-stdio",
"groups":{"health":{"description":"health checks"}},
"toolOverrides":{"ping":{"cliName":"ping","group":"health"}}
}`), 0o600); err != nil {
t.Fatal(err)
}
client := transport.NewStdioClient("/bin/sh", []string{
"-c", fmt.Sprintf("printf started > %q", marker),
}, nil)
p := &plugin.Plugin{
Manifest: plugin.Manifest{Name: "lazy-stdio", Description: "lazy stdio test"},
Root: t.TempDir(),
Manifest: plugin.Manifest{
Name: "lazy-stdio",
Description: "lazy stdio test",
MCPServers: map[string]*plugin.MCPServer{
"local": {
Type: "stdio",
Command: "unused",
CLI: json.RawMessage(`"overlay.json"`),
},
},
},
Root: pluginRoot,
}
descriptor := registerStdioServerFromManifest(p, plugin.StdioServerClient{Key: "local", Client: client})
commands := buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, executor.EchoRunner{}, nil)
root := pluginTestRoot(commands...)
root.SetArgs([]string{"lazy-stdio", "--help"})
if err := root.Execute(); err != nil {
t.Fatalf("lazy stdio help: %v", err)
}
requirePluginChild(t, commands[0], "health", "ping")
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("stdio process started during registration: stat error = %v", err)
+34 -44
View File
@@ -14,10 +14,7 @@
package app
import (
"encoding/json"
"log/slog"
"os"
"path/filepath"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
@@ -33,50 +30,26 @@ import (
// When no CLI metadata is present, a minimal overlay keyed by the server
// name is returned so callers can still build an identity descriptor.
func resolveStdioOverlay(p *plugin.Plugin, sc plugin.StdioServerClient) mcptypes.CLIOverlay {
serverID := sc.Key
overlay := mcptypes.CLIOverlay{
ID: serverID,
Command: serverID,
}
srv, ok := p.Manifest.MCPServers[sc.Key]
if !ok || len(srv.CLI) == 0 {
return overlay
}
cliData := srv.CLI
// A JSON string is interpreted as a relative path to an external
// overlay file (e.g. "overlay.json") anchored at the plugin root.
if len(cliData) > 0 && cliData[0] == '"' {
var cliPath string
if err := json.Unmarshal(cliData, &cliPath); err == nil && cliPath != "" {
absPath := filepath.Join(p.Root, cliPath)
if fileData, readErr := os.ReadFile(absPath); readErr == nil {
cliData = fileData
} else {
slog.Warn("plugin: failed to read CLI overlay file",
"plugin", p.Manifest.Name, "path", absPath, "error", readErr)
}
overlay, ok := p.ResolveCLIOverlay(sc.Key)
if !ok {
return mcptypes.CLIOverlay{
ID: sc.Key,
Command: sc.Key,
Skip: true,
}
}
if err := json.Unmarshal(cliData, &overlay); err != nil {
slog.Warn("plugin: failed to parse CLI overlay for stdio server",
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
}
if overlay.ID == "" {
overlay.ID = serverID
}
if overlay.Command == "" {
overlay.Command = serverID
}
return overlay
}
// registerStdioServerFromManifest registers an endpoint descriptor and an
// unstarted client from versioned plugin metadata. Tool discovery is not part
// of command-tree construction; execution starts and initializes the client.
func registerStdioServerFromManifest(p *plugin.Plugin, sc plugin.StdioServerClient) mcptypes.ServerDescriptor {
overlay := resolveStdioOverlay(p, sc)
descriptor := mcptypes.ServerDescriptor{
func stdioServerDescriptorFromManifest(
p *plugin.Plugin,
sc plugin.StdioServerClient,
) (mcptypes.ServerDescriptor, bool) {
overlay, ok := p.ResolveCLIOverlay(sc.Key)
if !ok {
return mcptypes.ServerDescriptor{}, false
}
return mcptypes.ServerDescriptor{
Key: sc.Key,
DisplayName: p.Manifest.Name + "/" + sc.Key,
Description: p.Manifest.Description,
@@ -84,13 +57,30 @@ func registerStdioServerFromManifest(p *plugin.Plugin, sc plugin.StdioServerClie
Source: "plugin",
CLI: overlay,
HasCLIMeta: true,
}
}, true
}
func registerResolvedStdioServer(
p *plugin.Plugin,
sc plugin.StdioServerClient,
descriptor mcptypes.ServerDescriptor,
) {
AppendDynamicServer(descriptor)
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
slog.Debug("plugin: stdio server registered from manifest",
"plugin", p.Manifest.Name, "server", sc.Key,
"toolOverrides", len(overlay.ToolOverrides))
"toolOverrides", len(descriptor.CLI.ToolOverrides))
}
// registerStdioServerFromManifest registers an endpoint descriptor and an
// unstarted client from versioned plugin metadata. Tool discovery is not part
// of command-tree construction; execution starts and initializes the client.
func registerStdioServerFromManifest(p *plugin.Plugin, sc plugin.StdioServerClient) mcptypes.ServerDescriptor {
descriptor, ok := stdioServerDescriptorFromManifest(p, sc)
if !ok {
return mcptypes.ServerDescriptor{}
}
registerResolvedStdioServer(p, sc, descriptor)
return descriptor
}
+324 -33
View File
@@ -23,6 +23,7 @@ import (
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"sync"
"syscall"
@@ -69,7 +70,8 @@ var (
rootPluginDescriptors = (*plugin.Plugin).ToServerDescriptors
rootPluginStdioClients = (*plugin.Plugin).StdioClients
rootRegisterPluginHTTPServer = registerPluginHTTPServer
rootRegisterStdioManifest = registerStdioServerFromManifest
rootPluginStdioDescriptor = stdioServerDescriptorFromManifest
rootRegisterResolvedStdioServer = registerResolvedStdioServer
rootPluginLoadHooks = (*plugin.Plugin).LoadHooks
rootPluginSyncSkills = plugin.SyncSkills
rootAuthLoadTokenData = authpkg.LoadTokenData
@@ -306,13 +308,28 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command {
if len(ctx) > 0 && ctx[0] != nil {
rootCtx = ctx[0]
}
return NewRootCommandWithEngine(rootCtx, nil)
return newRootCommandWithEngine(rootCtx, nil, true)
}
// NewSchemaSourceRootCommand constructs the distribution-owned command tree
// used by Schema generation and command-surface policy. Installed plugins and
// user-defined shortcuts must not change the reviewed embedded Schema.
func NewSchemaSourceRootCommand(ctx ...context.Context) *cobra.Command {
var rootCtx context.Context
if len(ctx) > 0 && ctx[0] != nil {
rootCtx = ctx[0]
}
return newRootCommandWithEngine(rootCtx, nil, false)
}
// NewRootCommandWithEngine constructs the root CLI command with an
// optional pipeline engine for input correction. When engine is nil,
// no pipeline processing is applied.
func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command {
return newRootCommandWithEngine(rootCtx, engine, true)
}
func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine, loadRuntimeExtensions bool) *cobra.Command {
if rootCtx == nil {
rootCtx = context.Background()
}
@@ -396,16 +413,9 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
}
root.AddCommand(utilityCommands...)
root.AddCommand(newLegacyPublicCommands(runner, patCaller)...)
root.AddCommand(newLegacyPublicCommands(runner, patCaller, loadRuntimeExtensions)...)
root.AddCommand(newLegacyHiddenCommands(runner)...)
// --- Plugin loading: runs AFTER legacy commands so plugin endpoints can
// be appended on top of the static endpoint registry.
pluginCmds := rootLoadPlugins(engine, runner)
if len(pluginCmds) > 0 {
addPluginCommandsSafe(root, pluginCmds)
}
// PAT authorization commands (open-source core)
pat.RegisterCommands(root, patCaller)
@@ -414,6 +424,15 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
fn(root, caller)
deduplicateCommands(root)
}
if loadRuntimeExtensions {
// Resolve plugins only after the complete distribution command tree is
// present, so endpoint and Cobra conflict checks see PAT and edition
// commands as well as the open-source base.
pluginCmds := rootLoadPlugins(root, engine, runner)
if len(pluginCmds) > 0 {
addPluginCommandsSafe(root, pluginCmds)
}
}
hideNonDirectRuntimeCommands(root)
configureRootHelp(root)
// Set custom flag error handler for better UX
@@ -631,12 +650,17 @@ var reservedCommands = map[string]bool{
"schema": true, "mcp": true, "help": true,
}
var replaceablePluginFallbacks = map[string]bool{
"conference": true,
}
// addPluginCommandsSafe registers plugin commands with conflict detection.
//
// Rules:
// - Plugin vs reserved (auth/plugin/cache/...) → reject, warn
// - Plugin vs plugin (same name) → reject later one, warn
// - Plugin vs Market dynamic command → allow, plugin wins
// - Plugin vs hidden compatibility fallback → allow, plugin wins
// - Plugin vs visible distribution command → reject, warn
func addPluginCommandsSafe(root *cobra.Command, pluginCmds []*cobra.Command) {
// Build index of existing commands before plugin registration.
existing := make(map[string]bool)
@@ -664,17 +688,47 @@ func addPluginCommandsSafe(root *cobra.Command, pluginCmds []*cobra.Command) {
}
pluginSeen[name] = true
// Rule 3: plugin vs Market — plugin wins, remove the old one.
// An alias must not bypass the same protections applied to primary
// plugin command names or shadow another root command.
filteredAliases := make([]string, 0, len(cmd.Aliases))
for _, rawAlias := range cmd.Aliases {
alias := strings.TrimSpace(rawAlias)
if alias == "" || alias == name || reservedCommands[alias] ||
existing[alias] || pluginSeen[alias] {
if alias != "" {
slog.Warn("plugin: command alias conflicts with an existing command, skipping",
"command", name, "alias", alias)
}
continue
}
pluginSeen[alias] = true
filteredAliases = append(filteredAliases, alias)
}
cmd.Aliases = filteredAliases
// Rule 3: an installed plugin may replace a hidden compatibility
// fallback (for example conference), but never a visible distribution
// command that participates in the reviewed base interface.
if existing[name] {
for _, old := range root.Commands() {
if old.Name() == name {
if !old.Hidden || !replaceablePluginFallbacks[name] ||
cmdutil.IsPluginSourced(old) {
slog.Warn("plugin: command conflicts with a visible distribution command, skipping",
"command", name)
cmd = nil
break
}
root.RemoveCommand(old)
slog.Debug("plugin: overriding Market command",
slog.Debug("plugin: overriding hidden compatibility command",
"command", name)
break
}
}
}
if cmd == nil {
continue
}
root.AddCommand(cmd)
}
@@ -811,7 +865,21 @@ func CloseFileLogger() {
// loadPlugins registers versioned plugin manifests, stdio clients, hooks, and
// skills. It deliberately does not initialize MCP transports or call
// tools/list while constructing the command tree.
func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
type pluginServerCandidate struct {
owner *plugin.Plugin
order int
descriptor mcptypes.ServerDescriptor
stdioClient *plugin.StdioServerClient
}
type pluginIdentityOwner struct {
plugin *plugin.Plugin
serverKey string
rootName string
shareable bool
}
func loadPlugins(root *cobra.Command, engine *pipeline.Engine, runner executor.Runner) []*cobra.Command {
pluginLoader := plugin.NewLoader(RawVersion())
// 0a. Inject plugin config values from settings.json as environment
@@ -838,25 +906,34 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
// 2. Load dev plugins (registered via `dws plugin dev`)
devPlugins := rootPluginLoadDev(pluginLoader)
sortPluginsForRegistration(userPlugins)
sortPluginsForRegistration(devPlugins)
allPlugins := append(userPlugins, devPlugins...)
descriptorsByPlugin := make(map[*plugin.Plugin][]mcptypes.ServerDescriptor, len(allPlugins))
// 3. Register HTTP descriptors and authentication from the manifest.
for _, p := range allPlugins {
for _, srv := range rootPluginDescriptors(p) {
rootRegisterPluginHTTPServer(srv)
// 3. Resolve every descriptor once, then choose identity winners before
// mutating endpoint, auth, or stdio-client registries. This keeps the
// visible command and its transport owned by the same plugin.
candidates := collectPluginServerCandidates(allPlugins, userCtx)
accepted := selectPluginServerCandidates(root, candidates)
for _, candidate := range accepted {
if candidate.stdioClient != nil {
rootRegisterResolvedStdioServer(
candidate.owner,
*candidate.stdioClient,
candidate.descriptor,
)
} else {
rootRegisterPluginHTTPServer(candidate.descriptor)
}
descriptorsByPlugin[candidate.owner] = append(
descriptorsByPlugin[candidate.owner],
candidate.descriptor,
)
}
// 4. Register stdio descriptors and unstarted clients. The subprocess is
// started and initialized only when a command is actually executed.
for _, p := range allPlugins {
for _, sc := range rootPluginStdioClients(p, userCtx) {
rootRegisterStdioManifest(p, sc)
}
}
// 5. Register plugin hooks into pipeline engine
// 4. Register plugin hooks into pipeline engine
if engine != nil {
for _, p := range allPlugins {
hooksCfg, err := rootPluginLoadHooks(p)
@@ -874,7 +951,7 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
}
}
// 7. Sync plugin skills to agent directories
// 5. Sync plugin skills to agent directories
rootPluginSyncSkills(allPlugins)
if len(allPlugins) > 0 {
@@ -884,11 +961,228 @@ func loadPlugins(engine *pipeline.Engine, _ executor.Runner) []*cobra.Command {
)
}
return nil
var pluginCommands []*cobra.Command
for _, p := range allPlugins {
// Build each plugin independently. addPluginCommandsSafe deliberately
// resolves cross-plugin root conflicts with first-plugin-wins semantics.
pluginCommands = append(pluginCommands, buildPluginCommands(descriptorsByPlugin[p], runner, root)...)
}
return pluginCommands
}
func sortPluginsForRegistration(plugins []*plugin.Plugin) {
sort.SliceStable(plugins, func(i, j int) bool {
left := strings.TrimSpace(plugins[i].Manifest.Name) + "\x00" + strings.TrimSpace(plugins[i].Root)
right := strings.TrimSpace(plugins[j].Manifest.Name) + "\x00" + strings.TrimSpace(plugins[j].Root)
return left < right
})
}
func collectPluginServerCandidates(
plugins []*plugin.Plugin,
userCtx *plugin.UserContext,
) []pluginServerCandidate {
var candidates []pluginServerCandidate
for order, owner := range plugins {
for _, descriptor := range rootPluginDescriptors(owner) {
candidates = append(candidates, pluginServerCandidate{
owner: owner,
order: order,
descriptor: descriptor,
})
}
for _, stdioClient := range rootPluginStdioClients(owner, userCtx) {
descriptor, ok := rootPluginStdioDescriptor(owner, stdioClient)
if !ok {
continue
}
clientCopy := stdioClient
candidates = append(candidates, pluginServerCandidate{
owner: owner,
order: order,
descriptor: descriptor,
stdioClient: &clientCopy,
})
}
}
sort.SliceStable(candidates, func(i, j int) bool {
if candidates[i].order != candidates[j].order {
return candidates[i].order < candidates[j].order
}
left := strings.TrimSpace(candidates[i].descriptor.Key)
right := strings.TrimSpace(candidates[j].descriptor.Key)
if left != right {
return left < right
}
return candidates[i].stdioClient == nil && candidates[j].stdioClient != nil
})
return candidates
}
func selectPluginServerCandidates(
root *cobra.Command,
candidates []pluginServerCandidate,
) []pluginServerCandidate {
distributionProducts := DirectRuntimeProductIDs()
owners := make(map[string]pluginIdentityOwner)
for identity := range distributionProducts {
if replaceablePluginFallbacks[identity] {
continue
}
owners[identity] = pluginIdentityOwner{serverKey: "distribution"}
}
accepted := make([]pluginServerCandidate, 0, len(candidates))
for _, candidate := range candidates {
descriptor := candidate.descriptor
if descriptor.CLI.Skip {
continue
}
if reason := unsupportedPluginDescriptor(root, descriptor); reason != "" {
slog.Warn("plugin: descriptor CLI semantics are unsupported, skipping",
"plugin", candidate.owner.Manifest.Name,
"server", descriptor.Key,
"field", reason)
continue
}
if pluginDescriptorConflictsWithDistribution(root, descriptor, distributionProducts) {
continue
}
claims := pluginDescriptorIdentityClaims(descriptor)
conflict := ""
for identity, shareable := range claims {
existing, exists := owners[identity]
if !exists {
continue
}
rootName := pluginDescriptorRootName(descriptor)
if shareable && existing.shareable &&
existing.plugin == candidate.owner &&
existing.rootName == rootName {
continue
}
conflict = identity
break
}
if conflict != "" {
slog.Warn("plugin: descriptor identity already owned, skipping",
"plugin", candidate.owner.Manifest.Name,
"server", descriptor.Key,
"identity", conflict)
continue
}
rootName := pluginDescriptorRootName(descriptor)
for identity, shareable := range claims {
if existing, exists := owners[identity]; exists &&
shareable && existing.shareable &&
existing.plugin == candidate.owner &&
existing.rootName == rootName {
continue
}
owners[identity] = pluginIdentityOwner{
plugin: candidate.owner,
serverKey: descriptor.Key,
rootName: rootName,
shareable: shareable,
}
}
accepted = append(accepted, candidate)
}
return accepted
}
func pluginDescriptorIdentityClaims(descriptor mcptypes.ServerDescriptor) map[string]bool {
claims := make(map[string]bool)
canonicalID := firstNonEmptyPluginString(descriptor.CLI.ID, descriptor.Key)
if canonicalID != "" {
claims[canonicalID] = false
}
for _, identity := range append(
[]string{pluginDescriptorRootName(descriptor)},
descriptor.CLI.Aliases...,
) {
identity = strings.TrimSpace(identity)
if identity == "" {
continue
}
if _, exists := claims[identity]; !exists {
claims[identity] = true
}
}
return claims
}
func pluginDescriptorRootName(descriptor mcptypes.ServerDescriptor) string {
return firstNonEmptyPluginString(
descriptor.CLI.Command,
descriptor.CLI.ID,
descriptor.Key,
)
}
func pluginDescriptorConflictsWithDistribution(
root *cobra.Command,
descriptor mcptypes.ServerDescriptor,
distributionProducts map[string]bool,
) bool {
candidates := append(
[]string{
firstNonEmptyPluginString(descriptor.CLI.ID, descriptor.Key),
pluginDescriptorRootName(descriptor),
},
descriptor.CLI.Aliases...,
)
for _, candidate := range candidates {
candidate = strings.TrimSpace(candidate)
if candidate == "" {
continue
}
if !reservedCommands[candidate] && replaceablePluginFallbacks[candidate] {
// The distribution ships only a hidden compatibility fallback for
// this name; plugins may claim it and the later command merge in
// addPluginCommandsSafe still rejects visible non-fallback owners.
continue
}
if reservedCommands[candidate] ||
distributionProducts[candidate] ||
distributionRootOwns(root, candidate) {
slog.Warn("plugin: descriptor conflicts with a distribution command, skipping",
"plugin", descriptor.DisplayName,
"server", descriptor.Key,
"identity", candidate)
return true
}
}
return false
}
func distributionRootOwns(root *cobra.Command, name string) bool {
if root == nil {
return false
}
for _, command := range root.Commands() {
if cmdutil.IsPluginSourced(command) {
continue
}
if command.Name() == name {
if command.Hidden && replaceablePluginFallbacks[name] {
return false
}
return true
}
for _, alias := range command.Aliases {
if strings.TrimSpace(alias) == name {
return true
}
}
}
return false
}
func registerPluginHTTPServer(srv mcptypes.ServerDescriptor) {
AppendDynamicServer(srv)
productID := firstNonEmptyPluginString(srv.CLI.ID, srv.Key)
ClearPluginAuth(productID)
if len(srv.AuthHeaders) > 0 {
registerPluginAuthFromHeaders(srv)
}
@@ -917,10 +1211,7 @@ func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
host := parsed.Hostname()
trustedDomains = []string{host, "*." + host}
}
productID := strings.TrimSpace(srv.CLI.ID)
if productID == "" {
productID = srv.Key
}
productID := firstNonEmptyPluginString(srv.CLI.ID, srv.Key)
RegisterPluginAuth(productID, &PluginAuth{
Token: authToken,
ExtraHeaders: extraHeaders,
+33 -8
View File
@@ -75,7 +75,7 @@ func TestCrossPlatformCoverageRootConstructionHooksAndVersionCoverage(t *testing
version, buildTime, gitCommit = oldVersion, oldBuild, oldCommit
})
rootLoadPlugins = func(*pipeline.Engine, executor.Runner) []*cobra.Command {
rootLoadPlugins = func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command {
return []*cobra.Command{{Use: "plugin-added", Run: func(*cobra.Command, []string) {}}}
}
preRunCalled := false
@@ -236,7 +236,8 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
oldDescriptors := rootPluginDescriptors
oldStdioClients := rootPluginStdioClients
oldHTTP := rootRegisterPluginHTTPServer
oldStdio := rootRegisterStdioManifest
oldStdioDescriptor := rootPluginStdioDescriptor
oldStdioRegister := rootRegisterResolvedStdioServer
oldHooks := rootPluginLoadHooks
oldSync := rootPluginSyncSkills
oldToken := rootAuthLoadTokenData
@@ -247,7 +248,8 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
rootPluginDescriptors = oldDescriptors
rootPluginStdioClients = oldStdioClients
rootRegisterPluginHTTPServer = oldHTTP
rootRegisterStdioManifest = oldStdio
rootPluginStdioDescriptor = oldStdioDescriptor
rootRegisterResolvedStdioServer = oldStdioRegister
rootPluginLoadHooks = oldHooks
rootPluginSyncSkills = oldSync
rootAuthLoadTokenData = oldToken
@@ -264,9 +266,17 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
}
rootPluginDescriptors = func(p *plugin.Plugin) []mcptypes.ServerDescriptor {
if p == p1 {
return []mcptypes.ServerDescriptor{{Key: "http", Endpoint: "https://example.test"}}
return []mcptypes.ServerDescriptor{{
Key: "http", Endpoint: "https://example.test",
CLI: mcptypes.CLIOverlay{
ID: "http", Command: "one-http",
ToolOverrides: map[string]mcptypes.CLIToolOverride{
"ping": {CLIName: "ping"},
},
},
}}
}
return []mcptypes.ServerDescriptor{{Key: "no-cli", Endpoint: "https://example.test"}}
return []mcptypes.ServerDescriptor{{Key: p.Manifest.Name + "-no-cli", Endpoint: "https://example.test"}}
}
client := transport.NewStdioClient("ignored", nil, nil)
rootPluginStdioClients = func(p *plugin.Plugin, uc *plugin.UserContext) []plugin.StdioServerClient {
@@ -278,9 +288,23 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
httpCount := 0
stdioCount := 0
rootRegisterPluginHTTPServer = func(mcptypes.ServerDescriptor) { httpCount++ }
rootRegisterStdioManifest = func(*plugin.Plugin, plugin.StdioServerClient) mcptypes.ServerDescriptor {
rootPluginStdioDescriptor = func(*plugin.Plugin, plugin.StdioServerClient) (mcptypes.ServerDescriptor, bool) {
return mcptypes.ServerDescriptor{
Key: "local",
CLI: mcptypes.CLIOverlay{
ID: "local", Command: "one-stdio",
ToolOverrides: map[string]mcptypes.CLIToolOverride{
"pong": {CLIName: "pong"},
},
},
}, true
}
rootRegisterResolvedStdioServer = func(
*plugin.Plugin,
plugin.StdioServerClient,
mcptypes.ServerDescriptor,
) {
stdioCount++
return mcptypes.ServerDescriptor{}
}
rootPluginLoadHooks = func(p *plugin.Plugin) (*plugin.HooksConfig, error) {
switch p {
@@ -294,7 +318,8 @@ func TestCrossPlatformCoverageRootLoadPluginsRemainingCoverage(t *testing.T) {
}
synced := false
rootPluginSyncSkills = func([]*plugin.Plugin) { synced = true }
if got := loadPlugins(pipeline.NewEngine(), runnerCoverageFallback{}); got != nil {
got := loadPlugins(nil, pipeline.NewEngine(), runnerCoverageFallback{})
if len(got) != 2 || got[0].Name() != "one-http" || got[1].Name() != "one-stdio" {
t.Fatalf("loaded plugin commands = %#v", got)
}
if httpCount != 3 || stdioCount != 1 || !synced {
+37 -3
View File
@@ -168,6 +168,7 @@ var (
runnerPreflightDocDownload = (*runtimeRunner).preflightDocDownload
runnerCallTool = (*transport.Client).CallTool
runnerStdioEnsureInitialized = (*transport.StdioClient).EnsureInitialized
runnerStdioListTools = (*transport.StdioClient).ListTools
runnerStdioCallTool = (*transport.StdioClient).CallTool
runnerHandlePatAuthCheck func(context.Context, *runtimeRunner, executor.Invocation, *apperrors.PATError, string, io.Writer) (executor.Result, error)
runnerRetryWithPatAuthRetry func(context.Context, executor.Runner, executor.Invocation, *PatScopeError, string, io.Writer) (executor.Result, error)
@@ -485,7 +486,7 @@ func (r *runtimeRunner) handleCatalogMiss(ctx context.Context, invocation execut
func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, invocation executor.Invocation) (result executor.Result, retErr error) {
// Route stdio:// endpoints to the local StdioClient — no HTTP, no auth.
if IsStdioEndpoint(endpoint) {
return r.executeStdioInvocation(ctx, invocation)
return r.executeStdioInvocationAtEndpoint(ctx, endpoint, invocation)
}
// Constructing the Cobra tree is also used for help, schema, and command
@@ -766,6 +767,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
// subprocess instead of the HTTP transport. This is used for plugin stdio
// servers whose endpoints use the stdio:// scheme.
func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
return r.executeStdioInvocationAtEndpoint(ctx, "", invocation)
}
func (r *runtimeRunner) executeStdioInvocationAtEndpoint(
ctx context.Context,
endpoint string,
invocation executor.Invocation,
) (executor.Result, error) {
if invocation.DryRun {
return executor.Result{
Invocation: invocation,
@@ -778,10 +787,14 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e
}, nil
}
client, ok := LookupStdioClient(invocation.CanonicalProduct)
lookupKey := strings.Trim(strings.TrimPrefix(strings.TrimSpace(endpoint), stdioEndpointScheme), "/")
if lookupKey == "" {
lookupKey = invocation.CanonicalProduct
}
client, ok := LookupStdioClient(lookupKey)
if !ok {
return executor.Result{}, apperrors.NewInternal(
fmt.Sprintf("stdio client not found for %q", invocation.CanonicalProduct))
fmt.Sprintf("stdio client not found for %q", lookupKey))
}
callCtx := ctx
@@ -798,6 +811,27 @@ func (r *runtimeRunner) executeStdioInvocation(ctx context.Context, invocation e
)
}
tools, err := runnerStdioListTools(client, callCtx)
if err != nil {
return executor.Result{}, apperrors.NewAPI(
fmt.Sprintf("stdio tools/list failed: %v", err),
apperrors.WithOperation("tools/list"),
apperrors.WithReason("stdio_tools_list_error"),
)
}
schema, ok := pluginToolInputSchema(tools, invocation.Tool)
if !ok {
return executor.Result{}, apperrors.NewValidation(
fmt.Sprintf("plugin tool %q is not declared by tools/list", invocation.Tool),
apperrors.WithReason("plugin_tool_not_found"),
)
}
normalizedParams, err := normalizePluginInputParams(invocation.Params, schema)
if err != nil {
return executor.Result{}, err
}
invocation.Params = normalizedParams
callResult, err := runnerStdioCallTool(client, callCtx, invocation.Tool, invocation.Params)
if err != nil {
return executor.Result{}, apperrors.NewAPI(
+20
View File
@@ -290,10 +290,12 @@ func TestCrossPlatformCoverageRunnerRemainingExecutionCoverage(t *testing.T) {
func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *testing.T) {
oldStdioInit := runnerStdioEnsureInitialized
oldStdioList := runnerStdioListTools
oldStdioCall := runnerStdioCallTool
oldEdition := edition.Get()
t.Cleanup(func() {
runnerStdioEnsureInitialized = oldStdioInit
runnerStdioListTools = oldStdioList
runnerStdioCallTool = oldStdioCall
edition.Override(oldEdition)
StopAllStdioClients()
@@ -309,6 +311,14 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test
t.Fatalf("stdio initialize error = %v", err)
}
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return nil }
runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) {
return transport.ToolsListResult{
Tools: []transport.ToolDescriptor{{
Name: "tool",
InputSchema: map[string]any{"type": "object"},
}},
}, nil
}
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
return transport.ToolCallResult{}, wantErr
}
@@ -327,6 +337,16 @@ func TestCrossPlatformCoverageRunnerRemainingStdioAuthAndHeadersCoverage(t *test
if got, err := r.executeStdioInvocation(context.Background(), inv); err != nil || !got.Invocation.Implemented {
t.Fatalf("stdio success = %#v, %v", got, err)
}
RegisterStdioClient("plugin/server-key", client)
overlayIDInvocation := inv
overlayIDInvocation.CanonicalProduct = "overlay-id"
if got, err := r.executeInvocation(
context.Background(),
"stdio://plugin/server-key",
overlayIDInvocation,
); err != nil || !got.Invocation.Implemented {
t.Fatalf("stdio endpoint-key lookup = %#v, %v", got, err)
}
r.globalFlags.Token = " explicit "
if got, err := r.resolveAuthToken(context.Background()); err != nil || got != "explicit" {
+1 -1
View File
@@ -14,7 +14,7 @@ func TestRuntimeSchemaCompletenessCoversPublicCommandTree(t *testing.T) {
if err != nil {
t.Fatal(err)
}
root := NewRootCommand()
root := NewSchemaSourceRootCommand()
if err := cli.ValidateEmbeddedRuntimeSchemaCompleteness(root); err != nil {
t.Fatal(err)
}
+11
View File
@@ -304,3 +304,14 @@ func splitSchemaPathTokens(raw string) []string {
}
return out
}
// normalizeSchemaQueryCLIPath accepts the historical query spellings while
// keeping authored Registry CLI paths strict and space-separated. Canonical
// identity lookup still runs before this compatibility normalization.
func normalizeSchemaQueryCLIPath(path string) string {
parts := splitSchemaPathTokens(strings.TrimSpace(path))
if len(parts) > 0 && parts[0] == "dws" {
parts = parts[1:]
}
return strings.Join(parts, " ")
}
+15 -15
View File
@@ -2,7 +2,7 @@
"product_id": "event",
"tools": {
"event consume": {
"agent_summary": "订阅并持续消费指定个人事件,输出 NDJSON 事件流",
"agent_summary": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"agent_summary_source": "dws-agent-selection/event",
"availability": "available",
"avoid_when": [
@@ -13,19 +13,19 @@
"effect": "write",
"effect_source": "agent-hint",
"examples": [
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
],
"field_provenance": {
"agent_summary": {
"value": "订阅并持续消费指定个人事件,输出 NDJSON 事件流",
"value": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": "订阅并持续消费指定个人事件,输出 NDJSON 事件流",
"value": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
"selected": true,
@@ -105,8 +105,8 @@
},
"examples": {
"value": [
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -115,8 +115,8 @@
"candidates": [
{
"value": [
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -538,7 +538,7 @@
]
},
"event schema": {
"agent_summary": "查询指定个人事件码的 payload 字段结构",
"agent_summary": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
"agent_summary_source": "dws-agent-selection/event",
"availability": "available",
"avoid_when": [
@@ -549,18 +549,18 @@
"effect": "read",
"effect_source": "agent-hint",
"examples": [
"dws event schema user_im_message_receive_at --format json"
"dws event schema user_im_message_receive_at --flatten --format json"
],
"field_provenance": {
"agent_summary": {
"value": "查询指定个人事件码的 payload 字段结构",
"value": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": "查询指定个人事件码的 payload 字段结构",
"value": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
"selected": true,
@@ -640,7 +640,7 @@
},
"examples": {
"value": [
"dws event schema user_im_message_receive_at --format json"
"dws event schema user_im_message_receive_at --flatten --format json"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -649,7 +649,7 @@
"candidates": [
{
"value": [
"dws event schema user_im_message_receive_at --format json"
"dws event schema user_im_message_receive_at --flatten --format json"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -1,6 +1,6 @@
{
"version": 1,
"source_hash": "sha256:de587cba5051dd4c2715353012d8d9f2a7c5208a0c6dee4ea703cfc97b7351f0",
"source_hash": "sha256:5df496973c41b3b4f7ae8c856ff0e9e99bcabd4455419b7d41bb23c44df6f1af",
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
"coverage": {
"surface_products": 22,
@@ -1,6 +1,6 @@
{
"version": 1,
"source_hash": "sha256:de587cba5051dd4c2715353012d8d9f2a7c5208a0c6dee4ea703cfc97b7351f0",
"source_hash": "sha256:5df496973c41b3b4f7ae8c856ff0e9e99bcabd4455419b7d41bb23c44df6f1af",
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
"source_files": 150,
"hint_files": 46,
+1 -1
View File
@@ -290,7 +290,7 @@ func schemaPayloadFromLoadedCatalog(loaded loadedSchemaCatalog, args []string) (
return payload, nil
}
raw := strings.TrimSpace(args[0])
if tool, ok := loaded.Index.Resolve(raw); ok {
if tool, ok := loaded.Index.ResolveQuery(raw); ok {
return schemaToolForResolvedPath(tool, raw).ToPayload()
}
tokens := splitSchemaPathTokens(raw)
+193 -25
View File
@@ -1,12 +1,12 @@
{
"version": 1,
"source_hash": "sha256:522d4b43cf13f07430a407319a772cd11e9b1f268f8d98b4d68bdb385e4e585b",
"source_hash": "sha256:14398788381c822f208e8cae059d98cd60da6625023869c74015735650d67e6f",
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
"catalog": {
"agent_metadata": {
"products_with_metadata": 22,
"source": "embedded-skill-metadata",
"source_hash": "sha256:de587cba5051dd4c2715353012d8d9f2a7c5208a0c6dee4ea703cfc97b7351f0",
"source_hash": "sha256:5df496973c41b3b4f7ae8c856ff0e9e99bcabd4455419b7d41bb23c44df6f1af",
"surface_hash": "sha256:7ef588f38052f0104e027c8daff5fefd68698781f2c87715461183d4058288ed",
"surface_products": 22,
"surface_tools": 572,
@@ -12178,7 +12178,7 @@
"tools": [
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "订阅并持续消费指定个人事件,输出 NDJSON 事件流",
"agent_summary": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"agent_summary_source": "dws-agent-selection/event",
"availability": "available",
"avoid_when": [
@@ -12189,7 +12189,7 @@
"cli_name": "consume",
"cli_path": "event consume",
"confirmation": "not_required",
"description": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好)\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。",
"description": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 单行紧凑 JSON;不传 --flatten 时沿用原 compact processor\n\n数据结构:\n ndjson/json/pretty 默认保持 transport envelope(type/event_type/data/headers)\n --flatten 结构化格式输出稳定的顶层业务字段,适合 Agent / 脚本直接消费\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。",
"effect": "write",
"idempotency": "non_idempotent",
"interface_mode": "composite",
@@ -12234,7 +12234,7 @@
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "查询指定个人事件码的 payload 字段结构",
"agent_summary": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
"agent_summary_source": "dws-agent-selection/event",
"availability": "available",
"avoid_when": [
@@ -290130,7 +290130,7 @@
"skills/mono/SKILL.md",
"skills/mono/references/products/event.md"
],
"agent_summary": "订阅并持续消费指定个人事件,输出 NDJSON 事件流",
"agent_summary": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"agent_summary_source": "dws-agent-selection/event",
"availability": "available",
"avoid_when": [
@@ -290149,13 +290149,13 @@
]
]
},
"description": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好)\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。",
"description": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 单行紧凑 JSON;不传 --flatten 时沿用原 compact processor\n\n数据结构:\n ndjson/json/pretty 默认保持 transport envelope(type/event_type/data/headers)\n --flatten 结构化格式输出稳定的顶层业务字段,适合 Agent / 脚本直接消费\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。",
"display": "事件订阅 (DingTalk Stream 长连接)",
"effect": "write",
"effect_source": "agent-hint",
"examples": [
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
],
"field_provenance": {
"agent_summary": {
@@ -290165,14 +290165,14 @@
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"selected": true,
"source": "internal/cli/schema_hints/selection/event.json",
"value": "订阅并持续消费指定个人事件,输出 NDJSON 事件流"
"value": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/event.json",
"value": "订阅并持续消费指定个人事件,输出 NDJSON 事件流"
"value": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON"
},
"availability": {
"candidates": [
@@ -290250,13 +290250,13 @@
"precedence": "cobra_help",
"selected": true,
"source": "cobra_help",
"value": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好)\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。"
"value": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 单行紧凑 JSON;不传 --flatten 时沿用原 compact processor\n\n数据结构:\n ndjson/json/pretty 默认保持 transport envelope(type/event_type/data/headers)\n --flatten 结构化格式输出稳定的顶层业务字段,适合 Agent / 脚本直接消费\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。"
}
],
"precedence": "cobra_help",
"resolution": "highest_precedence",
"source": "cobra_help",
"value": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好)\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。"
"value": "订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。\n\n输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对\n事件流无意义会 fallback 到 ndjson):\n ndjson (默认) 一行一对象,适合 jq / 管道处理\n json 每事件多行美化 JSON(必须配 --max-events 或 --duration)\n pretty 同 json,未来加颜色\n raw 仅 SDK 原始 payload,无外层封装\n compact 单行紧凑 JSON;不传 --flatten 时沿用原 compact processor\n\n数据结构:\n ndjson/json/pretty 默认保持 transport envelope(type/event_type/data/headers)\n --flatten 结构化格式输出稳定的顶层业务字段,适合 Agent / 脚本直接消费\n\n默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加\n--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用\nSIGTERM、关 stdin,或先用 dws event stop \u003csubscribe_id\u003e --dry-run 预览、确认后加\n--yes,绝不要 kill -9。\n--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费\n通常不需要设置。"
},
"effect": {
"candidates": [
@@ -290282,8 +290282,8 @@
"selected": true,
"source": "internal/cli/schema_hints/selection/event.json",
"value": [
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
]
}
],
@@ -290292,8 +290292,8 @@
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/event.json",
"value": [
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
]
},
"idempotency": {
@@ -290444,7 +290444,7 @@
"interface_reason": "Reviewed composite workflow: the command creates or reuses a remote personal-event subscription and coordinates the local event bus and Stream consumer; no single pinned RPC represents the workflow.",
"is_alias": false,
"name": "consume",
"parameter_count": 27,
"parameter_count": 28,
"parameters": {
"compact": {
"description": "提示 bus 客户端期望 compact 渲染(语义透传,bus 仍按原 payload 投递)",
@@ -291124,6 +291124,90 @@
"required": false,
"type": "string"
},
"flatten": {
"description": "将个人事件 transport envelope 投影为稳定的顶层业务字段",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "将个人事件 transport envelope 投影为稳定的顶层业务字段"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "将个人事件 transport envelope 投影为稳定的顶层业务字段"
},
"property": {
"candidates": [
{
"precedence": "inference",
"selected": true,
"source": "flag_name_inference",
"value": "flatten"
}
],
"precedence": "inference",
"resolution": "highest_precedence",
"source": "flag_name_inference",
"value": "flatten"
},
"required": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": false
}
],
"precedence": "default",
"resolution": "fallback",
"source": "default",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "boolean"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "boolean"
}
},
"property": "flatten",
"required": false,
"type": "boolean"
},
"force": {
"description": "仅 --foreground 模式生效:跳过单实例锁 (慎用:会让云事件被随机切分)",
"field_provenance": {
@@ -293464,7 +293548,7 @@
"skills/mono/SKILL.md",
"skills/mono/references/products/event.md"
],
"agent_summary": "查询指定个人事件码的 payload 字段结构",
"agent_summary": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
"agent_summary_source": "dws-agent-selection/event",
"availability": "available",
"avoid_when": [
@@ -293480,7 +293564,7 @@
"effect": "read",
"effect_source": "agent-hint",
"examples": [
"dws event schema user_im_message_receive_at --format json"
"dws event schema user_im_message_receive_at --flatten --format json"
],
"field_provenance": {
"agent_summary": {
@@ -293490,14 +293574,14 @@
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"selected": true,
"source": "internal/cli/schema_hints/selection/event.json",
"value": "查询指定个人事件码的 payload 字段结构"
"value": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/event.json",
"value": "查询指定个人事件码的 payload 字段结构"
"value": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式"
},
"availability": {
"candidates": [
@@ -293607,7 +293691,7 @@
"selected": true,
"source": "internal/cli/schema_hints/selection/event.json",
"value": [
"dws event schema user_im_message_receive_at --format json"
"dws event schema user_im_message_receive_at --flatten --format json"
]
}
],
@@ -293616,7 +293700,7 @@
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"source": "internal/cli/schema_hints/selection/event.json",
"value": [
"dws event schema user_im_message_receive_at --format json"
"dws event schema user_im_message_receive_at --flatten --format json"
]
},
"idempotency": {
@@ -293763,8 +293847,92 @@
"interface_reason": "命令读取 CLI 内置的个人事件 payload 定义,不绑定 pinned MCP RPC",
"is_alias": false,
"name": "schema",
"parameter_count": 1,
"parameter_count": 2,
"parameters": {
"flatten": {
"description": "显示 --flatten 消费模式对应的顶层业务字段 schema",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "显示 --flatten 消费模式对应的顶层业务字段 schema"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "显示 --flatten 消费模式对应的顶层业务字段 schema"
},
"property": {
"candidates": [
{
"precedence": "inference",
"selected": true,
"source": "flag_name_inference",
"value": "flatten"
}
],
"precedence": "inference",
"resolution": "highest_precedence",
"source": "flag_name_inference",
"value": "flatten"
},
"required": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": false
}
],
"precedence": "default",
"resolution": "fallback",
"source": "default",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "boolean"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "boolean"
}
},
"property": "flatten",
"required": false,
"type": "boolean"
},
"format": {
"default": "json",
"description": "输出格式: json",
@@ -194,6 +194,23 @@ func TestCrossPlatformCoverageSchemaCatalogLookupAndConversionEdges(t *testing.T
}
}
func TestEmbeddedSchemaLookupAcceptsCompatibleCLIPathSeparators(t *testing.T) {
loaded := embeddedSchemaCatalog()
for _, path := range []string{
"dev app list",
"dev.app.list",
"dev/app/list",
} {
payload, err := schemaPayloadFromLoadedCatalog(loaded, []string{path})
if err != nil {
t.Fatalf("schemaPayloadFromLoadedCatalog(%q) error = %v", path, err)
}
if got := schemaString(payload["canonical_path"]); got != "dev.list_dev_app" {
t.Fatalf("schemaPayloadFromLoadedCatalog(%q) canonical_path = %q, want %q", path, got, "dev.list_dev_app")
}
}
}
type catalogHookSnapshot struct {
parameterBindings func(BoundCommandRegistry, SchemaRegistry) error
dryRun func(SchemaRegistry) error
+16
View File
@@ -617,6 +617,22 @@ func (i SchemaIndex) Resolve(path string) (ToolSpec, bool) {
return i.registry.Products[location.product].Tools[location.tool], true
}
// ResolveQuery adds compatibility for dotted and slash-separated CLI paths at
// the user-facing query boundary. Resolve remains strict because Registry
// validation uses it to detect missing canonical identities without falling
// through to a similarly spelled CLI path.
func (i SchemaIndex) ResolveQuery(path string) (ToolSpec, bool) {
if tool, ok := i.Resolve(path); ok {
return tool, true
}
canonical, ok := i.byCLIPath[normalizeSchemaQueryCLIPath(path)]
if !ok {
return ToolSpec{}, false
}
location := i.byCanonical[canonical]
return i.registry.Products[location.product].Tools[location.tool], true
}
// CanonicalPaths returns the complete tool identity set in stable order.
func (i SchemaIndex) CanonicalPaths() []string {
paths := make([]string, 0, len(i.byCanonical))
@@ -514,6 +514,19 @@ func TestSchemaRegistryIndexResolvesCanonicalCLIAndAlias(t *testing.T) {
t.Fatalf("Resolve(%q) = %#v, %v", path, resolved.Identity, ok)
}
}
for _, path := range []string{
"calendar.attendee.delete",
"calendar/attendee/delete",
"dws.calendar.attendee.delete",
} {
resolved, ok := index.ResolveQuery(path)
if !ok || resolved.Identity.CanonicalPath != "calendar.attendee_delete" {
t.Fatalf("ResolveQuery(%q) = %#v, %v", path, resolved.Identity, ok)
}
}
if _, ok := index.ResolveQuery("calendar.attendee.unknown"); ok {
t.Fatal("unknown dotted CLI path unexpectedly resolved")
}
if got := index.CanonicalPaths(); !reflect.DeepEqual(got, []string{"calendar.attendee_delete"}) {
t.Fatalf("CanonicalPaths() = %#v", got)
}
@@ -154,6 +154,35 @@ func TestSelectionExplicitEmptyListSurvivesFinalDelivery(t *testing.T) {
}
}
func TestCompatibleSchemaAliasSeparatorsSurviveFinalDelivery(t *testing.T) {
snapshot := schemaDeliveryTestSnapshot(schemaDeliveryTestTool{
Canonical: "sample.run",
CLIPath: "sample category run",
Aliases: []string{"sample legacy execute"},
})
encoded, err := json.Marshal(snapshot)
if err != nil {
t.Fatal(err)
}
loaded, err := decodeSchemaCatalogSnapshot(encoded)
if err != nil {
t.Fatalf("decodeSchemaCatalogSnapshot(): %v", err)
}
canonical, err := schemaPayloadFromLoadedCatalog(loaded, []string{"sample.run"})
if err != nil {
t.Fatalf("canonical query: %v", err)
}
for _, path := range []string{"sample legacy execute", "sample.legacy.execute", "sample/legacy/execute"} {
alias, aliasErr := schemaPayloadFromLoadedCatalog(loaded, []string{path})
if aliasErr != nil {
t.Fatalf("alias query %q: %v", path, aliasErr)
}
if problem := schemaAliasViewProblem(canonical, alias, "sample legacy execute"); problem != "" {
t.Fatalf("alias projection for %q: %s", path, problem)
}
}
}
func TestValidateSchemaDeliveryInvariantsAllowsOnlyEnvelopeHashes(t *testing.T) {
snapshot := schemaDeliveryTestSnapshot(schemaDeliveryTestTool{Canonical: "sample.run", CLIPath: "sample run"})
snapshot.SurfaceHash = "sha256:reviewed-command-registry"
@@ -16,7 +16,7 @@ import (
// Go's normal per-package coverage accounting attributes the exercised Schema
// assembly code to internal/cli.
func TestCrossPlatformCoverageProductionSchemaSourcePipeline(t *testing.T) {
root := app.NewRootCommand()
root := app.NewSchemaSourceRootCommand()
resolved, err := cli.ResolveSchemaBuild(root)
if err != nil {
t.Fatalf("ResolveSchemaBuild() error = %v", err)
@@ -33,17 +33,17 @@ func TestCrossPlatformCoverageProductionSchemaSourcePipeline(t *testing.T) {
if len(snapshot.Tools) == 0 {
t.Fatal("production Schema snapshot contains no tools")
}
registry, err := cli.AssembleSchemaRegistry(app.NewRootCommand())
registry, err := cli.AssembleSchemaRegistry(app.NewSchemaSourceRootCommand())
if err != nil {
t.Fatalf("AssembleSchemaRegistry() error = %v", err)
}
if len(registry.Products) == 0 {
t.Fatal("assembled production Schema registry contains no products")
}
if err := cli.ValidateEmbeddedRuntimeSchemaCompleteness(app.NewRootCommand()); err != nil {
if err := cli.ValidateEmbeddedRuntimeSchemaCompleteness(app.NewSchemaSourceRootCommand()); err != nil {
t.Fatalf("ValidateEmbeddedRuntimeSchemaCompleteness() error = %v", err)
}
root = app.NewRootCommand()
root = app.NewSchemaSourceRootCommand()
if _, err := cli.ApplyEmbeddedManualSchemaHints(root); err != nil {
t.Fatal(err)
}
+5
View File
@@ -634,6 +634,11 @@
"target": "event consume",
"reason": "已审查的带 event_key 和输出参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_receive_at": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_receive_group": {
"status": "alias",
"target": "event consume",
@@ -585,6 +585,11 @@
"target": "event consume",
"reason": "已审查的带 event_key 和输出参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_receive_at": {
"status": "alias",
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_receive_group": {
"status": "alias",
"target": "event consume",
@@ -9,7 +9,7 @@
},
"tools": {
"event.consume": {
"agent_summary": "订阅并持续消费指定个人事件,输出 NDJSON 事件流",
"agent_summary": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"use_when": [
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
@@ -20,8 +20,8 @@
"只看事件目录/字段时用 event list / event schema"
],
"examples": [
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
],
"reviewed": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
@@ -57,7 +57,7 @@
]
},
"event.schema": {
"agent_summary": "查询指定个人事件码的 payload 字段结构",
"agent_summary": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
"use_when": [
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
],
@@ -66,7 +66,7 @@
"要实际收事件时用 event consume"
],
"examples": [
"dws event schema user_im_message_receive_at --format json"
"dws event schema user_im_message_receive_at --flatten --format json"
],
"reviewed": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
+2 -2
View File
@@ -284,7 +284,7 @@ func runtimeSchemaPayloadFromRegistry(registry SchemaRegistry, args []string) (m
}
raw := strings.TrimSpace(args[0])
if tool, ok := index.Resolve(raw); ok {
if tool, ok := index.ResolveQuery(raw); ok {
tool = schemaToolForResolvedPath(tool, raw)
return renderRegistryToolPayload(tool)
}
@@ -338,7 +338,7 @@ func runtimeSchemaAllPayloadFromRegistry(registry SchemaRegistry) (map[string]an
}
func schemaToolForResolvedPath(tool ToolSpec, raw string) ToolSpec {
normalized := normalizeSchemaCLIPath(raw)
normalized := normalizeSchemaQueryCLIPath(raw)
if normalized == "" || normalized == tool.Identity.CLIPath || normalized == tool.Identity.PrimaryCLIPath {
return tool
}
+4
View File
@@ -90,6 +90,10 @@ type Config struct {
// NormalizeFormat; an empty Format here defaults to NDJSON inside
// BuildPipeline.
Format Format
// Flatten records whether the caller explicitly selected a structured
// business projection. Projector remains the executable behavior; this
// field is surfaced in dry-run output so users can verify the final mode.
Flatten bool
// OutputDir, if non-empty, switches the fallback sink from stdout to
// "file per event" under this directory.
OutputDir string
+1
View File
@@ -132,6 +132,7 @@ func PrintDryRun(w io.Writer, cfg Config) {
fmt.Fprintf(w, " filter : %s\n", cfg.Filter)
}
fmt.Fprintf(w, " format : %s\n", cfg.Format)
fmt.Fprintf(w, " flatten : %v\n", cfg.Flatten)
if cfg.OutputDir != "" {
fmt.Fprintf(w, " output_dir : %s\n", cfg.OutputDir)
}
+2 -1
View File
@@ -137,6 +137,7 @@ func TestPrintDryRun_RendersAllSetFields(t *testing.T) {
c.EventTypes = []string{"im.*", "approval.*"}
c.Filter = "^im\\."
c.Format = FormatCompact
c.Flatten = true
c.OutputDir = "/tmp/events"
c.Routes, _ = ParseRoutes([]string{`^im\.=dir:/tmp/im/`})
c.MaxEvents = 5
@@ -151,7 +152,7 @@ func TestPrintDryRun_RendersAllSetFields(t *testing.T) {
wants := []string{
"client_id", "workdir", "ipc_endpoint", "im.*,approval.*",
"^im\\.", "compact", "/tmp/events", "route[0]", "max_events : 5",
"duration", "true",
"duration", "flatten : true", "true",
}
for _, w := range wants {
if !strings.Contains(out, w) {
+33
View File
@@ -394,6 +394,39 @@ func outputSchema(eventKey string) map[string]any {
}
}
func transportEnvelopeSchema(eventKey string) map[string]any {
eventType := reflect.TypeOf(transport.Event{})
properties := make(map[string]any, eventType.NumField())
for i := 0; i < eventType.NumField(); i++ {
field := eventType.Field(i)
name := strings.Split(field.Tag.Get("json"), ",")[0]
property := map[string]any{"type": schemaType(field.Type)}
switch name {
case "type":
property["description"] = "transport frame 类型"
property["enum"] = []string{string(transport.FrameTypeEvent)}
case "event_type":
property["description"] = "事件类型"
property["enum"] = []string{eventKey}
case "data":
property["description"] = "服务端业务 payload JSON 字符串"
property["content_media_type"] = "application/json"
case "headers":
property["description"] = "Stream transport headers"
property["additionalProperties"] = map[string]any{"type": "string"}
case "event_id":
property["description"] = "transport 事件 ID"
case "subscribe_id":
property["description"] = "个人事件订阅 ID"
}
properties[name] = property
}
return map[string]any{
"type": "object",
"properties": properties,
}
}
func outputTypeForEvent(eventKey string) reflect.Type {
switch {
case isMessageReceiveEvent(eventKey):
+12 -2
View File
@@ -258,8 +258,18 @@ func Catalog(category string, enabledOnly, includePending bool) []Definition {
}
func BuildSchemaDocument(def Definition) SchemaDocument {
return BuildSchemaDocumentForMode(def, false)
}
func BuildSchemaDocumentForMode(def Definition, flatten bool) SchemaDocument {
requiredParams := make([]string, 0, len(def.RequiredParams))
requiredParams = append(requiredParams, def.RequiredParams...)
jqRootPath := ".data | fromjson"
schema := transportEnvelopeSchema(def.EventKey)
if flatten {
jqRootPath = "."
schema = outputSchema(def.EventKey)
}
return SchemaDocument{
EventKey: def.EventKey,
DisplayName: def.DisplayName,
@@ -268,8 +278,8 @@ func BuildSchemaDocument(def Definition) SchemaDocument {
RuleType: def.RuleType,
RequiredParams: requiredParams,
Constraints: cloneParameterConstraints(def.Constraints),
JQRootPath: ".",
Schema: outputSchema(def.EventKey),
JQRootPath: jqRootPath,
Schema: schema,
}
}
+54 -13
View File
@@ -95,7 +95,7 @@ func TestDefinitionJSONHidesInternalSchemaIDs(t *testing.T) {
}
}
func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) {
func TestSchemaDocumentsDefaultToTransportEnvelope(t *testing.T) {
for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser} {
t.Run(eventKey, func(t *testing.T) {
def, ok := Lookup(eventKey)
@@ -117,16 +117,16 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) {
"required_params",
"jq_root_path",
"schema",
"type",
"seq",
"event_id",
"timestamp",
"event_born_time",
"event_type",
"subscribe_id",
"content",
"sender",
"sender_open_dingtalk_id",
"conversation_id",
"message_id",
"create_time",
"event_time",
"source_id",
"data",
"headers",
"received_at_unix_ms",
} {
if !strings.Contains(out, want) {
t.Fatalf("schema for %s missing %q: %s", eventKey, want, out)
@@ -144,7 +144,6 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) {
"payload_schema",
"output_schema",
"data_json_path",
"headers",
"audit",
"tenant",
"subject",
@@ -152,13 +151,18 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) {
"msgIdMetaq",
"at_users",
"sender_user_id",
"sender_open_dingtalk_id",
"conversation_id",
"message_id",
"create_time",
"event_time",
} {
if strings.Contains(out, leaked) {
t.Fatalf("schema for %s leaked %q: %s", eventKey, leaked, out)
}
}
if doc.JQRootPath != "." {
t.Fatalf("jq_root_path = %q, want .", doc.JQRootPath)
if doc.JQRootPath != ".data | fromjson" {
t.Fatalf("jq_root_path = %q, want .data | fromjson", doc.JQRootPath)
}
if doc.RequiredParams == nil {
t.Fatalf("required_params = nil, want empty slice")
@@ -167,6 +171,38 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) {
if !ok {
t.Fatalf("schema.properties = %#v, want object", doc.Schema["properties"])
}
wantProperties := []string{
"type", "seq", "event_id", "event_born_time", "event_corp_id",
"event_type", "event_unified_app_id", "event_scope", "subscribe_id",
"source_id", "rule_type", "data", "headers", "received_at_unix_ms",
}
if len(props) != len(wantProperties) {
t.Fatalf("schema.properties = %#v, want exactly %d transport fields", props, len(wantProperties))
}
for _, name := range wantProperties {
if _, ok := props[name].(map[string]any); !ok {
t.Fatalf("schema.properties.%s = %#v, want object", name, props[name])
}
}
})
}
}
func TestFlattenedSchemaDocumentsUseMessageDTO(t *testing.T) {
for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser} {
t.Run(eventKey, func(t *testing.T) {
def, ok := Lookup(eventKey)
if !ok {
t.Fatalf("Lookup(%q) failed", eventKey)
}
doc := BuildSchemaDocumentForMode(def, true)
if doc.JQRootPath != "." {
t.Fatalf("jq_root_path = %q, want .", doc.JQRootPath)
}
props, ok := doc.Schema["properties"].(map[string]any)
if !ok {
t.Fatalf("schema.properties = %#v, want object", doc.Schema["properties"])
}
wantProperties := []string{
"type", "event_id", "timestamp", "subscribe_id", "message_id",
"conversation_id", "sender", "sender_open_dingtalk_id", "content",
@@ -180,6 +216,11 @@ func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) {
t.Fatalf("schema.properties.%s = %#v, want object", name, props[name])
}
}
for _, transportField := range []string{"data", "headers", "seq", "event_type"} {
if _, ok := props[transportField]; ok {
t.Fatalf("flattened schema exposed transport field %q", transportField)
}
}
})
}
}
@@ -286,7 +327,7 @@ func TestActionSchemaDocumentsMatchOutputDTOs(t *testing.T) {
if !ok {
t.Fatalf("Lookup(%q) failed", eventKey)
}
doc := BuildSchemaDocument(def)
doc := BuildSchemaDocumentForMode(def, true)
if doc.JQRootPath != "." {
t.Fatalf("jq_root_path = %q, want .", doc.JQRootPath)
}
+7 -4
View File
@@ -116,11 +116,14 @@ func NewWorkflowInvocation(legacyPath, workflowName string, steps []Invocation)
func MergePayloads(jsonPayload, paramsPayload string, overrides map[string]any) (map[string]any, error) {
merged := make(map[string]any)
for label, payload := range map[string]string{
"--json": jsonPayload,
"--params": paramsPayload,
for _, payload := range []struct {
label string
raw string
}{
{label: "--json", raw: jsonPayload},
{label: "--params", raw: paramsPayload},
} {
value, err := parseJSONObject(label, payload)
value, err := parseJSONObject(payload.label, payload.raw)
if err != nil {
return nil, err
}
+4
View File
@@ -52,6 +52,10 @@ func TestCrossPlatformCoverageMergePayloadsAndToolCallRequest(t *testing.T) {
if empty, err := MergePayloads(" ", "", nil); err != nil || len(empty) != 0 {
t.Fatalf("empty MergePayloads() = %#v, %v", empty, err)
}
merged, err = MergePayloads(`{"same":"json"}`, `{"same":"params"}`, nil)
if err != nil || merged["same"] != "params" {
t.Fatalf("MergePayloads() precedence = %#v, %v; want --params to win", merged, err)
}
for _, input := range []string{`{`, `[]`, `null`} {
if _, err := MergePayloads(input, "", nil); err == nil {
t.Errorf("MergePayloads(%q) error = nil", input)
@@ -18,7 +18,7 @@ func TestCrossPlatformCoverageGenerateProductionAgentMetadataPipeline(t *testing
if err != nil {
t.Fatal(err)
}
root := app.NewRootCommand()
root := app.NewSchemaSourceRootCommand()
if _, err := cli.ApplyEmbeddedManualSchemaHints(root); err != nil {
t.Fatal(err)
}
@@ -46,7 +46,7 @@ var (
writeMetadataFileBytes = os.WriteFile
writeMetadataJSON = writeJSON
newMetadataRoot = app.NewRootCommand
newMetadataRoot = app.NewSchemaSourceRootCommand
buildEffectiveMetadata = cli.BuildEffectiveCommandRegistry
bindEffectiveMetadata = cli.BindEffectiveCommandRegistry
loadSelectionMetadataHints = cli.LoadAgentHintsFromSelectionForValidation
@@ -48,7 +48,7 @@ func main() {
fail(err)
}
root := app.NewRootCommand()
root := app.NewSchemaSourceRootCommand()
if err := generateSchemaCatalog(root, resolvedSurfacePath, outputPath); err != nil {
fail(err)
}
@@ -131,7 +131,7 @@ func TestCrossPlatformCoverageGenerateSchemaCatalogFailureEdges(t *testing.T) {
}
func TestCrossPlatformCoverageGenerateSchemaCatalogResolvesBuildExactlyOnce(t *testing.T) {
root := app.NewRootCommand()
root := app.NewSchemaSourceRootCommand()
resolveCalls := 0
resolvedRegistryHash := ""
resolver := func(candidate *cobra.Command) (cli.ResolvedSchemaBuild, error) {
@@ -17,7 +17,7 @@ import (
)
var (
newSmokeRoot = app.NewRootCommand
newSmokeRoot = app.NewSchemaSourceRootCommand
buildEffectiveSmokeRegistry = cli.BuildEffectiveCommandRegistry
bindEffectiveSmokeRegistry = cli.BindEffectiveCommandRegistry
buildSmokeRegistryData = buildSmokeRegistry
+26
View File
@@ -3143,6 +3143,25 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
# resource-id: 从 dws chat message list 返回的消息内容中获取 mediaId
# message-id: 从 dws chat message list 返回的 openMessageId
# open-conversation-id: 从 dws chat search 获取 openConversationId`,
PreRunE: func(cmd *cobra.Command, args []string) error {
// Cobra validates required flags after PreRunE. Copy a supplied alias
// into the canonical flag first so --message-id can remain a hard
// required fact in both the executable and Agent Schema contracts.
if cmd.Flags().Changed("message-id") {
return nil
}
alias := ""
switch {
case cmd.Flags().Changed("msg-id"):
alias = "msg-id"
case cmd.Flags().Changed("open-message-id"):
alias = "open-message-id"
default:
return nil
}
value, _ := cmd.Flags().GetString(alias) // registered string flags above
return cmd.Flags().Set("message-id", value)
},
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "type", "resource-id", "message-id", "open-conversation-id", "output"); err != nil {
return err
@@ -3226,6 +3245,13 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
_ = chatMessageDownloadMediaCmd.MarkFlagRequired("open-conversation-id")
chatMessageDownloadMediaCmd.Flags().String("message-id", "", "消息 openMessageId (必填)")
_ = chatMessageDownloadMediaCmd.MarkFlagRequired("message-id")
// Hidden aliases: agents routinely pass --msg-id / --open-message-id since
// the message-list output exposes the field as openMessageId/msgId. Accept
// them transparently instead of failing with "unknown flag".
chatMessageDownloadMediaCmd.Flags().String("msg-id", "", "--message-id 的别名")
_ = chatMessageDownloadMediaCmd.Flags().MarkHidden("msg-id")
chatMessageDownloadMediaCmd.Flags().String("open-message-id", "", "--message-id 的别名")
_ = chatMessageDownloadMediaCmd.Flags().MarkHidden("open-message-id")
chatMessageDownloadMediaCmd.Flags().String("output", "", "本地保存路径,文件或目录 (必填)")
_ = chatMessageDownloadMediaCmd.MarkFlagRequired("output")
@@ -222,6 +222,12 @@ func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *te
tmp := t.TempDir()
base := []string{"message", "download-media", "--type=mediaId", "--resource-id=r", "--open-conversation-id=cid", "--message-id=mid"}
_ = runChatCoverageCommand(t, &productExampleCaller{dry: true}, append(base, "--output="+filepath.Join(tmp, "dry"))...)
for _, alias := range []string{"--msg-id=mid", "--open-message-id=mid"} {
aliasArgs := []string{"message", "download-media", "--type=mediaId", "--resource-id=r", "--open-conversation-id=cid", alias, "--output=" + filepath.Join(tmp, "alias-dry")}
_ = runChatCoverageCommand(t, &productExampleCaller{dry: true}, aliasArgs...)
}
missingMessageID := []string{"message", "download-media", "--type=mediaId", "--resource-id=r", "--open-conversation-id=cid", "--output=" + filepath.Join(tmp, "missing-id")}
_ = runChatCoverageCommand(t, &productExampleCaller{dry: true}, missingMessageID...)
for _, tc := range []struct {
step scriptedToolStep
out string
+77 -17
View File
@@ -14,7 +14,9 @@
package plugin
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"os"
"path/filepath"
@@ -24,6 +26,8 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
)
const maxPluginCLIOverlayBytes = 4 << 20
// UserContext holds the minimal user identity fields injected into
// stdio plugin subprocesses via environment variables.
type UserContext struct {
@@ -111,23 +115,9 @@ func (p *Plugin) ToServerDescriptors() []mcptypes.ServerDescriptor {
continue
}
overlay := mcptypes.CLIOverlay{}
if len(srv.CLI) > 0 {
if err := json.Unmarshal(srv.CLI, &overlay); err != nil {
slog.Warn("plugin: failed to parse CLIOverlay",
"plugin", p.Manifest.Name,
"server", key,
"error", err,
)
}
}
// Ensure the overlay has an ID — fall back to server key.
if overlay.ID == "" {
overlay.ID = key
}
if overlay.Command == "" {
overlay.Command = key
overlay, ok := p.ResolveCLIOverlay(key)
if !ok {
continue
}
source := "plugin"
@@ -154,3 +144,73 @@ func (p *Plugin) ToServerDescriptors() []mcptypes.ServerDescriptor {
}
return descriptors
}
// ResolveCLIOverlay resolves inline or external manifest CLI metadata exactly
// once. External files are opened relative to the plugin root with os.Root so
// absolute paths, parent traversal, and escaping symlinks fail closed.
func (p *Plugin) ResolveCLIOverlay(serverKey string) (mcptypes.CLIOverlay, bool) {
overlay := mcptypes.CLIOverlay{
ID: serverKey,
Command: serverKey,
}
server, ok := p.Manifest.MCPServers[serverKey]
if !ok || len(server.CLI) == 0 {
return overlay, true
}
data := []byte(strings.TrimSpace(string(server.CLI)))
if len(data) == 0 {
return overlay, true
}
if data[0] == '"' {
var relativePath string
if err := json.Unmarshal(data, &relativePath); err != nil ||
strings.TrimSpace(relativePath) == "" {
slog.Warn("plugin: invalid external CLI overlay path",
"plugin", p.Manifest.Name, "server", serverKey, "error", err)
return mcptypes.CLIOverlay{}, false
}
root, err := os.OpenRoot(p.Root)
if err != nil {
slog.Warn("plugin: failed to open plugin root",
"plugin", p.Manifest.Name, "server", serverKey, "error", err)
return mcptypes.CLIOverlay{}, false
}
defer root.Close()
file, err := root.Open(relativePath)
if err != nil {
slog.Warn("plugin: failed to open CLI overlay file",
"plugin", p.Manifest.Name, "server", serverKey,
"path", relativePath, "error", err)
return mcptypes.CLIOverlay{}, false
}
defer file.Close()
data, err = io.ReadAll(io.LimitReader(file, maxPluginCLIOverlayBytes+1))
if err != nil || len(data) > maxPluginCLIOverlayBytes {
slog.Warn("plugin: failed to read CLI overlay file",
"plugin", p.Manifest.Name, "server", serverKey,
"path", relativePath, "error", err)
return mcptypes.CLIOverlay{}, false
}
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&overlay); err != nil {
slog.Warn("plugin: failed to parse CLI overlay",
"plugin", p.Manifest.Name, "server", serverKey, "error", err)
return mcptypes.CLIOverlay{}, false
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
slog.Warn("plugin: CLI overlay contains trailing JSON",
"plugin", p.Manifest.Name, "server", serverKey, "error", err)
return mcptypes.CLIOverlay{}, false
}
if strings.TrimSpace(overlay.ID) == "" {
overlay.ID = serverKey
}
if strings.TrimSpace(overlay.Command) == "" {
overlay.Command = serverKey
}
return overlay, true
}
+143
View File
@@ -0,0 +1,143 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package plugin
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestResolveCLIOverlayDefaultsAndInlineErrors(t *testing.T) {
plugin := &Plugin{
Root: t.TempDir(),
Manifest: Manifest{MCPServers: map[string]*MCPServer{
"empty": {CLI: nil},
"whitespace": {CLI: json.RawMessage(" \n\t")},
"fallback": {CLI: json.RawMessage(`{"id":" ","command":""}`)},
"malformed": {CLI: json.RawMessage(`{`)},
"unknown": {CLI: json.RawMessage(`{"id":"plugin","unknownField":true}`)},
"unknownFlag": {CLI: json.RawMessage(`{
"toolOverrides":{"tool":{"flags":{"value":{"unknownFlagField":true}}}}
}`)},
"trailing": {CLI: json.RawMessage(`{} {}`)},
"legacyTools": {CLI: json.RawMessage(`{
"tools":[{
"name":"tool","cliName":"leaf","title":"Title","description":"Description",
"isSensitive":true,"category":"read","hidden":true,
"flags":{"value":{"alias":"value-alias","shorthand":"v"}}
}]
}`)},
}},
}
for _, serverKey := range []string{"missing", "empty", "whitespace", "fallback"} {
t.Run(serverKey, func(t *testing.T) {
overlay, ok := plugin.ResolveCLIOverlay(serverKey)
if !ok || overlay.ID != serverKey || overlay.Command != serverKey {
t.Fatalf("ResolveCLIOverlay(%q) = (%#v, %v), want default overlay", serverKey, overlay, ok)
}
})
}
legacy, ok := plugin.ResolveCLIOverlay("legacyTools")
if !ok || len(legacy.Tools) != 1 || legacy.Tools[0].CLIName != "leaf" ||
legacy.Tools[0].Flags["value"].Alias != "value-alias" {
t.Fatalf("ResolveCLIOverlay(legacyTools) = (%#v, %v), want historical tool metadata", legacy, ok)
}
if overlay, ok := plugin.ResolveCLIOverlay("malformed"); ok {
t.Fatalf("ResolveCLIOverlay(malformed) = (%#v, true), want failure", overlay)
}
for _, serverKey := range []string{"unknown", "unknownFlag", "trailing"} {
if overlay, ok := plugin.ResolveCLIOverlay(serverKey); ok {
t.Fatalf("ResolveCLIOverlay(%s) = (%#v, true), want strict failure", serverKey, overlay)
}
}
}
func TestResolveCLIOverlayRejectsInvalidExternalFiles(t *testing.T) {
root := t.TempDir()
plugin := &Plugin{
Root: root,
Manifest: Manifest{MCPServers: map[string]*MCPServer{
"external": {},
}},
}
server := plugin.Manifest.MCPServers["external"]
t.Run("malformed path JSON", func(t *testing.T) {
server.CLI = json.RawMessage(`"unterminated`)
if overlay, ok := plugin.ResolveCLIOverlay("external"); ok {
t.Fatalf("ResolveCLIOverlay() = (%#v, true), want malformed path failure", overlay)
}
})
t.Run("blank path", func(t *testing.T) {
server.CLI = json.RawMessage(`" "`)
if overlay, ok := plugin.ResolveCLIOverlay("external"); ok {
t.Fatalf("ResolveCLIOverlay() = (%#v, true), want blank path failure", overlay)
}
})
t.Run("missing root", func(t *testing.T) {
plugin.Root = filepath.Join(root, "does-not-exist")
server.CLI = json.RawMessage(`"overlay.json"`)
if overlay, ok := plugin.ResolveCLIOverlay("external"); ok {
t.Fatalf("ResolveCLIOverlay() = (%#v, true), want missing root failure", overlay)
}
plugin.Root = root
})
t.Run("missing file", func(t *testing.T) {
server.CLI = json.RawMessage(`"missing.json"`)
if overlay, ok := plugin.ResolveCLIOverlay("external"); ok {
t.Fatalf("ResolveCLIOverlay() = (%#v, true), want missing file failure", overlay)
}
})
t.Run("read error", func(t *testing.T) {
if err := os.Mkdir(filepath.Join(root, "overlay-dir"), 0o700); err != nil {
t.Fatal(err)
}
server.CLI = json.RawMessage(`"overlay-dir"`)
if overlay, ok := plugin.ResolveCLIOverlay("external"); ok {
t.Fatalf("ResolveCLIOverlay() = (%#v, true), want directory read failure", overlay)
}
})
t.Run("oversized file", func(t *testing.T) {
path := filepath.Join(root, "oversized.json")
if err := os.WriteFile(path, bytes.Repeat([]byte(" "), maxPluginCLIOverlayBytes+1), 0o600); err != nil {
t.Fatal(err)
}
server.CLI = json.RawMessage(`"oversized.json"`)
if overlay, ok := plugin.ResolveCLIOverlay("external"); ok {
t.Fatalf("ResolveCLIOverlay() = (%#v, true), want oversized file failure", overlay)
}
})
}
func TestResolveCLIOverlayExternalFileAppliesFallbackIdentity(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "overlay.json"), []byte(`{
"id":"",
"command":" ",
"aliases":["conference"]
}`), 0o600); err != nil {
t.Fatal(err)
}
plugin := &Plugin{
Root: root,
Manifest: Manifest{MCPServers: map[string]*MCPServer{
"server-key": {CLI: json.RawMessage(`"overlay.json"`)},
}},
}
overlay, ok := plugin.ResolveCLIOverlay("server-key")
if !ok || overlay.ID != "server-key" || overlay.Command != "server-key" ||
len(overlay.Aliases) != 1 || overlay.Aliases[0] != "conference" {
t.Fatalf("ResolveCLIOverlay() = (%#v, %v), want fallback identity with file metadata", overlay, ok)
}
}
+10 -6
View File
@@ -131,22 +131,19 @@ func TestCrossPlatformCoveragePluginConverterEdges(t *testing.T) {
t.Fatalf("StdioClients(user) length = %d", len(got))
}
descriptors := p.ToServerDescriptors()
if len(descriptors) != 2 {
if len(descriptors) != 1 {
t.Fatalf("descriptors length = %d", len(descriptors))
}
seenDefault := false
seenOverlay := false
for _, d := range descriptors {
switch d.Key {
case "http-default":
seenDefault = d.CLI.ID == "http-default" && d.CLI.Command == "http-default" && d.HasCLIMeta
case "http-overlay":
seenOverlay = d.CLI.ID == "custom" && d.CLI.Command == "run" &&
d.AuthHeaders["Authorization"] == "Bearer secret"
}
}
if !seenDefault || !seenOverlay {
t.Fatalf("descriptor defaults/overlay not covered: %#v", descriptors)
if !seenOverlay {
t.Fatalf("valid descriptor overlay not covered: %#v", descriptors)
}
dataDir := filepath.Join(filepath.Dir(filepath.Dir(root)), "data")
if got := expandPluginVars("$"+"{DWS_PLUGIN_ROOT}|$"+"{DWS_PLUGIN_DATA}|$"+"{PLUGIN_TOKEN}", root); got != root+"|"+dataDir+"|secret" {
@@ -300,6 +297,7 @@ func TestCrossPlatformCoverageManifestEdges(t *testing.T) {
func TestCrossPlatformCoverageLoaderDiscoveryLifecycle(t *testing.T) {
oldHome := pluginUserHomeDir
t.Cleanup(func() { pluginUserHomeDir = oldHome })
t.Setenv("DWS_CONFIG_DIR", "")
home := t.TempDir()
pluginUserHomeDir = func() (string, error) { return home, nil }
defaultLoader := NewLoader("1.2.3")
@@ -310,6 +308,12 @@ func TestCrossPlatformCoverageLoaderDiscoveryLifecycle(t *testing.T) {
if got := NewLoader("dev").PluginsDir; got != filepath.Join(".dws", "plugins") {
t.Fatalf("NewLoader error path = %q", got)
}
customConfig := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", customConfig)
if got := NewLoader("dev").PluginsDir; got != filepath.Join(customConfig, "plugins") {
t.Fatalf("NewLoader custom config path = %q", got)
}
t.Setenv("DWS_CONFIG_DIR", "")
root := t.TempDir()
l := &Loader{PluginsDir: root, CLIVersion: "1.0.0"}
+6 -2
View File
@@ -56,9 +56,13 @@ var (
// NewLoader creates a Loader with default paths.
func NewLoader(cliVersion string) *Loader {
home, _ := pluginUserHomeDir()
configDir := strings.TrimSpace(os.Getenv("DWS_CONFIG_DIR"))
if configDir == "" {
home, _ := pluginUserHomeDir()
configDir = filepath.Join(home, ".dws")
}
return &Loader{
PluginsDir: filepath.Join(home, ".dws", "plugins"),
PluginsDir: filepath.Join(configDir, "plugins"),
CLIVersion: cliVersion,
}
}
+77 -2
View File
@@ -74,6 +74,56 @@ func TestParseManifest(t *testing.T) {
}
}
func TestResolveCLIOverlayExternalFileIsRootContained(t *testing.T) {
root := t.TempDir()
overlayPath := filepath.Join(root, "overlay.json")
if err := os.WriteFile(overlayPath, []byte(`{
"id":"external-id",
"command":"external-command",
"toolOverrides":{"ping":{"cliName":"ping"}}
}`), 0o600); err != nil {
t.Fatal(err)
}
loaded := &Plugin{
Root: root,
Manifest: Manifest{
Name: "external-plugin",
MCPServers: map[string]*MCPServer{
"external": {
Type: "streamable-http",
Endpoint: "https://example.invalid/mcp",
CLI: json.RawMessage(`"overlay.json"`),
},
},
},
}
overlay, ok := loaded.ResolveCLIOverlay("external")
if !ok || overlay.ID != "external-id" || overlay.Command != "external-command" {
t.Fatalf("external overlay = (%#v, %v)", overlay, ok)
}
descriptors := loaded.ToServerDescriptors()
if len(descriptors) != 1 || descriptors[0].CLI.ID != "external-id" {
t.Fatalf("external HTTP descriptors = %#v", descriptors)
}
outside := filepath.Join(t.TempDir(), "outside-overlay.json")
if err := os.WriteFile(outside, []byte(`{"id":"escaped"}`), 0o600); err != nil {
t.Fatal(err)
}
loaded.Manifest.MCPServers["external"].CLI = json.RawMessage(`"../outside-overlay.json"`)
if overlay, ok := loaded.ResolveCLIOverlay("external"); ok {
t.Fatalf("parent traversal resolved overlay %#v", overlay)
}
link := filepath.Join(root, "escaped-link.json")
if err := os.Symlink(outside, link); err == nil {
loaded.Manifest.MCPServers["external"].CLI = json.RawMessage(`"escaped-link.json"`)
if overlay, ok := loaded.ResolveCLIOverlay("external"); ok {
t.Fatalf("escaping symlink resolved overlay %#v", overlay)
}
}
}
func TestManifestValidate(t *testing.T) {
tests := []struct {
name string
@@ -219,8 +269,23 @@ func TestLoadAllSuppressesOptionalPluginValidationWarnings(t *testing.T) {
func TestPluginToServerDescriptors(t *testing.T) {
cliOverlay, _ := json.Marshal(map[string]any{
"id": "conference",
"command": "conference",
"id": "conference",
"command": "conference",
"description": "video conference",
"groups": map[string]any{
"camera": map[string]any{"description": "camera control"},
},
"toolOverrides": map[string]any{
"open_camera": map[string]any{
"cliName": "open",
"group": "camera",
"description": "open camera",
"isSensitive": true,
"flags": map[string]any{
"device_id": map[string]any{"description": "camera device"},
},
},
},
})
p := &Plugin{
@@ -262,6 +327,16 @@ func TestPluginToServerDescriptors(t *testing.T) {
if d.CLI.ID != "conference" {
t.Errorf("cli.id = %q, want conference", d.CLI.ID)
}
override := d.CLI.ToolOverrides["open_camera"]
if d.CLI.Description != "video conference" ||
d.CLI.Groups["camera"].Description != "camera control" ||
override.CLIName != "open" ||
override.Group != "camera" ||
override.Description != "open camera" ||
!override.IsSensitive ||
override.Flags["device_id"].Description != "camera device" {
t.Fatalf("CLI overlay command metadata was not preserved: %#v", d.CLI)
}
}
func TestPluginToServerDescriptorsWithHeaders(t *testing.T) {
@@ -0,0 +1,33 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
package builtin_test
import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/builtin"
)
func TestBaseCommandsExposeSortedDistributionShortcuts(t *testing.T) {
commands := builtin.BaseCommands()
if len(commands) == 0 {
t.Fatal("BaseCommands returned no distribution shortcuts")
}
for index, command := range commands {
if index > 0 && commands[index-1].Name() > command.Name() {
t.Fatalf("BaseCommands are not sorted: %q before %q", commands[index-1].Name(), command.Name())
}
children := command.Commands()
if len(children) == 0 {
t.Fatalf("base shortcut service %q has no commands", command.Name())
}
for _, child := range children {
if !strings.HasPrefix(child.Name(), "+") {
t.Fatalf("base shortcut %q under %q is not mounted as a +command", child.Name(), command.Name())
}
}
}
}
+6
View File
@@ -50,3 +50,9 @@ import (
func Commands() []*cobra.Command {
return shortcut.Commands()
}
// BaseCommands returns only distribution-owned shortcuts for Schema and
// interface generation.
func BaseCommands() []*cobra.Command {
return shortcut.BuiltInCommands()
}
+33 -17
View File
@@ -17,6 +17,7 @@ import (
"fmt"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
// MessagesSend sends a text/markdown message as the current user
@@ -242,6 +243,11 @@ var MessagesList = shortcut.Shortcut{
// clean output projection. Both the list container and the per-item field names are
// probed defensively across candidate keys, so an empty or unexpected shape
// yields an empty list rather than a crash or fabricated data.
//
// Text goes through the shared chatmsg projection so card/auto-reply JSON is
// rendered readable and encrypted ciphertext is marked, and forwarded chat
// records ("聊天记录") expand their nested messages under "forwarded" instead of
// collapsing to a "[卡片]" summary.
func listMessagesProject(data map[string]any) []map[string]any {
raw := listMessagesResolveList(data)
out := make([]map[string]any, 0, len(raw))
@@ -250,29 +256,39 @@ func listMessagesProject(data map[string]any) []map[string]any {
if !ok {
continue
}
row := map[string]any{}
if v, ok := listMessagesFirst(m, "openMessageId", "openMsgId", "messageId", "msgId"); ok {
row["messageId"] = v
}
if v, ok := listMessagesFirst(m, "senderOpenDingTalkId", "senderUserId", "senderId", "senderStaffId"); ok {
row["senderId"] = v
}
if v, ok := listMessagesFirst(m, "msgType", "messageType", "type"); ok {
row["msgType"] = v
}
if v, ok := listMessagesFirst(m, "createTime", "sendTime", "gmtCreate", "messageTime"); ok {
row["createTime"] = v
}
if v, ok := listMessagesFirst(m, "text", "content", "plainText"); ok {
row["text"] = v
}
if len(row) > 0 {
if row := listMessageProjectOne(m); len(row) > 0 {
out = append(out, row)
}
}
return out
}
// listMessageProjectOne projects a single message into the native
// {messageId, senderId, msgType, createTime, text(, forwarded)} shape, reused
// recursively for forwarded chat records.
func listMessageProjectOne(m map[string]any) map[string]any {
row := map[string]any{}
if v, ok := listMessagesFirst(m, "openMessageId", "openMsgId", "messageId", "msgId"); ok {
row["messageId"] = v
}
if v, ok := listMessagesFirst(m, "senderOpenDingTalkId", "senderUserId", "senderId", "senderStaffId"); ok {
row["senderId"] = v
}
if v, ok := listMessagesFirst(m, "msgType", "messageType", "type"); ok {
row["msgType"] = v
}
if v, ok := listMessagesFirst(m, "createTime", "sendTime", "gmtCreate", "messageTime"); ok {
row["createTime"] = v
}
if text := chatmsg.Text(m); text != nil {
row["text"] = text
}
if forwarded := chatmsg.Forwarded(m, listMessageProjectOne); len(forwarded) > 0 {
row["forwarded"] = forwarded
}
return row
}
// listMessagesResolveList locates the list payload, tolerating a bare top-level
// array container or nesting one level deeper under a common envelope key.
func listMessagesResolveList(data map[string]any) []any {
@@ -0,0 +1,56 @@
// 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 (
"strings"
"testing"
)
const testCipher = "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP||2||1||1"
func TestListMessageProjectOne(t *testing.T) {
// full field mapping + forwarded expansion; an encrypted body is marked (no
// cross-conversation recovery), not leaked as base64.
row := listMessageProjectOne(map[string]any{
"openMessageId": "mid",
"senderOpenDingTalkId": "DXYZ",
"msgType": "text",
"createTime": "2026-07-19 13:37:03",
"content": testCipher,
"forwardMessages": []any{
map[string]any{"openMessageId": "c1", "senderOpenDingTalkId": "DA", "content": "子消息", "createTime": "t"},
},
})
if row["messageId"] != "mid" || row["senderId"] != "DXYZ" || row["msgType"] != "text" {
t.Fatalf("field mapping = %#v", row)
}
if row["createTime"] != "2026-07-19 13:37:03" {
t.Errorf("createTime = %v", row["createTime"])
}
if s, _ := row["text"].(string); !strings.Contains(s, "加密消息") || strings.Contains(s, "||2||1||") {
t.Errorf("encrypted text = %v, want marker", row["text"])
}
fwd, ok := row["forwarded"].([]map[string]any)
if !ok || len(fwd) != 1 || fwd[0]["messageId"] != "c1" || fwd[0]["text"] != "子消息" {
t.Errorf("forwarded = %#v", row["forwarded"])
}
// a bare message with no recognizable fields → empty row (no keys)
row = listMessageProjectOne(map[string]any{"unrelated": 1})
if len(row) != 0 {
t.Errorf("empty message row = %#v, want no keys", row)
}
}
+292
View File
@@ -0,0 +1,292 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package chatmsg holds the shared, read-only projection helpers for DingTalk
// message-list responses (list_individual_chat_message,
// list_conversation_message_v2, search_at_me_message, search_messages_by_keyword,
// list_topic_replies, …). Several shortcuts reshape those raw responses into a
// clean speaker/text/time list; centralising the fiddly bits here keeps them
// consistent and fixed in one place:
//
// - Sender: the display name lives under the bare "sender" key, forwarded
// entries carry the literal string "null", and some responses nest the
// speaker in a {name:…} object — all handled here.
// - Text: out-of-office auto-replies / cards arrive as raw rich-content JSON,
// and card/robot messages arrive as undecryptable ciphertext; CleanText
// renders the former to readable text and marks the latter, WITHOUT ever
// rewriting ordinary text that merely contains a JSON fragment.
// - Forwarded: a forwarded chat record ("聊天记录") hides its real per-message
// bodies in forwardMessages while the top-level content is a lossy summary.
package chatmsg
import (
"encoding/json"
"regexp"
"strings"
)
// Sender reads a message's speaker display name, tolerating common sender-name
// keys. The message-list responses carry the display name under the bare
// "sender" key (verified live), so it is probed first; the remaining aliases and
// the *Id fallbacks keep the projection resilient to other shapes. The literal
// string "null" (forwarded entries) and the empty string are treated as absent,
// and a nested {name:…} sender object yields its display name rather than the
// raw object.
func Sender(m map[string]any) any {
for _, key := range []string{"sender", "senderName", "senderNick", "nick", "senderStaffName", "userName", "name", "senderId", "senderStaffId", "senderOpenDingTalkId"} {
v, ok := m[key]
if !ok || v == nil {
continue
}
switch t := v.(type) {
case string:
if t == "" || t == "null" {
continue
}
return t
case map[string]any:
// Nested sender object: extract a display-name field; never return
// the raw map (it would surface a JSON object and block fallbacks).
if name := senderDisplayName(t); name != "" {
return name
}
continue
default:
// Scalar id (e.g. numeric) — usable as-is.
return v
}
}
return nil
}
// senderDisplayName extracts a human name from a nested sender object.
func senderDisplayName(m map[string]any) string {
for _, k := range []string{"name", "nick", "userName", "staffName", "displayName", "senderName"} {
if s, ok := m[k].(string); ok {
if s = strings.TrimSpace(s); s != "" && s != "null" {
return s
}
}
}
return ""
}
// Text reads a message's textual content (tolerating common text keys and one
// level of nesting) and runs it through CleanText.
func Text(m map[string]any) any {
for _, key := range []string{"text", "content", "msgContent", "message", "body", "plainText"} {
v, ok := m[key]
if !ok || v == nil {
continue
}
switch t := v.(type) {
case string:
if t != "" {
return CleanText(t)
}
case map[string]any:
for _, inner := range []string{"text", "content", "value"} {
if s, ok := t[inner].(string); ok && s != "" {
return CleanText(s)
}
}
}
}
return nil
}
// CreateTime reads a message's create/send time under whichever candidate key is
// present, returning the raw value.
func CreateTime(m map[string]any) any {
for _, key := range []string{"createTime", "sendTime", "gmtCreate", "createAt", "timestamp", "time"} {
if v, ok := m[key]; ok && v != nil {
return v
}
}
return nil
}
// Forwarded projects the nested messages of a forwarded chat record. The caller
// supplies its own per-message projection so each command keeps its own row
// shape; project is applied recursively, so multi-level forwards expand too.
func Forwarded(m map[string]any, project func(map[string]any) map[string]any) []map[string]any {
raw, ok := m["forwardMessages"].([]any)
if !ok || len(raw) == 0 {
return nil
}
out := make([]map[string]any, 0, len(raw))
for _, e := range raw {
if sub, ok := e.(map[string]any); ok {
out = append(out, project(sub))
}
}
return out
}
// CleanText makes a message body human-readable WITHOUT ever rewriting ordinary
// text. It only transforms a body that is a genuine DingTalk structured message:
//
// - Encrypted card/robot ciphertext (base64 + "||v||t||len" trailer) → a clear
// "[加密消息]" marker instead of the raw base64.
// - A rich-content card (out-of-office auto-reply, link/preview card, …) whose
// lines include at least one recognised rich-content block → the readable
// text extracted from those blocks, with the card's decorative JSON lines and
// "empty" placeholders dropped.
//
// Crucially, if NO line is a recognised rich-content block (e.g. ordinary text
// that merely embeds a `{"approved":false}` fragment), the original string is
// returned verbatim — a JSON line is never silently dropped.
func CleanText(s string) string {
if IsEncrypted(s) {
return "[加密消息,无法解码]"
}
// Fast path: no JSON delimiters at all — the overwhelming common case.
if !strings.ContainsAny(s, "{[") {
return s
}
lines := strings.Split(s, "\n")
isJSON := make([]bool, len(lines))
isDecoration := make([]bool, len(lines))
extracted := make([][]string, len(lines))
anyExtracted := false
for i, line := range lines {
t := strings.TrimSpace(line)
if !strings.HasPrefix(t, "{") && !strings.HasPrefix(t, "[") {
continue
}
var v any
if json.Unmarshal([]byte(t), &v) != nil {
continue
}
isJSON[i] = true
isDecoration[i] = isKnownRichDecoration(v)
if texts := richItemTexts(v); len(texts) > 0 {
extracted[i] = texts
anyExtracted = true
}
}
// No recognised rich-content block anywhere → treat the whole body as plain
// text (which may merely contain a JSON fragment) and return it untouched.
if !anyExtracted {
return s
}
out := make([]string, 0, len(lines))
for i, line := range lines {
if len(extracted[i]) > 0 {
out = append(out, extracted[i]...)
continue
}
// In card mode, drop only JSON shapes known to be card decoration.
// Unrecognised JSON may be user-authored message content and must remain
// verbatim even when another line contains a rich-content block.
if isJSON[i] && isDecoration[i] {
continue
}
if t := strings.TrimSpace(line); t == "" || t == "empty" {
continue
}
out = append(out, line)
}
// anyExtracted is true here, so out always holds at least one non-empty
// extracted text — the joined result is never empty.
return strings.TrimSpace(strings.Join(out, "\n"))
}
// isKnownRichDecoration recognises the two decoration records emitted alongside
// DingTalk rich-content bodies. Keep this deliberately narrow: an arbitrary JSON
// object in the same message is user content unless its shape is known here.
func isKnownRichDecoration(node any) bool {
m, ok := node.(map[string]any)
if !ok {
return false
}
_, hasPreviewURL := m["previewUrl"]
_, hasTitle := m["title"]
_, hasAutoLayout := m["autoLayout"]
_, hasEnableForward := m["enableForward"]
return (hasPreviewURL && hasTitle) || (hasAutoLayout && hasEnableForward)
}
// richItemTexts walks a decoded DingTalk rich-content blob and returns the
// readable text carried by its rich-content items (items[].data.text). It only
// harvests item bodies, so decorative fields (card titles, preview URLs, layout
// config) contribute nothing and are dropped. An empty result means "not a
// recognised rich-content block".
func richItemTexts(node any) []string {
var texts []string
var walk func(n any)
walk = func(n any) {
switch t := n.(type) {
case []any:
for _, e := range t {
walk(e)
}
case map[string]any:
if items, ok := t["items"].([]any); ok {
for _, it := range items {
mm, ok := it.(map[string]any)
if !ok {
continue
}
data, ok := mm["data"].(map[string]any)
if !ok {
continue
}
if s, ok := data["text"].(string); ok {
if s = strings.TrimSpace(s); s != "" {
texts = append(texts, s)
}
}
}
}
for _, e := range t {
walk(e)
}
}
}
walk(node)
return texts
}
// encryptedTrailerRE matches DingTalk's encrypted-message trailer
// "||<version>||<type>||<len>" (e.g. "||2||1||196") anchored at the end.
var encryptedTrailerRE = regexp.MustCompile(`\|\|\d+\|\|\d+\|\|\d+\s*$`)
// IsEncrypted reports whether a message body is a raw DingTalk encrypted-message
// ciphertext: a base64 blob (DingTalk wraps it across several lines) followed by
// the "||v||t||len" trailer. It is intentionally strict — both the trailer and a
// pure-base64 body are required — so ordinary text (CJK, punctuation, …) never
// trips it.
func IsEncrypted(s string) bool {
s = strings.TrimSpace(s)
if !encryptedTrailerRE.MatchString(s) {
return false
}
body := strings.TrimSpace(encryptedTrailerRE.ReplaceAllString(s, ""))
if len(body) < 32 {
return false
}
for _, r := range body {
switch {
case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9',
r == '+', r == '/', r == '=', r == '\n', r == '\r', r == ' ', r == '\t':
default:
return false
}
}
return true
}
+176
View File
@@ -0,0 +1,176 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package chatmsg
import (
"strings"
"testing"
)
func TestSender(t *testing.T) {
// The display name lives under the bare "sender" key.
if got := Sender(map[string]any{"sender": "念晨", "senderOpenDingTalkId": "D1"}); got != "念晨" {
t.Fatalf("sender = %v, want 念晨", got)
}
// Falls back to the open id when no display name is present.
if got := Sender(map[string]any{"senderOpenDingTalkId": "DXYZ"}); got != "DXYZ" {
t.Fatalf("sender fallback = %v, want DXYZ", got)
}
// forwardMessages entries carry the literal string "null" — treat as absent.
if got := Sender(map[string]any{"sender": "null"}); got != nil {
t.Fatalf("sender \"null\" = %v, want nil", got)
}
if got := Sender(map[string]any{"sender": "null", "senderName": "念晨"}); got != "念晨" {
t.Fatalf("sender \"null\" fallthrough = %v, want 念晨", got)
}
// A nested {name:…} sender object yields its display name, not the raw map.
if got := Sender(map[string]any{"sender": map[string]any{"name": "Alice"}}); got != "Alice" {
t.Fatalf("nested sender = %v, want Alice", got)
}
// A nested sender object with no usable name must not block the fallback.
if got := Sender(map[string]any{"sender": map[string]any{"foo": "bar"}, "senderName": "Bob"}); got != "Bob" {
t.Fatalf("nested-no-name fallthrough = %v, want Bob", got)
}
// A scalar numeric id is returned as-is.
if got := Sender(map[string]any{"senderId": float64(42)}); got != float64(42) {
t.Fatalf("numeric sender id = %v", got)
}
}
func TestCleanText(t *testing.T) {
// Out-of-office auto-reply: readable body lives in items[].data.text; the
// decorative preview/config JSON lines and "empty" placeholder are dropped.
autoReply := "* 仅你和对方可见\n" +
`[{"text":{"minSupportVersion":"1.1","translateMap":{},"version":"1.2","items":[{"fallbackKey":"","data":{"text":"你好,我在出差中,消息回复可能不及时。"},"style":{"size":15,"bold":0},"type":"text"}]},"type":"markdown"}]` + "\n" +
`{"previewUrl":"dingtalk://x","title":{"text":"自动回复","type":"text"}}` + "\n" +
"empty\n" +
`{"autoLayout":false,"enableForward":false}`
if got, want := CleanText(autoReply), "* 仅你和对方可见\n你好,我在出差中,消息回复可能不及时。"; got != want {
t.Fatalf("auto-reply cleaned = %q, want %q", got, want)
}
// P1 regression: ordinary text whose middle line is a JSON fragment (no
// rich-content block anywhere) must be returned VERBATIM, not rewritten.
mixed := "payload:\n{\"approved\":false}\nplease check"
if got := CleanText(mixed); got != mixed {
t.Fatalf("mixed text was rewritten: got %q, want %q", got, mixed)
}
// An ordinary JSON line must also survive when a different line contains a
// recognised rich-content block. Card mode is not permission to discard
// unrelated user-authored JSON.
richAndPlain := `[{"items":[{"data":{"text":"卡片正文"}}]}]` + "\n" +
`{"approved":false}`
if got, want := CleanText(richAndPlain), "卡片正文\n{\"approved\":false}"; got != want {
t.Fatalf("mixed rich/plain JSON was rewritten: got %q, want %q", got, want)
}
// Malformed items (non-map item, item whose "data" isn't a map) are skipped;
// only the well-formed item's text is extracted.
blob := `[{"items":["notmap",{"data":"notmap"},{"data":{"text":"有效正文"}}]}]`
if got := CleanText(blob); got != "有效正文" {
t.Fatalf("CleanText rich edge = %q, want 有效正文", got)
}
tests := map[string]string{
"上周五 7.1 KW": "上周五 7.1 KW",
"上周客户统计的[图片消息](mediaId=@lQ)": "上周客户统计的[图片消息](mediaId=@lQ)",
"[文件] 简历.pdf fileId: qnY 注意:如需下载使用dws drive download命令下载": "[文件] 简历.pdf fileId: qnY 注意:如需下载使用dws drive download命令下载",
"[讨论] 排期\n明天开会": "[讨论] 排期\n明天开会",
// a lone JSON object that isn't a rich-content block is left untouched
`{"autoLayout":false,"enableForward":false}`: `{"autoLayout":false,"enableForward":false}`,
}
for in, want := range tests {
if got := CleanText(in); got != want {
t.Errorf("CleanText(%q) = %q, want %q", in, got, want)
}
}
}
func TestIsEncryptedAndMarker(t *testing.T) {
cipher := "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP\n" +
"7oDptklFO+YzDltH+myErV6rkc8URHYykpeSDsMP6kznFa9E320NsIntfY771dx+\n" +
"||2||1||196"
if !IsEncrypted(cipher) {
t.Fatalf("ciphertext not detected: %q", cipher)
}
if got := CleanText(cipher); !strings.Contains(got, "加密消息") || strings.Contains(got, "||2||1||") {
t.Fatalf("encrypted cleaned = %q, want marker not ciphertext", got)
}
for _, s := range []string{
"上周五 7.1 KW",
"价格 100||2||1||3",
"[图片消息](mediaId=@lQLPJwDw3VmNDcfMos0DhLB3OHPQeTBlzgov2Oi1ly4A)",
"大哥,我看了一下我觉得有几个点可以关注一下",
strings.Repeat("好", 20) + "||2||1||1", // long CJK body + trailer, not base64
} {
if IsEncrypted(s) {
t.Errorf("false positive: %q flagged as encrypted", s)
}
}
}
func TestText(t *testing.T) {
if got := Text(map[string]any{"content": "你好"}); got != "你好" {
t.Errorf("Text string = %v", got)
}
if got := Text(map[string]any{"content": map[string]any{"text": "嵌套"}}); got != "嵌套" {
t.Errorf("Text nested = %v", got)
}
if got := Text(map[string]any{"plainText": "纯文本"}); got != "纯文本" {
t.Errorf("Text plainText = %v", got)
}
if got := Text(map[string]any{"foo": 1}); got != nil {
t.Errorf("Text none = %v, want nil", got)
}
}
func TestCreateTime(t *testing.T) {
if got := CreateTime(map[string]any{"sendTime": "2026-07-19 13:37:03"}); got != "2026-07-19 13:37:03" {
t.Errorf("CreateTime = %v", got)
}
if got := CreateTime(map[string]any{}); got != nil {
t.Errorf("CreateTime empty = %v, want nil", got)
}
}
func TestForwarded(t *testing.T) {
var project func(m map[string]any) map[string]any
project = func(m map[string]any) map[string]any {
row := map[string]any{"text": Text(m)}
if fwd := Forwarded(m, project); len(fwd) > 0 { // recurse
row["forwarded"] = fwd
}
return row
}
if got := Forwarded(map[string]any{"content": "x"}, project); got != nil {
t.Errorf("Forwarded none = %v", got)
}
fwd := Forwarded(map[string]any{
"forwardMessages": []any{
map[string]any{"content": "a"},
"not-a-map",
map[string]any{"content": "b", "forwardMessages": []any{
map[string]any{"content": "nested"},
}},
},
}, project)
if len(fwd) != 2 || fwd[0]["text"] != "a" || fwd[1]["text"] != "b" {
t.Fatalf("Forwarded = %#v", fwd)
}
nested, ok := fwd[1]["forwarded"].([]map[string]any)
if !ok || len(nested) != 1 || nested[0]["text"] != "nested" {
t.Errorf("nested forwarded = %#v", fwd[1]["forwarded"])
}
}
+11
View File
@@ -41,6 +41,17 @@ func Commands() []*cobra.Command {
return build(allShortcuts)
}
// BuiltInCommands compiles only distribution-owned shortcuts.
func BuiltInCommands() []*cobra.Command {
builtins := make([]Shortcut, 0, len(allShortcuts))
for _, registered := range allShortcuts {
if !registered.UserDefined {
builtins = append(builtins, registered)
}
}
return build(builtins)
}
// All returns the registered shortcuts. Primarily for coverage tests that need
// each shortcut's declared flags (types/enums/required) to synthesize inputs.
func All() []Shortcut {
+20
View File
@@ -266,6 +266,26 @@ func TestCrossPlatformCoverageBuildGroupsByService(t *testing.T) {
}
}
func TestBuiltInCommandsExcludeUserDefinedShortcuts(t *testing.T) {
previous := append([]Shortcut(nil), allShortcuts...)
t.Cleanup(func() { allShortcuts = previous })
allShortcuts = nil
Register(
Shortcut{Service: "calendar", Command: "+builtin", Execute: noop},
Shortcut{Service: "calendar", Command: "+user", UserDefined: true, Execute: noop},
)
all := Commands()
if len(all) != 1 || len(all[0].Commands()) != 2 {
t.Fatalf("all shortcut commands = %#v", all)
}
builtins := BuiltInCommands()
if len(builtins) != 1 || len(builtins[0].Commands()) != 1 ||
builtins[0].Commands()[0].Name() != "+builtin" {
t.Fatalf("built-in shortcut commands = %#v", builtins)
}
}
func noop(_ *RuntimeContext) error { return nil }
func TestCrossPlatformCoverageCallMCPWriteDataRejectsDryRun(t *testing.T) {
+41 -11
View File
@@ -19,6 +19,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
// AtMe: pull the messages that recently @-mentioned ME across chats in one step.
@@ -91,12 +92,7 @@ var AtMe = shortcut.Shortcut{
}
results := make([]map[string]any, 0, len(items))
for _, m := range items {
results = append(results, map[string]any{
"sender": atMeSender(m),
"time": atMeTime(m),
"text": atMeText(m),
"conversation": atMeConversation(m),
})
results = append(results, atMeProject(m))
}
return rt.Output(map[string]any{"messages": results})
},
@@ -194,28 +190,62 @@ func atMeToMaps(arr []any) []map[string]any {
return out
}
// atMeProject reshapes one @me message into {sender, time, text, conversation},
// running text through the shared chatmsg cleaning (card/auto-reply JSON →
// readable, ciphertext → marker) and recursively expanding any forwarded chat
// record under "forwarded".
func atMeProject(m map[string]any) map[string]any {
row := map[string]any{
"sender": atMeSender(m),
"time": atMeTime(m),
"text": atMeCleanText(m),
"conversation": atMeConversation(m),
}
if forwarded := chatmsg.Forwarded(m, atMeProject); len(forwarded) > 0 {
row["forwarded"] = forwarded
}
return row
}
// atMeCleanText runs atMeText's extraction through chatmsg.CleanText so
// card/auto-reply JSON and ciphertext render readable instead of leaking raw.
func atMeCleanText(m map[string]any) any {
if s, ok := atMeText(m).(string); ok {
return chatmsg.CleanText(s)
}
return atMeText(m)
}
// atMeSender reads a message's sender display name/id, tolerating the common
// sender keys the gateway may use (including a nested sender object).
// sender keys the gateway may use (including a nested sender object). The literal
// string "null" (carried by forwarded sub-messages) and the empty string are
// both treated as absent so they never surface as the speaker.
func atMeSender(m map[string]any) any {
norm := func(v any) string {
if s := atMeString(v); s != "" && s != "null" {
return s
}
return ""
}
for _, key := range []string{"senderName", "sender_name", "senderNick", "fromName", "senderStaffName"} {
if v := atMeString(m[key]); v != "" {
if v := norm(m[key]); v != "" {
return v
}
}
for _, key := range []string{"sender", "from", "senderUser"} {
if nested, ok := m[key].(map[string]any); ok {
for _, k2 := range []string{"name", "nick", "userName", "staffName", "displayName"} {
if v := atMeString(nested[k2]); v != "" {
if v := norm(nested[k2]); v != "" {
return v
}
}
}
if v := atMeString(m[key]); v != "" {
if v := norm(m[key]); v != "" {
return v
}
}
for _, key := range []string{"senderId", "sender_id", "senderUserId", "senderStaffId", "openDingTalkId"} {
if v := atMeString(m[key]); v != "" {
if v := norm(m[key]); v != "" {
return v
}
}
@@ -0,0 +1,131 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package smart
import (
"strings"
"testing"
)
const testCipher = "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP||2||1||1"
func TestAtMeProject(t *testing.T) {
// nested sender object + plain text
row := atMeProject(map[string]any{
"sender": map[string]any{"name": "念晨"},
"createTime": "2026-07-19 13:37:03",
"content": "普通消息",
"conversationTitle": "群A",
"openConversationId": "cid1",
})
if row["sender"] != "念晨" || row["text"] != "普通消息" || row["conversation"] != "群A" {
t.Fatalf("atMeProject nested = %#v", row)
}
// encrypted content → marked (never leaked); id-only sender fallback; a
// forwarded sub-message whose sender is the literal "null" must be nulled.
row = atMeProject(map[string]any{
"senderId": "DXYZ",
"openMessageId": "m1",
"content": testCipher,
"forwardMessages": []any{
map[string]any{"sender": "null", "content": "子消息", "createTime": "t"},
},
})
if row["sender"] != "DXYZ" {
t.Errorf("atMeProject id-fallback sender = %v", row["sender"])
}
if s, _ := row["text"].(string); !strings.Contains(s, "加密消息") {
t.Errorf("atMeProject encrypted text = %v, want marker", row["text"])
}
fwd, ok := row["forwarded"].([]map[string]any)
if !ok || len(fwd) != 1 {
t.Fatalf("atMeProject forwarded = %#v", row["forwarded"])
}
if fwd[0]["sender"] != nil {
t.Errorf("forwarded sub sender = %v, want nil (literal \"null\")", fwd[0]["sender"])
}
// no sender / no text at all → nils, no forwarded key
row = atMeProject(map[string]any{"createTime": "t"})
if row["sender"] != nil || row["text"] != nil {
t.Errorf("atMeProject empty = %#v", row)
}
if _, has := row["forwarded"]; has {
t.Errorf("atMeProject plain unexpectedly has forwarded")
}
}
func TestSearchMsgProject(t *testing.T) {
// nested sender + plain text + messageId
row := searchMsgProject(map[string]any{
"sender": map[string]any{"nick": "千启"},
"createTime": "2026-07-19 13:37:03",
"content": "命中关键词的消息",
"msgId": "mid1",
})
if row["sender"] != "千启" || row["text"] != "命中关键词的消息" {
t.Fatalf("searchMsgProject = %#v", row)
}
// encrypted → marker; id-only sender; forwarded "null" sender nulled.
row = searchMsgProject(map[string]any{
"senderId": "DAAA",
"openMessageId": "m2",
"content": testCipher,
"forwardMessages": []any{
map[string]any{"sender": "null", "content": "转发子消息", "createTime": "t"},
},
})
if row["sender"] != "DAAA" {
t.Errorf("searchMsgProject sender = %v", row["sender"])
}
if s, _ := row["text"].(string); !strings.Contains(s, "加密消息") {
t.Errorf("searchMsgProject encrypted text = %v, want marker", row["text"])
}
fwd, ok := row["forwarded"].([]map[string]any)
if !ok || len(fwd) != 1 || fwd[0]["sender"] != nil {
t.Errorf("searchMsgProject forwarded = %#v", row["forwarded"])
}
// no sender / no text
row = searchMsgProject(map[string]any{"createTime": "t"})
if row["sender"] != nil || row["text"] != nil {
t.Errorf("searchMsgProject empty = %#v", row)
}
}
// TestSenderHelpers exercises the atMe/searchMsg sender key families directly:
// a senderName-family key (first probe loop), a flat string under "sender"
// (second loop), and the "null" sentinel normalisation.
func TestSenderHelpers(t *testing.T) {
cases := []struct {
fn func(map[string]any) any
name string
}{
{atMeSender, "atMeSender"},
{searchMsgSender, "searchMsgSender"},
}
for _, c := range cases {
if got := c.fn(map[string]any{"senderName": "张三"}); got != "张三" {
t.Errorf("%s senderName = %v, want 张三", c.name, got)
}
if got := c.fn(map[string]any{"sender": "李四"}); got != "李四" {
t.Errorf("%s flat sender = %v, want 李四", c.name, got)
}
if got := c.fn(map[string]any{"senderName": "null"}); got != nil {
t.Errorf("%s \"null\" = %v, want nil", c.name, got)
}
}
}
+14 -50
View File
@@ -17,6 +17,7 @@ import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
// ChatMessages: fetch the message list of one conversation (group OR single
@@ -111,11 +112,7 @@ var ChatMessages = shortcut.Shortcut{
items := chatMessageItems(data)
results := make([]map[string]any, 0, len(items))
for _, m := range items {
results = append(results, map[string]any{
"sender": chatMessageSender(m),
"text": chatMessageText(m),
"createTime": chatMessageCreateTime(m),
})
results = append(results, projectChatMessage(m))
}
return rt.Output(map[string]any{
@@ -156,53 +153,20 @@ func chatMessageItems(data map[string]any) []map[string]any {
return nil
}
// chatMessageSender reads a message's speaker display name, tolerating common
// sender-name keys.
func chatMessageSender(m map[string]any) any {
for _, key := range []string{"senderName", "senderNick", "nick", "senderStaffName", "userName", "name", "senderId", "senderStaffId"} {
if v, ok := m[key]; ok && v != nil {
if s, ok := v.(string); ok && s == "" {
continue
}
return v
}
// projectChatMessage reshapes one raw message into the clean
// {sender, text, createTime} projection, rendering card/auto-reply JSON and
// marking encrypted messages via chatmsg, and recursively expanding forwarded
// chat records under "forwarded".
func projectChatMessage(m map[string]any) map[string]any {
row := map[string]any{
"sender": chatmsg.Sender(m),
"text": chatmsg.Text(m),
"createTime": chatmsg.CreateTime(m),
}
return nil
}
// chatMessageText reads a message's textual content, tolerating common text
// keys and one level of nesting (e.g. {"content":{"text":"..."}}).
func chatMessageText(m map[string]any) any {
for _, key := range []string{"text", "content", "msgContent", "message", "body"} {
v, ok := m[key]
if !ok || v == nil {
continue
}
switch t := v.(type) {
case string:
if t != "" {
return t
}
case map[string]any:
for _, inner := range []string{"text", "content", "value"} {
if s, ok := t[inner].(string); ok && s != "" {
return s
}
}
}
if forwarded := chatmsg.Forwarded(m, projectChatMessage); len(forwarded) > 0 {
row["forwarded"] = forwarded
}
return nil
}
// chatMessageCreateTime reads a message's create/send time, returning the raw
// value under whichever candidate key is present.
func chatMessageCreateTime(m map[string]any) any {
for _, key := range []string{"createTime", "sendTime", "gmtCreate", "createAt", "timestamp", "time"} {
if v, ok := m[key]; ok && v != nil {
return v
}
}
return nil
return row
}
func init() {
@@ -0,0 +1,62 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package smart
import "testing"
// TestProjectChatMessageExpandsForwarded guards that a forwarded chat record
// ("聊天记录") exposes its nested messages under "forwarded" instead of
// collapsing to the lossy top-level "[卡片]" summary, recursing through nested
// forwards, and that the string-"null" sender is nulled out. The per-field
// behaviour (sender/text/encryption) is covered in the chatmsg package tests.
func TestProjectChatMessageExpandsForwarded(t *testing.T) {
row := projectChatMessage(map[string]any{
"sender": "hugozhu",
"content": "hugozhu与opencode-agent的聊天记录\nopencode-agent:[卡片]",
"createTime": "2026-07-20 21:41:21",
"forwardMessages": []any{
map[string]any{"sender": "null", "content": "读下冬翔发给我的最近两条消息", "createTime": "2026-07-20 09:30:33"},
map[string]any{"sender": "冬翔", "content": "W29 工作总结", "createTime": "2026-07-19 23:35:40",
// nested forward inside a forward — must expand recursively.
"forwardMessages": []any{
map[string]any{"sender": "念晨", "content": "收到", "createTime": "2026-07-19 23:36:00"},
},
},
},
})
if row["sender"] != "hugozhu" {
t.Fatalf("top sender = %v, want hugozhu", row["sender"])
}
forwarded, ok := row["forwarded"].([]map[string]any)
if !ok || len(forwarded) != 2 {
t.Fatalf("forwarded = %#v, want 2 entries", row["forwarded"])
}
if forwarded[0]["sender"] != nil {
t.Errorf("forwarded[0].sender = %v, want nil (string \"null\")", forwarded[0]["sender"])
}
if forwarded[0]["text"] != "读下冬翔发给我的最近两条消息" {
t.Errorf("forwarded[0].text = %v", forwarded[0]["text"])
}
nested, ok := forwarded[1]["forwarded"].([]map[string]any)
if !ok || len(nested) != 1 || nested[0]["sender"] != "念晨" {
t.Errorf("nested forwarded = %#v, want 1 entry from 念晨", forwarded[1]["forwarded"])
}
// A plain message must not grow a "forwarded" key.
plain := projectChatMessage(map[string]any{"sender": "念晨", "content": "hi", "createTime": "t"})
if _, has := plain["forwarded"]; has {
t.Errorf("plain message unexpectedly has forwarded key: %#v", plain)
}
}
+40 -11
View File
@@ -19,6 +19,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
// SearchMsg: search messages inside a single group chat by keyword in one step.
@@ -108,12 +109,7 @@ var SearchMsg = shortcut.Shortcut{
}
results := make([]map[string]any, 0, len(items))
for _, m := range items {
results = append(results, map[string]any{
"sender": searchMsgSender(m),
"time": searchMsgTime(m),
"text": searchMsgText(m),
"messageId": searchMsgMessageID(m),
})
results = append(results, searchMsgProject(m))
}
return rt.Output(map[string]any{"messages": results})
},
@@ -152,28 +148,61 @@ func searchMsgToMaps(arr []any) []map[string]any {
return out
}
// searchMsgProject reshapes one matched message into {sender, time, text,
// messageId}, running text through the shared chatmsg cleaning (card/auto-reply
// JSON → readable, ciphertext → marker) and recursively expanding any forwarded
// chat record under "forwarded".
func searchMsgProject(m map[string]any) map[string]any {
row := map[string]any{
"sender": searchMsgSender(m),
"time": searchMsgTime(m),
"text": searchMsgCleanText(m),
"messageId": searchMsgMessageID(m),
}
if forwarded := chatmsg.Forwarded(m, searchMsgProject); len(forwarded) > 0 {
row["forwarded"] = forwarded
}
return row
}
// searchMsgCleanText runs searchMsgText's extraction through chatmsg.CleanText.
func searchMsgCleanText(m map[string]any) any {
if s, ok := searchMsgText(m).(string); ok {
return chatmsg.CleanText(s)
}
return searchMsgText(m)
}
// searchMsgSender reads a message's sender display name/id, tolerating the
// common sender keys the gateway may use (including a nested sender object).
// common sender keys the gateway may use (including a nested sender object). The
// literal string "null" (carried by forwarded sub-messages) and the empty string
// are both treated as absent so they never surface as the speaker.
func searchMsgSender(m map[string]any) any {
norm := func(v any) string {
if s := searchMsgString(v); s != "" && s != "null" {
return s
}
return ""
}
for _, key := range []string{"senderName", "sender_name", "senderNick", "fromName", "senderStaffName"} {
if v := searchMsgString(m[key]); v != "" {
if v := norm(m[key]); v != "" {
return v
}
}
for _, key := range []string{"sender", "from", "senderUser"} {
if nested, ok := m[key].(map[string]any); ok {
for _, k2 := range []string{"name", "nick", "userName", "staffName", "displayName"} {
if v := searchMsgString(nested[k2]); v != "" {
if v := norm(nested[k2]); v != "" {
return v
}
}
}
if v := searchMsgString(m[key]); v != "" {
if v := norm(m[key]); v != "" {
return v
}
}
for _, key := range []string{"senderId", "sender_id", "senderUserId", "senderStaffId", "openDingTalkId"} {
if v := searchMsgString(m[key]); v != "" {
if v := norm(m[key]); v != "" {
return v
}
}
+1 -54
View File
@@ -78,11 +78,7 @@ var ThreadReplies = shortcut.Shortcut{
items := threadReplyItems(data)
results := make([]map[string]any, 0, len(items))
for _, m := range items {
results = append(results, map[string]any{
"sender": threadReplySender(m),
"text": threadReplyText(m),
"createTime": threadReplyCreateTime(m),
})
results = append(results, projectChatMessage(m))
}
return rt.Output(map[string]any{
@@ -124,55 +120,6 @@ func threadReplyItems(data map[string]any) []map[string]any {
return nil
}
// threadReplySender reads a reply's speaker display name, tolerating common
// sender-name keys.
func threadReplySender(m map[string]any) any {
for _, key := range []string{"senderName", "senderNick", "nick", "senderStaffName", "userName", "name", "senderId", "senderStaffId"} {
if v, ok := m[key]; ok && v != nil {
if s, ok := v.(string); ok && s == "" {
continue
}
return v
}
}
return nil
}
// threadReplyText reads a reply's textual content, tolerating common text keys
// and one level of nesting (e.g. {"text":{"content":"..."}}).
func threadReplyText(m map[string]any) any {
for _, key := range []string{"text", "content", "msgContent", "message", "body"} {
v, ok := m[key]
if !ok || v == nil {
continue
}
switch t := v.(type) {
case string:
if t != "" {
return t
}
case map[string]any:
for _, inner := range []string{"content", "text", "value"} {
if s, ok := t[inner].(string); ok && s != "" {
return s
}
}
}
}
return nil
}
// threadReplyCreateTime reads a reply's create/send time, returning the raw
// value under whichever candidate key is present.
func threadReplyCreateTime(m map[string]any) any {
for _, key := range []string{"createTime", "sendTime", "gmtCreate", "createAt", "timestamp", "time"} {
if v, ok := m[key]; ok && v != nil {
return v
}
}
return nil
}
func init() {
shortcut.Register(ThreadReplies)
}
+4
View File
@@ -127,6 +127,10 @@ type Shortcut struct {
Tips []string
// Hidden hides the command from listings while keeping it invocable.
Hidden bool
// UserDefined identifies shortcuts loaded from the user's config
// directory. Distribution-owned Schema and interface snapshots exclude
// these runtime extensions even if another root loaded them earlier.
UserDefined bool
// Validate optionally checks resolved flag values before execution. Return a
// non-nil error to abort with a validation message. Runs after built-in
+1
View File
@@ -196,6 +196,7 @@ func Compile(s Spec) shortcut.Shortcut {
Intent: intent,
Risk: risk,
Flags: flags,
UserDefined: true,
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{}
for key, tmpl := range bind {
+3
View File
@@ -69,6 +69,9 @@ func TestCrossPlatformCoverageCompileFlagsAndDefaults(t *testing.T) {
if sc.Service != "chat" || sc.Command != "+notify-team" {
t.Fatalf("bad identity: %+v", sc)
}
if !sc.UserDefined {
t.Fatal("compiled user shortcut is missing user-defined provenance")
}
if sc.Risk != shortcut.RiskRead {
t.Errorf("risk default = %q, want read", sc.Risk)
}
+19 -1
View File
@@ -23,15 +23,28 @@ const SourceAnnotation = "dws.source"
// SourceEnvelope marks a command as authored by the runtime discovery envelope.
const SourceEnvelope = "envelope"
// SourcePlugin marks a command as an installed plugin extension. Plugin
// commands are part of the runtime CLI surface, not the embedded base Schema.
const SourcePlugin = "plugin"
// MarkEnvelopeSource stamps cmd with runtime discovery provenance.
func MarkEnvelopeSource(cmd *cobra.Command) {
markSource(cmd, SourceEnvelope)
}
// MarkPluginSource stamps cmd with installed-plugin provenance.
func MarkPluginSource(cmd *cobra.Command) {
markSource(cmd, SourcePlugin)
}
func markSource(cmd *cobra.Command, source string) {
if cmd == nil {
return
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[SourceAnnotation] = SourceEnvelope
cmd.Annotations[SourceAnnotation] = source
}
// IsEnvelopeSourced reports whether cmd was authored by the runtime discovery
@@ -40,6 +53,11 @@ func IsEnvelopeSourced(cmd *cobra.Command) bool {
return cmd != nil && cmd.Annotations[SourceAnnotation] == SourceEnvelope
}
// IsPluginSourced reports whether cmd came from an installed plugin.
func IsPluginSourced(cmd *cobra.Command) bool {
return cmd != nil && cmd.Annotations[SourceAnnotation] == SourcePlugin
}
// KindAnnotation is the annotation key for marking command kinds.
const KindAnnotation = "dws.kind"
+16
View File
@@ -43,3 +43,19 @@ func TestMarkEnvelopeSourceNilDoesNotPanic(t *testing.T) {
t.Parallel()
MarkEnvelopeSource(nil)
}
func TestPluginSourceProvenance(t *testing.T) {
t.Parallel()
if IsPluginSourced(nil) {
t.Fatal("nil command should not be plugin sourced")
}
cmd := &cobra.Command{Use: "conference"}
MarkPluginSource(cmd)
if !IsPluginSourced(cmd) || IsEnvelopeSourced(cmd) {
t.Fatalf("plugin source annotation = %#v", cmd.Annotations)
}
if got := cmd.Annotations[SourceAnnotation]; got != SourcePlugin {
t.Fatalf("SourceAnnotation = %q, want %q", got, SourcePlugin)
}
MarkPluginSource(nil)
}
+98 -3
View File
@@ -1,6 +1,10 @@
package mcptypes
import "encoding/json"
import (
"bytes"
"encoding/json"
"strings"
)
type ServerDescriptor struct {
Key string
@@ -16,19 +20,110 @@ type ServerDescriptor struct {
type CLIOverlay struct {
ID string `json:"id"`
Command string `json:"command"`
Parent string `json:"parent,omitempty"`
Description string `json:"description,omitempty"`
Aliases []string `json:"aliases"`
Prefixes []string `json:"prefixes"`
Group string `json:"group,omitempty"`
Skip bool `json:"skip"`
Hidden bool `json:"hidden,omitempty"`
Tools []CLITool `json:"tools"`
Groups map[string]CLIGroupDef `json:"groups,omitempty"`
ToolOverrides map[string]CLIToolOverride `json:"toolOverrides,omitempty"`
ServerDeps []string `json:"serverDeps,omitempty"`
Hints map[string]json.RawMessage `json:"hintCommands,omitempty"`
RedirectTo string `json:"redirectTo,omitempty"`
}
type CLIGroupDef struct {
Description string `json:"description,omitempty"`
}
type CLITool struct {
Name string `json:"name"`
Name string `json:"name"`
CLIName string `json:"cliName,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
IsSensitive bool `json:"isSensitive,omitempty"`
Category string `json:"category,omitempty"`
Hidden bool `json:"hidden,omitempty"`
Flags map[string]CLIFlagHint `json:"flags,omitempty"`
}
type CLIFlagHint struct {
Shorthand string `json:"shorthand,omitempty"`
Alias string `json:"alias,omitempty"`
}
type CLIToolOverride struct {
ServerOverride string `json:"serverOverride,omitempty"`
CLIName string `json:"cliName,omitempty"`
CLIAliases []string `json:"cliAliases,omitempty"`
Description string `json:"description,omitempty"`
Example string `json:"example,omitempty"`
Group string `json:"group,omitempty"`
IsSensitive bool `json:"isSensitive,omitempty"`
Hidden bool `json:"hidden,omitempty"`
Flags map[string]CLIFlagOverride `json:"flags,omitempty"`
OutputFormat map[string]any `json:"outputFormat,omitempty"`
ServerOverride string `json:"serverOverride,omitempty"`
BodyWrapper string `json:"bodyWrapper,omitempty"`
MutuallyExclusive [][]string `json:"mutuallyExclusive,omitempty"`
RequireOneOf [][]string `json:"requireOneOf,omitempty"`
RequireTogether [][]string `json:"requireTogether,omitempty"`
RejectPositional bool `json:"rejectPositional,omitempty"`
RedirectTo string `json:"redirectTo,omitempty"`
Pipeline []json.RawMessage `json:"pipeline,omitempty"`
}
type CLIFlagOverride struct {
Alias string `json:"alias,omitempty"`
Aliases []string `json:"aliases,omitempty"`
MapsTo string `json:"mapsTo,omitempty"`
Transform string `json:"transform,omitempty"`
TransformArgs map[string]any `json:"transformArgs,omitempty"`
EnvDefault string `json:"envDefault,omitempty"`
Hidden bool `json:"hidden,omitempty"`
Default string `json:"default,omitempty"`
Shorthand string `json:"shorthand,omitempty"`
Required bool `json:"required,omitempty"`
Description string `json:"description,omitempty"`
Positional bool `json:"positional,omitempty"`
PositionalIndex int `json:"positionalIndex,omitempty"`
Type string `json:"type,omitempty"`
OmitWhen string `json:"omitWhen,omitempty"`
RuntimeDefault string `json:"runtimeDefault,omitempty"`
PipelineLocal bool `json:"pipelineLocal,omitempty"`
}
func (override *CLIFlagOverride) UnmarshalJSON(data []byte) error {
type alias CLIFlagOverride
aux := struct {
Default json.RawMessage `json:"default,omitempty"`
*alias
}{alias: (*alias)(override)}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&aux); err != nil {
return err
}
override.Default = coercePluginScalar(aux.Default)
return nil
}
func coercePluginScalar(raw json.RawMessage) string {
value := strings.TrimSpace(string(raw))
if value == "" || value == "null" ||
strings.HasPrefix(value, "{") || strings.HasPrefix(value, "[") {
return ""
}
if strings.HasPrefix(value, `"`) {
var decoded string
if json.Unmarshal(raw, &decoded) == nil {
return decoded
}
return ""
}
return value
}
func OverlayFromJSON(data json.RawMessage) CLIOverlay {
+70
View File
@@ -0,0 +1,70 @@
package mcptypes
import (
"encoding/json"
"testing"
)
func TestCLIFlagOverrideUnmarshalJSONCoercesScalarDefaults(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{name: "missing", raw: `{}`, want: ""},
{name: "null", raw: `{"default":null}`, want: ""},
{name: "object", raw: `{"default":{"nested":true}}`, want: ""},
{name: "array", raw: `{"default":[1,2]}`, want: ""},
{name: "string", raw: `{"default":"hello"}`, want: "hello"},
{name: "escaped string", raw: `{"default":"line\nvalue"}`, want: "line\nvalue"},
{name: "boolean", raw: `{"default":true}`, want: "true"},
{name: "integer", raw: `{"default":42}`, want: "42"},
{name: "number", raw: `{"default":-1.25}`, want: "-1.25"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got CLIFlagOverride
if err := json.Unmarshal([]byte(tt.raw), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if got.Default != tt.want {
t.Fatalf("Default = %q, want %q", got.Default, tt.want)
}
})
}
var got CLIFlagOverride
if err := json.Unmarshal([]byte(`{"alias":`), &got); err == nil {
t.Fatal("json.Unmarshal() error = nil, want malformed JSON error")
}
if err := got.UnmarshalJSON([]byte(`{"alias":`)); err == nil {
t.Fatal("CLIFlagOverride.UnmarshalJSON() error = nil, want malformed JSON error")
}
if err := json.Unmarshal([]byte(`{"unknownFlagField":true}`), &got); err == nil {
t.Fatal("CLIFlagOverride.UnmarshalJSON() accepted an unknown field")
}
if got := coercePluginScalar(json.RawMessage(`"unterminated`)); got != "" {
t.Fatalf("coercePluginScalar(invalid string) = %q, want empty", got)
}
}
func TestOverlayFromJSONHandlesEmptyValidAndMalformedInput(t *testing.T) {
if got := OverlayFromJSON(nil); got.ID != "" || got.Command != "" {
t.Fatalf("OverlayFromJSON(nil) = %#v, want zero overlay", got)
}
valid := json.RawMessage(`{
"id":"conference",
"command":"meeting",
"toolOverrides":{"create":{"flags":{"count":{"default":3}}}}
}`)
got := OverlayFromJSON(valid)
if got.ID != "conference" || got.Command != "meeting" ||
got.ToolOverrides["create"].Flags["count"].Default != "3" {
t.Fatalf("OverlayFromJSON(valid) = %#v", got)
}
if got := OverlayFromJSON(json.RawMessage(`{`)); got.ID != "" || got.Command != "" {
t.Fatalf("OverlayFromJSON(malformed) = %#v, want zero overlay", got)
}
}
+118 -13
View File
@@ -8,16 +8,32 @@ GITEE_API="${GITEE_API:-https://gitee.com/api/v5}"
GITEE_CURL_CONNECT_TIMEOUT="${GITEE_CURL_CONNECT_TIMEOUT:-15}"
GITEE_CURL_MAX_TIME="${GITEE_CURL_MAX_TIME:-120}"
GITEE_LIST_MAX_TIME="${GITEE_LIST_MAX_TIME:-20}"
# Attachment-list reads are safe to retry and are used both before an upload
# and to detect a committed upload whose HTTP response was lost. Keep retrying
# long enough to bridge a short Gitee TLS/API outage without ever blindly
# replaying the upload itself.
GITEE_LIST_RETRIES="${GITEE_LIST_RETRIES:-24}"
GITEE_LIST_RETRY_DELAY="${GITEE_LIST_RETRY_DELAY:-20}"
GITEE_LIST_RETRY_WINDOW_SECONDS="${GITEE_LIST_RETRY_WINDOW_SECONDS:-420}"
GITEE_VERIFY_MAX_TIME="${GITEE_VERIFY_MAX_TIME:-60}"
GITEE_MUTATION_MAX_TIME="${GITEE_MUTATION_MAX_TIME:-20}"
GITEE_UPLOAD_MAX_TIME="${GITEE_UPLOAD_MAX_TIME:-120}"
# Gitee does not return a response until an attachment upload is committed.
# From GitHub-hosted runners, a near-10 MiB DWS binary can legitimately take
# more than five minutes, so keep the transfer deadline above that observed
# floor while the per-asset and overall deadlines retain hard upper bounds.
GITEE_UPLOAD_MAX_TIME="${GITEE_UPLOAD_MAX_TIME:-1200}"
# The per-asset deadline guarantees one complete slow upload plus one complete
# attachment-list recovery on each side of that upload. The second upload
# attempt is allowed only for zero-byte failures before HTTP begins; a first
# attempt that consumes the full transfer deadline intentionally exhausts the
# retry budget instead of holding a runner for another full slow upload.
GITEE_UPLOAD_RETRIES="${GITEE_UPLOAD_RETRIES:-2}"
GITEE_UPLOAD_RETRY_DELAY="${GITEE_UPLOAD_RETRY_DELAY:-5}"
GITEE_EXISTING_VERIFY_ATTEMPTS="${GITEE_EXISTING_VERIFY_ATTEMPTS:-1}"
GITEE_POST_UPLOAD_VERIFY_ATTEMPTS="${GITEE_POST_UPLOAD_VERIFY_ATTEMPTS:-2}"
GITEE_VERIFY_RETRY_DELAY="${GITEE_VERIFY_RETRY_DELAY:-5}"
GITEE_ASSET_TIMEOUT_SECONDS="${GITEE_ASSET_TIMEOUT_SECONDS:-600}"
GITEE_OVERALL_TIMEOUT_SECONDS="${GITEE_OVERALL_TIMEOUT_SECONDS:-4920}"
GITEE_ASSET_TIMEOUT_SECONDS="${GITEE_ASSET_TIMEOUT_SECONDS:-2220}"
GITEE_OVERALL_TIMEOUT_SECONDS="${GITEE_OVERALL_TIMEOUT_SECONDS:-18300}"
err() {
printf 'error: %s\n' "$*" >&2
@@ -43,6 +59,8 @@ for setting in \
GITEE_CURL_CONNECT_TIMEOUT \
GITEE_CURL_MAX_TIME \
GITEE_LIST_MAX_TIME \
GITEE_LIST_RETRIES \
GITEE_LIST_RETRY_WINDOW_SECONDS \
GITEE_VERIFY_MAX_TIME \
GITEE_MUTATION_MAX_TIME \
GITEE_UPLOAD_MAX_TIME \
@@ -54,6 +72,7 @@ for setting in \
require_positive_integer "$setting" "${!setting}"
done
for setting in \
GITEE_LIST_RETRY_DELAY \
GITEE_UPLOAD_RETRY_DELAY \
GITEE_VERIFY_RETRY_DELAY; do
require_nonnegative_integer "$setting" "${!setting}"
@@ -139,21 +158,81 @@ api_get() {
--max-time "$max_time" "$@"
}
list_assets() {
api_get "$GITEE_LIST_MAX_TIME" \
list_assets_once() {
local max_time="$1" payload
payload="$(api_get "$max_time" \
-H "Authorization: token ${GITEE_TOKEN}" \
"${base}/releases/${release_id}/attach_files" \
| python3 -c 'import json,sys
"${base}/releases/${release_id}/attach_files")" || return 1
printf '%s\n' "$payload" | python3 -c 'import json,sys
data=json.load(sys.stdin)
rows=data if isinstance(data,list) else data.get("attach_files",[])
if isinstance(data,list):
rows=data
elif isinstance(data,dict) and "attach_files" in data:
rows=data["attach_files"]
else:
raise ValueError("attachment list response must be a list or contain attach_files")
if not isinstance(rows,list):
raise ValueError("attach_files must be a list")
for asset in rows:
if not isinstance(asset,dict):
raise ValueError("each attachment must be an object")
name=asset.get("name","")
asset_id=asset.get("id","")
url=asset.get("browser_download_url","")
if name and asset_id != "":
print("%s\t%s\t%s" % (name, asset_id, url))'
valid_id=(isinstance(asset_id,int) and not isinstance(asset_id,bool) and asset_id > 0) or (isinstance(asset_id,str) and asset_id.isdigit() and int(asset_id) > 0)
if not isinstance(name,str) or not name or not valid_id or not isinstance(url,str) or not url:
raise ValueError("attachment identity and download URL are required")
print("%s\t%s\t%s" % (name, asset_id, url))'
}
list_assets() {
local attempt=1 candidate now retry_deadline remaining max_time delay
now="$(now_seconds)"
retry_deadline=$(( now + GITEE_LIST_RETRY_WINDOW_SECONDS ))
if [ "$retry_deadline" -gt "$active_deadline" ]; then
retry_deadline="$active_deadline"
fi
while [ "$attempt" -le "$GITEE_LIST_RETRIES" ]; do
now="$(now_seconds)"
remaining=$(( retry_deadline - now ))
[ "$remaining" -gt 0 ] || break
max_time="$GITEE_LIST_MAX_TIME"
if [ "$max_time" -gt "$remaining" ]; then
max_time="$remaining"
fi
# Publish one complete parse atomically. A malformed response can make the
# parser emit valid leading rows before it fails; leaking those rows into a
# later successful retry would manufacture duplicates and could delete a
# correct attachment.
if candidate="$(list_assets_once "$max_time")"; then
now="$(now_seconds)"
if [ "$now" -lt "$retry_deadline" ]; then
printf '%s\n' "$candidate"
return 0
fi
break
fi
if [ "$attempt" -ge "$GITEE_LIST_RETRIES" ]; then
break
fi
attempt=$((attempt + 1))
now="$(now_seconds)"
remaining=$(( retry_deadline - now ))
[ "$remaining" -gt 0 ] || break
delay="$GITEE_LIST_RETRY_DELAY"
if [ "$delay" -gt 0 ]; then
# Do not turn a configured backoff into a burst of zero-delay requests
# during the final wall-clock second. Explicit delay=0 remains available
# to fast unit tests and tightly controlled callers.
[ "$remaining" -gt 1 ] || break
if [ "$delay" -ge "$remaining" ]; then
delay=$(( remaining - 1 ))
fi
fi
echo " ⚠ Gitee attachment list attempt $((attempt - 1))/${GITEE_LIST_RETRIES} failed; retrying in ${delay}s" >&2
sleep_within_deadline "$delay" || return 1
done
return 1
}
sha256_of() {
@@ -234,20 +313,42 @@ delete_named_assets() {
}
gitee_attach() {
local file="$1" name attempt response status max_time
local file="$1" name attempt response status max_time transfer_log metrics http_code size_upload safe_to_retry
name="$(basename "$file")"
attempt=1
while [ "$attempt" -le "$GITEE_UPLOAD_RETRIES" ]; do
status=0
max_time="$(bounded_max_time "$GITEE_UPLOAD_MAX_TIME")" || return 1
if response="$(curl -fsS --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" \
transfer_log="$(mktemp)" || return 1
if metrics="$(curl -fsS --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" \
--max-time "$max_time" \
-X POST "${base}/releases/${release_id}/attach_files" \
-H "Authorization: token ${GITEE_TOKEN}" -F "file=@${file}" 2>&1)"; then
-H "Authorization: token ${GITEE_TOKEN}" \
-H "Expect:" \
-F "file=@${file}" \
-o /dev/null \
-w '%{http_code}\t%{size_upload}' \
2>"$transfer_log")"; then
status=0
else
status=$?
fi
response="$(<"$transfer_log")"
rm -f "$transfer_log"
http_code="${metrics%%$'\t'*}"
if [ "$metrics" = "$http_code" ]; then
size_upload=""
else
size_upload="${metrics#*$'\t'}"
fi
safe_to_retry=0
if [ "$status" -ne 0 ] && [ "$http_code" = "000" ] && \
awk -v value="$size_upload" 'BEGIN { exit !((value + 0) == 0 && value ~ /^[0-9]+([.][0-9]+)?$/) }'; then
# A request that never reached HTTP and uploaded zero bytes cannot have
# committed an attachment. TLS/DNS/connect failures are therefore the
# only transport failures safe to replay automatically.
safe_to_retry=1
fi
# Gitee can commit an upload but let the HTTP response time out. Probe the
# release before retrying so a lost response does not create duplicates.
@@ -259,6 +360,10 @@ gitee_attach() {
fi
echo " ⚠ upload attempt ${attempt}/${GITEE_UPLOAD_RETRIES} failed for ${name}: $(printf '%s' "$response" | head -c 240)" >&2
if [ "$safe_to_retry" -ne 1 ]; then
echo " ⚠ ${name} upload outcome is ambiguous (HTTP ${http_code:-unknown}, uploaded ${size_upload:-unknown} bytes); refusing to replay POST" >&2
return 1
fi
attempt=$((attempt + 1))
if [ "$attempt" -le "$GITEE_UPLOAD_RETRIES" ]; then
# Remove any partial, stale, or duplicate attachment before the one
+48 -14
View File
@@ -31,11 +31,13 @@ DIST_DIR="${DIST_DIR:-dist}"
GITEE_API="${GITEE_API:-https://gitee.com/api/v5}"
GITEE_CURL_CONNECT_TIMEOUT="${GITEE_CURL_CONNECT_TIMEOUT:-15}"
GITEE_CURL_MAX_TIME="${GITEE_CURL_MAX_TIME:-120}"
GITEE_SYNC_TIMEOUT_SECONDS="${GITEE_SYNC_TIMEOUT_SECONDS:-5700}"
GITEE_SYNC_TIMEOUT_SECONDS="${GITEE_SYNC_TIMEOUT_SECONDS:-18840}"
GITEE_TAG_TIMEOUT_SECONDS="${GITEE_TAG_TIMEOUT_SECONDS:-300}"
GITEE_RELEASE_LOOKUP_MAX_TIME="${GITEE_RELEASE_LOOKUP_MAX_TIME:-60}"
GITEE_RELEASE_LOOKUP_RETRIES="${GITEE_RELEASE_LOOKUP_RETRIES:-2}"
GITEE_RELEASE_LOOKUP_RETRY_DELAY="${GITEE_RELEASE_LOOKUP_RETRY_DELAY:-2}"
GITEE_RELEASE_CREATE_MAX_TIME="${GITEE_RELEASE_CREATE_MAX_TIME:-60}"
GITEE_RECONCILE_TIMEOUT_SECONDS="${GITEE_RECONCILE_TIMEOUT_SECONDS:-4920}"
GITEE_RECONCILE_TIMEOUT_SECONDS="${GITEE_RECONCILE_TIMEOUT_SECONDS:-18300}"
GITEE_CHILD_DEADLINE_RESERVE_SECONDS="${GITEE_CHILD_DEADLINE_RESERVE_SECONDS:-5}"
err() {
@@ -51,17 +53,28 @@ require_positive_integer() {
[ "$value" -gt 0 ] || err "${name} must be greater than zero"
}
require_nonnegative_integer() {
local name="$1" value="$2"
case "$value" in
''|*[!0-9]*) err "${name} must be a non-negative integer: ${value}" ;;
esac
}
for setting in \
GITEE_CURL_CONNECT_TIMEOUT \
GITEE_CURL_MAX_TIME \
GITEE_SYNC_TIMEOUT_SECONDS \
GITEE_TAG_TIMEOUT_SECONDS \
GITEE_RELEASE_LOOKUP_MAX_TIME \
GITEE_RELEASE_LOOKUP_RETRIES \
GITEE_RELEASE_CREATE_MAX_TIME \
GITEE_RECONCILE_TIMEOUT_SECONDS \
GITEE_CHILD_DEADLINE_RESERVE_SECONDS; do
require_positive_integer "$setting" "${!setting}"
done
require_nonnegative_integer \
GITEE_RELEASE_LOOKUP_RETRY_DELAY \
"$GITEE_RELEASE_LOOKUP_RETRY_DELAY"
missing=""
[ -z "${GITEE_TOKEN:-}" ] && missing="$missing GITEE_TOKEN"
@@ -117,14 +130,6 @@ GITEE_TAG_TIMEOUT_SECONDS="$GITEE_TAG_TIMEOUT_SECONDS" \
deadline_remaining >/dev/null || err "overall Gitee sync deadline exhausted during tag synchronization"
target_commit="$(git rev-parse --verify "${VERSION}^{commit}")"
api_get() {
local configured_max_time="$1" max_time
shift
max_time="$(bounded_max_time "$configured_max_time")" || return 1
curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" \
--max-time "$max_time" "$@"
}
release_id_from_json() {
python3 -c 'import json, sys
data = json.load(sys.stdin)
@@ -136,12 +141,41 @@ elif isinstance(value, str) and value.isdigit() and int(value) > 0:
}
# ── Resolve or create the Gitee release for this tag ──────────────────────────
rel_json="$(api_get "$GITEE_RELEASE_LOOKUP_MAX_TIME" \
# A transient lookup failure must not be mistaken for a missing release: doing
# so can race a duplicate create and hides the actual Gitee availability issue.
release_lookup_body="$(mktemp)"
trap 'rm -f "$release_lookup_body"' EXIT HUP INT TERM
lookup_max_time="$(bounded_max_time "$GITEE_RELEASE_LOOKUP_MAX_TIME")" \
|| err "overall Gitee sync deadline exhausted before release lookup"
if ! release_status="$(curl -sS --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" \
--max-time "$lookup_max_time" \
--retry "$GITEE_RELEASE_LOOKUP_RETRIES" \
--retry-all-errors \
--retry-delay "$GITEE_RELEASE_LOOKUP_RETRY_DELAY" \
--retry-max-time "$lookup_max_time" \
-o "$release_lookup_body" \
-w '%{http_code}' \
-H "Authorization: token ${GITEE_TOKEN}" \
"${base}/releases/tags/${VERSION}" 2>/dev/null || true)"
release_id="$(printf '%s' "$rel_json" | release_id_from_json || true)"
"${base}/releases/tags/${VERSION}")"; then
deadline_remaining >/dev/null \
|| err "overall Gitee sync deadline exhausted during release lookup"
err "could not query Gitee release ${VERSION} after bounded retries"
fi
rel_json="$(<"$release_lookup_body")"
case "$release_status" in
200)
release_id="$(printf '%s' "$rel_json" | release_id_from_json || true)"
[ -n "$release_id" ] || err "Gitee release lookup returned HTTP 200 without a valid release id"
;;
404)
release_id=""
;;
*)
err "Gitee release lookup returned HTTP ${release_status} for ${VERSION}"
;;
esac
if [ -z "$release_id" ]; then
if [ "$release_status" = "404" ]; then
deadline_remaining >/dev/null || err "overall Gitee sync deadline exhausted during release lookup"
echo " No Gitee release for ${VERSION} yet — creating it."
create_max_time="$(bounded_max_time "$GITEE_RELEASE_CREATE_MAX_TIME")" \
+2 -2
View File
@@ -22,7 +22,7 @@ cli_version: ">=1.0.15"
- 单次批量操作不超过 30 条记录
- 所有命令必须**严格遵循**对应产品参考文档里面规定的参数格式(如:如果有参数值,则参数和参数值之间至少用一个空格隔开)
- **脚本优先**:[scripts/](./scripts/) 下的 `python scripts/<name>.py` 已封装翻页/轮询/批量逻辑,遇到对应场景(如 AI 表格批量导入导出、AI 应用创建轮询、文档创建后写内容、钉盘目录树等)**优先调用脚本**而非手写多步命令。脚本均支持 `--dry-run` 预览、`--format json` 输出,失败时回退到手动步骤
- **实时个人消息事件例外**:用户要监听消息、订阅事件、自动回复消息或事件驱动 Agent 时,必须走 `dws event consume` 长连接,不要写脚本轮询消息历史
- **实时个人消息事件例外**:用户要监听消息、订阅事件、自动回复消息或事件驱动 Agent 时,必须走 `dws event consume ... --flatten` 长连接,不要写脚本轮询消息历史
## Shortcut 与原子命令的使用原则
@@ -259,7 +259,7 @@ Schema 与 Help 冲突是**契约漂移**,不得静默猜测或把两边字段
`dev.*` 包含 helper-only 执行面,其中远端 helper 未进入 pinned metadata 时标记为 `composite`,不能伪装成 `local`。`event list` / `event schema` 读取内置目录和 payload 定义,属于 `local`;`event consume` / `event status` / `event stop` 同时编排远端个人订阅控制面与本地 bus/consume,属于 `composite`。实现来源不同,不改变统一查询边界:进入全局 `dws schema` 的命令必须先进入 reviewed CommandRegistry,并由同一 `ToolSpec` 投影到 leaf、产品/分组、`--all` 与 Catalog。不得在查询时重新调用 MCP `tools/list`,也不得把 Cobra 临时合成结果作为第二条 Schema 数据路径。
事件需要区分两种 Schema:`dws event schema <event_key>` 查询事件 payload 字段;`dws schema "event consume"` 查询 CLI 命令参数。前者是真实业务命令,后者只读取最终内嵌 SchemaRegistry;不能相互替代。
事件需要区分两种 Schema:`dws event schema <event_key> --flatten` 查询 Agent 要消费的顶层业务字段;`dws schema "event consume"` 查询 CLI 命令参数。前者是真实业务命令,后者只读取最终内嵌 SchemaRegistry;不能相互替代。
`source` 表示最终命令 identity 的来源,不表示运行时 backing;helper/local/MCP 实现机制读取 `interface_mode`、`availability` 和 provenance,不要假定 `dev.*` 必然是 `source=mcp:<server>`,也不要假定本地命令必然是 `source=cobra`。
+40 -40
View File
@@ -13,8 +13,8 @@
| Command | Purpose |
|---|---|
| `dws event schema <event_key>` | 查看事件参数和输出字段 schema |
| `dws event consume <event_key> [flags]` | 阻塞消费,事件写到 stdout,用 `-f ndjson` |
| `dws event schema <event_key> --flatten` | 查看 Agent 使用的顶层业务字段 schema |
| `dws event consume <event_key> --flatten [flags]` | 阻塞消费,事件写到 stdout,用 `-f ndjson` |
| `dws event status --event <event_key>` | 查看个人订阅、bus、本地 consume |
| `dws event stop <subscribe_id> --dry-run` / `--yes` | 先预览,再确认取消订阅并停止对应本地消费 |
| `dws event stop --all --dry-run` / `--yes` | 先预览,再确认清理当前身份下全部个人订阅 |
@@ -42,20 +42,20 @@
| 用户说 | 下一步 |
|---|---|
| "监听有人 @ 我的消息" | `event consume`,事件码 `user_im_message_receive_at`,参数 `-f ndjson` |
| "监听我和 userId test-user-001 的单聊消息" | `event consume`,事件码 `user_im_message_receive_o2o`,参数 `--user test-user-001 -f ndjson` |
| "监听我和 openDingtalkId abc 的单聊消息" | `event consume`,事件码 `user_im_message_receive_o2o`,参数 `--open-dingtalk-id abc -f ndjson` |
| "监听有人 @ 我的消息" | `event consume`,事件码 `user_im_message_receive_at`,参数 `--flatten -f ndjson` |
| "监听我和 userId test-user-001 的单聊消息" | `event consume`,事件码 `user_im_message_receive_o2o`,参数 `--user test-user-001 --flatten -f ndjson` |
| "监听我和 openDingtalkId abc 的单聊消息" | `event consume`,事件码 `user_im_message_receive_o2o`,参数 `--open-dingtalk-id abc --flatten -f ndjson` |
| "监听 XX 群消息" | 先 `dws chat search --query "XX" --format json`,确认后 consume group |
| "监听 userId test-user-001 发给我的消息" | `event consume`,事件码 `user_im_message_receive_user`,参数 `--user test-user-001 -f ndjson` |
| "监听 openDingtalkId abc 发给我的消息" | `event consume`,事件码 `user_im_message_receive_user`,参数 `--open-dingtalk-id abc -f ndjson` |
| "监听我发给 userId test-user-001 的消息是否已读" | `event consume`,事件码 `user_im_message_read_o2o`,参数 `--user test-user-001 -f ndjson` |
| "监听 userId test-user-001 发给我的消息" | `event consume`,事件码 `user_im_message_receive_user`,参数 `--user test-user-001 --flatten -f ndjson` |
| "监听 openDingtalkId abc 发给我的消息" | `event consume`,事件码 `user_im_message_receive_user`,参数 `--open-dingtalk-id abc --flatten -f ndjson` |
| "监听我发给 userId test-user-001 的消息是否已读" | `event consume`,事件码 `user_im_message_read_o2o`,参数 `--user test-user-001 --flatten -f ndjson` |
| "监听 XX 群消息已读" | 先解析群 ID,再 consume `user_im_message_read_group --group <id>` |
| "监听我和 userId test-user-001 的消息撤回" | `event consume`,事件码 `user_im_message_recall_o2o`,参数 `--user test-user-001 -f ndjson` |
| "监听我和 userId test-user-001 的消息撤回" | `event consume`,事件码 `user_im_message_recall_o2o`,参数 `--user test-user-001 --flatten -f ndjson` |
| "监听 XX 群消息撤回" | 先解析群 ID,再 consume `user_im_message_recall_group --group <id>` |
| "监听我和 userId test-user-001 的消息贴表情" | `event consume`,事件码 `user_im_message_reaction_o2o`,参数 `--user test-user-001 -f ndjson` |
| "监听我和 userId test-user-001 的消息贴表情" | `event consume`,事件码 `user_im_message_reaction_o2o`,参数 `--user test-user-001 --flatten -f ndjson` |
| "监听 XX 群消息表情回应" | 先解析群 ID,再 consume `user_im_message_reaction_group --group <id>` |
| "监听并自动回复某人的单聊消息" | 先解析对端 userId,再启动 o2o consume;不要写轮询脚本 |
| "查看个人消息事件 schema" | `dws event schema <event_key>` |
| "查看个人消息事件 schema" | `dws event schema <event_key> --flatten` |
| "看个人事件订阅状态" | `dws event status --event <event_key>` |
| "停止这个个人事件订阅" | `dws event stop <subscribe_id> --dry-run`,确认后改用 `--yes` |
@@ -66,8 +66,8 @@
## Call flow
1. 从用户意图选择事件码;人名或群名先解析成必填 ID。
2. 需要了解字段时运行 `dws event schema <event_key>`,读取 `schema.properties`;`jq_root_path` 当前固定为 `.`。
3. 启动 `dws event consume <event_key> ... -f ndjson`,等待 stderr 出现 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>` 后开始处理 stdout,不要用 `sleep` 猜测。
2. 需要了解字段时运行 `dws event schema <event_key> --flatten`,读取 `schema.properties`;此模式的 `jq_root_path` 为 `.`。
3. 启动 `dws event consume <event_key> ... --flatten -f ndjson`,等待 stderr 出现 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>` 后开始处理 stdout,不要用 `sleep` 猜测。
4. stdout 每行是一个扁平事件 JSON;直接按该事件的 `schema.properties` 读取顶层字段。
5. 需要确认监听状态时运行 `dws event status --event <event_key>`,查看 `Subscriptions` 和 `Consumers`。
6. 任务完成后优雅结束 consume;本次新建的订阅会自动取消。复用已有订阅或需要从外部主动取消时,先运行 `dws event stop <subscribe_id> --dry-run`,向用户确认后再以 `--yes` 执行;自测可在 consume 加 `--max-events` 或 `--duration` 自动退出。
@@ -75,31 +75,31 @@
## Commands
```bash
dws event schema user_im_message_receive_at
dws event schema user_im_message_receive_o2o
dws event schema user_im_message_receive_group
dws event schema user_im_message_receive_user
dws event schema user_im_message_read_o2o
dws event schema user_im_message_read_group
dws event schema user_im_message_recall_o2o
dws event schema user_im_message_recall_group
dws event schema user_im_message_reaction_o2o
dws event schema user_im_message_reaction_group
dws event schema user_im_message_receive_at --flatten
dws event schema user_im_message_receive_o2o --flatten
dws event schema user_im_message_receive_group --flatten
dws event schema user_im_message_receive_user --flatten
dws event schema user_im_message_read_o2o --flatten
dws event schema user_im_message_read_group --flatten
dws event schema user_im_message_recall_o2o --flatten
dws event schema user_im_message_recall_group --flatten
dws event schema user_im_message_reaction_o2o --flatten
dws event schema user_im_message_reaction_group --flatten
```
```bash
dws event consume user_im_message_receive_at -f ndjson
dws event consume user_im_message_receive_o2o --user test-user-001 -f ndjson
dws event consume user_im_message_receive_o2o --open-dingtalk-id abc -f ndjson
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
dws event consume user_im_message_receive_user --user test-user-001 -f ndjson
dws event consume user_im_message_receive_user --open-dingtalk-id abc -f ndjson
dws event consume user_im_message_read_o2o --user test-user-001 -f ndjson
dws event consume user_im_message_read_group --group <openConversationId> -f ndjson
dws event consume user_im_message_recall_o2o --user test-user-001 -f ndjson
dws event consume user_im_message_recall_group --group <openConversationId> -f ndjson
dws event consume user_im_message_reaction_o2o --user test-user-001 -f ndjson
dws event consume user_im_message_reaction_group --group <openConversationId> -f ndjson
dws event consume user_im_message_receive_at --flatten -f ndjson
dws event consume user_im_message_receive_o2o --user test-user-001 --flatten -f ndjson
dws event consume user_im_message_receive_o2o --open-dingtalk-id abc --flatten -f ndjson
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
dws event consume user_im_message_receive_user --user test-user-001 --flatten -f ndjson
dws event consume user_im_message_receive_user --open-dingtalk-id abc --flatten -f ndjson
dws event consume user_im_message_read_o2o --user test-user-001 --flatten -f ndjson
dws event consume user_im_message_read_group --group <openConversationId> --flatten -f ndjson
dws event consume user_im_message_recall_o2o --user test-user-001 --flatten -f ndjson
dws event consume user_im_message_recall_group --group <openConversationId> --flatten -f ndjson
dws event consume user_im_message_reaction_o2o --user test-user-001 --flatten -f ndjson
dws event consume user_im_message_reaction_group --group <openConversationId> --flatten -f ndjson
```
上述所有 `*_o2o` 命令和 `user_im_message_receive_user` 都可将 `--user <userId>` 替换为 `--open-dingtalk-id <openDingtalkId>`,但两个参数不能同时使用。
@@ -125,10 +125,10 @@ dws event stop --all --yes
## Output parsing
- 推荐 `-f ndjson`:一行一个事件 JSON,适合 Agent 管道读取。
- 人工取样可用 `-f json --max-events 1`。
- `jq_root_path` 当前为 `.`;消息正文、发送人和会话 ID 分别直接读取顶层 `content`、`sender`、`conversation_id`。
- 不要生成 `fromjson` 或内部 payload 路径。正常处理直接持续读取 stdout,不要改写为 `--output-dir` watcher。
- 推荐 `--flatten -f ndjson`:顶层业务字段,一行一个事件 JSON,适合 Agent 管道读取。
- 人工取样可用 `--flatten -f json --max-events 1`。`--format` 只控制序列化,`--flatten` 控制数据结构。
- `--flatten` 的 `jq_root_path` 为 `.`;消息正文、发送人和会话 ID 分别直接读取顶层 `content`、`sender`、`conversation_id`。
- Agent 已显式使用 `--flatten`,不要再生成 `fromjson` 或内部 payload 路径。不传时默认保持兼容 envelope,业务 payload 在 `.data | fromjson`。正常处理直接持续读取 stdout,不要改写为 `--output-dir` watcher。
- 群自动回复使用顶层 `conversation_id`;单聊自动回复使用顶层 `sender_open_dingtalk_id`。
- 已读事件直接读取 `reader/reader_open_dingtalk_id/read_time`;撤回事件读取 `recaller/recaller_open_dingtalk_id/recall_time`。
- 表情回应事件直接读取 `operator/operator_open_dingtalk_id/reaction_name/reaction_text/operation_type/operation_time`。
@@ -136,7 +136,7 @@ dws event stop --all --yes
- 正常动作事件输出不含内部 `payload/uid/corpid/clientId/filterSubId/bizid`;原始排查才使用 `-f raw` 或 `--debug-raw-events`。
- 自己发的消息不作为事件回来(`isSelfLoop` 过滤);自发验证会看到 0 事件,测试投递使用别人或机器人发消息。
- `--jq <表达式>` 可进一步过滤或投影扁平输出。
- `--debug-raw-events` 仅用于服务端联调,正常消费不要使用。
- `--debug-raw-events` 仅用于服务端联调,正常消费不要使用;它和 `--flatten` 互斥,`-f raw` 也不能与 `--flatten` 同时使用。
## Troubleshooting
+20 -10
View File
@@ -19,8 +19,8 @@ description: 钉钉个人 IM 事件长连接监听、订阅与消费,覆盖消
| Command | Purpose |
|---|---|
| `dws event list` | 查看当前个人事件目录;不要把它当能力菜单主动展示 |
| `dws event schema <event_key>` | 查看事件参数和输出字段 schema,默认 JSON |
| `dws event consume <event_key> [flags]` | 阻塞消费;事件写到 stdout,推荐 `-f ndjson` |
| `dws event schema <event_key> --flatten` | 查看 Agent 使用的顶层业务字段 schema,默认 JSON |
| `dws event consume <event_key> --flatten [flags]` | 阻塞消费;事件写到 stdout,推荐 `-f ndjson` |
| `dws event status --event <event_key>` | 查看个人订阅、personal bus 和本地 consume |
| `dws event stop <subscribe_id> --dry-run` / `--yes` | 先预览,再确认取消个人订阅并停止对应本地消费 |
| `dws event stop --all --dry-run` / `--yes` | 先预览,再确认清理当前身份下本地记录的全部个人订阅 |
@@ -57,17 +57,17 @@ description: 钉钉个人 IM 事件长连接监听、订阅与消费,覆盖消
- 用户只给群名时,先运行 `dws chat search --query "<group>" --format json` 解析 openConversationId;多候选必须让用户确认。
- 用户要求执行“撤回消息”时使用 `dws chat`;只有“监听/订阅消息撤回”才使用 `dws event consume user_im_message_recall_*`。
- 用户说“贴标签”且语义是给消息贴表情时,按消息表情回应事件处理,event key 使用 `reaction`。
- 正常 Agent 消费使用 `-f ndjson`。抓一条样本可用 `--max-events 1 -f json`。
- 正常 Agent 消费统一显式使用 `--flatten -f ndjson`。抓一条样本可用 `--flatten --max-events 1 -f json`。`--format` 只控制 JSON 序列化,`--flatten` 才控制数据结构。
- 监听非默认组织时带 `--profile <corpId 或 profile 名>`;漏传会退回默认 profile 而失败。
- 自己发的消息不作为事件回来(`isSelfLoop` 过滤):边监听边 `dws chat message send` 回复不成环;测试投递用别人 / 机器人发(自发会看到 0 事件)。
- `--debug-raw-events` 只用于联调确认服务端推送是否到达本地连接;正常任务不要使用。
- `--debug-raw-events` 只用于联调确认服务端推送是否到达本地连接;正常任务不要使用。它和 `--flatten` 互斥,`-f raw` 也不能与 `--flatten` 同时使用。
- 排查:consume 报 bus 启动失败 → 报错已带真实原因,先查 `dws --profile <x> auth status`(非默认组织带对 `--profile`);本地日志见 `~/.dws/events/<edition>/personal_stream/<hash>/bus.log`(`hash` 见 `dws event status` 的 Workdir);有残留先用 `dws event stop --all --dry-run` 预览,确认后加 `--yes` 清理。看着"挂住"无输出多是误加了 `--foreground`(那是跑 bus、不打印事件),去掉即可。
## Call flow
1. 从用户意图选择事件码;人名或群名先解析成必填 ID。
2. 需要了解字段时运行 `dws event schema <event_key>`,读取 `schema.properties`;`jq_root_path` 当前固定为 `.`。
3. 启动 `dws event consume <event_key> ... -f ndjson`,等待 stderr 出现 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>` 后开始处理 stdout,不要用 `sleep` 猜测。
2. 需要了解字段时运行 `dws event schema <event_key> --flatten`,读取 `schema.properties`;此模式的 `jq_root_path` 为 `.`。
3. 启动 `dws event consume <event_key> ... --flatten -f ndjson`,等待 stderr 出现 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>` 后开始处理 stdout,不要用 `sleep` 猜测。
4. stdout 每行是一个扁平事件 JSON;直接按该事件的 `schema.properties` 读取顶层字段。
5. 需要确认监听状态时运行 `dws event status --event <event_key>`,查看 `Subscriptions` 和 `Consumers`。
6. 任务完成后优雅结束 consume;本次新建的订阅会自动取消。复用已有订阅或需要从外部主动取消时,先用 `dws event stop <subscribe_id> --dry-run` 预览,向用户确认后再加 `--yes`;临时测试可用 `--max-events` 或 `--duration` 自动退出。
@@ -88,57 +88,67 @@ description: 钉钉个人 IM 事件长连接监听、订阅与消费,覆盖消
```bash
# 当前用户被 @ 的消息
dws event consume user_im_message_receive_at -f ndjson
dws event consume user_im_message_receive_at --flatten -f ndjson
# 当前用户与指定用户的单聊消息
dws event consume user_im_message_receive_o2o \
--user test-user-001 \
--flatten \
-f ndjson
# 使用 openDingtalkId 监听外部联系人、机器人或跨组织身份的单聊消息
dws event consume user_im_message_receive_o2o \
--open-dingtalk-id open-user-1 \
--flatten \
-f ndjson
# 指定群聊/会话消息
dws event consume user_im_message_receive_group \
--group cidxxxxxxxx \
--flatten \
-f ndjson
# 指定发送人的消息(单聊和群聊)
dws event consume user_im_message_receive_user \
--user test-user-001 \
--flatten \
-f ndjson
# 使用 openDingtalkId 监听指定发送人的消息
dws event consume user_im_message_receive_user \
--open-dingtalk-id open-user-1 \
--flatten \
-f ndjson
# 指定单聊消息已读
dws event consume user_im_message_read_o2o \
--user test-user-001 \
--flatten \
-f ndjson
# 指定群聊消息撤回
dws event consume user_im_message_recall_group \
--group cidxxxxxxxx \
--flatten \
-f ndjson
# 指定单聊消息收到表情回应
dws event consume user_im_message_reaction_o2o \
--user test-user-001 \
--flatten \
-f ndjson
# 有界自测
dws event consume user_im_message_receive_at \
--duration 10m \
--flatten \
-f ndjson
# 抓一条样本
dws event consume user_im_message_receive_o2o \
--user test-user-001 \
--max-events 1 \
--flatten \
-f json
```
@@ -146,10 +156,10 @@ dws event consume user_im_message_receive_o2o \
## 输出处理
- `dws event schema <event_key>` 是写解析逻辑的依据。
- 顶层 `jq_root_path` 说明业务字段起点;当前值是 `.`。
- `dws event schema <event_key> --flatten` 是 Agent 写解析逻辑的依据。
- `--flatten` 模式的顶层 `jq_root_path` 为 `.`;不传时为兼容存量脚本的 transport envelope,业务 payload 在 `.data | fromjson`。
- `schema.properties` 是业务字段列表,例如 `content`、`sender`、`conversation_id`、`message_id`、`event_time`。
- 所有公开事件都是扁平业务对象,直接读取顶层字段;不要生成 `fromjson` 或内部 payload 路径。
- Agent 命令已显式传 `--flatten`,直接读取顶层字段;不要对该模式再生成 `fromjson` 或内部 payload 路径。
- 群自动回复使用事件顶层 `conversation_id`;单聊自动回复使用顶层 `sender_open_dingtalk_id`。
- 已读事件读取顶层 `reader`、`reader_open_dingtalk_id`、`read_time`;撤回事件读取 `recaller`、`recaller_open_dingtalk_id`、`recall_time`。
- 表情回应事件读取顶层 `operator`、`operator_open_dingtalk_id`、`reaction_name`、`reaction_text`、`operation_type`、`operation_time`。
@@ -15,19 +15,19 @@ dws auth login
查看事件 schema:
```bash
dws event schema user_im_message_receive_at
dws event schema user_im_message_receive_o2o
dws event schema user_im_message_receive_group
dws event schema user_im_message_receive_user
dws event schema user_im_message_read_o2o
dws event schema user_im_message_read_group
dws event schema user_im_message_recall_o2o
dws event schema user_im_message_recall_group
dws event schema user_im_message_reaction_o2o
dws event schema user_im_message_reaction_group
dws event schema user_im_message_receive_at --flatten
dws event schema user_im_message_receive_o2o --flatten
dws event schema user_im_message_receive_group --flatten
dws event schema user_im_message_receive_user --flatten
dws event schema user_im_message_read_o2o --flatten
dws event schema user_im_message_read_group --flatten
dws event schema user_im_message_recall_o2o --flatten
dws event schema user_im_message_recall_group --flatten
dws event schema user_im_message_reaction_o2o --flatten
dws event schema user_im_message_reaction_group --flatten
```
schema 默认 JSON。业务字段说明在 `schema.properties`,`jq_root_path` 当前固定为 `.`。
schema 默认 JSON。Agent 使用 `--flatten` schema,业务字段在 `schema.properties`,`jq_root_path` 为 `.`。不传 `--flatten` 时查看兼容 transport envelope,其 `jq_root_path` 为 `.data | fromjson`。
## Event catalog
@@ -62,61 +62,72 @@ schema 默认 JSON。业务字段说明在 `schema.properties`,`jq_root_path`
```bash
# 被 @ 消息
dws event consume user_im_message_receive_at -f ndjson
dws event consume user_im_message_receive_at --flatten -f ndjson
# 指定单聊消息
dws event consume user_im_message_receive_o2o \
--user test-user-001 \
--flatten \
-f ndjson
# 通过 openDingtalkId 指定单聊对端
dws event consume user_im_message_receive_o2o \
--open-dingtalk-id open-user-1 \
--flatten \
-f ndjson
# 指定群消息
dws event consume user_im_message_receive_group \
--group cidxxxxxxxx \
--flatten \
-f ndjson
# 指定发送人的消息(单聊和群聊)
dws event consume user_im_message_receive_user \
--user test-user-001 \
--flatten \
-f ndjson
# 通过 openDingtalkId 指定发送人
dws event consume user_im_message_receive_user \
--open-dingtalk-id open-user-1 \
--flatten \
-f ndjson
# 指定单聊已读事件
dws event consume user_im_message_read_o2o \
--user test-user-001 \
--flatten \
-f ndjson
# 指定群聊已读事件
dws event consume user_im_message_read_group \
--group cidxxxxxxxx \
--flatten \
-f ndjson
# 指定单聊撤回事件
dws event consume user_im_message_recall_o2o \
--user test-user-001 \
--flatten \
-f ndjson
# 指定群聊撤回事件
dws event consume user_im_message_recall_group \
--group cidxxxxxxxx \
--flatten \
-f ndjson
# 指定单聊表情回应事件
dws event consume user_im_message_reaction_o2o \
--user test-user-001 \
--flatten \
-f ndjson
# 指定群聊表情回应事件
dws event consume user_im_message_reaction_group \
--group cidxxxxxxxx \
--flatten \
-f ndjson
```
@@ -124,16 +135,16 @@ dws event consume user_im_message_reaction_group \
| 事件码 | 自测参数 | 触发方式 |
|---|---|---|
| `user_im_message_receive_at` | `--duration 10m -f ndjson` | 让任意可触达用户在群里 @ 当前登录用户 |
| `user_im_message_receive_o2o` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--duration 10m -f ndjson` | 让对端用户给当前登录用户发送单聊消息 |
| `user_im_message_receive_group` | `--group <openConversationId> --duration 10m -f ndjson` | 让任意用户在该群发送消息 |
| `user_im_message_receive_user` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--duration 10m -f ndjson` | 让指定用户分别在单聊或共同群聊中发送消息 |
| `user_im_message_read_o2o` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--duration 10m -f ndjson` | 当前用户给对端发送单聊消息,再让对端打开并阅读 |
| `user_im_message_read_group` | `--group <openConversationId> --duration 10m -f ndjson` | 当前用户在群内发送消息,再让群成员打开并阅读 |
| `user_im_message_recall_o2o` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--duration 10m -f ndjson` | 在指定单聊中发送并撤回一条消息 |
| `user_im_message_recall_group` | `--group <openConversationId> --duration 10m -f ndjson` | 在指定群聊中发送并撤回一条消息 |
| `user_im_message_reaction_o2o` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--duration 10m -f ndjson` | 在指定单聊中给消息添加表情回应 |
| `user_im_message_reaction_group` | `--group <openConversationId> --duration 10m -f ndjson` | 在指定群聊中给消息添加表情回应 |
| `user_im_message_receive_at` | `--flatten --duration 10m -f ndjson` | 让任意可触达用户在群里 @ 当前登录用户 |
| `user_im_message_receive_o2o` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--flatten --duration 10m -f ndjson` | 让对端用户给当前登录用户发送单聊消息 |
| `user_im_message_receive_group` | `--group <openConversationId> --flatten --duration 10m -f ndjson` | 让任意用户在该群发送消息 |
| `user_im_message_receive_user` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--flatten --duration 10m -f ndjson` | 让指定用户分别在单聊或共同群聊中发送消息 |
| `user_im_message_read_o2o` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--flatten --duration 10m -f ndjson` | 当前用户给对端发送单聊消息,再让对端打开并阅读 |
| `user_im_message_read_group` | `--group <openConversationId> --flatten --duration 10m -f ndjson` | 当前用户在群内发送消息,再让群成员打开并阅读 |
| `user_im_message_recall_o2o` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--flatten --duration 10m -f ndjson` | 在指定单聊中发送并撤回一条消息 |
| `user_im_message_recall_group` | `--group <openConversationId> --flatten --duration 10m -f ndjson` | 在指定群聊中发送并撤回一条消息 |
| `user_im_message_reaction_o2o` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--flatten --duration 10m -f ndjson` | 在指定单聊中给消息添加表情回应 |
| `user_im_message_reaction_group` | `--group <openConversationId> --flatten --duration 10m -f ndjson` | 在指定群聊中给消息添加表情回应 |
stderr 出现固定就绪行 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>` 表示本地 consume 已连接到事件 bus;父进程等这行再读 stdout。stdout 每行是一个扁平事件 JSON。
@@ -141,7 +152,8 @@ stderr 出现固定就绪行 `[event] ready event_key=<key> bus_pid=<pid> subscr
| 参数 | 用途 |
|---|---|
| `-f ndjson` | 推荐输出,一行一个事件 JSON |
| `--flatten` | 将 `ndjson/json/pretty` 的默认 transport envelope(或原 compact processor)投影为 Agent 可直接读取的顶层业务字段;不能与 `-f raw` 或 `--debug-raw-events` 同时使用 |
| `-f ndjson` | 控制序列化为一行一个 JSON;不改变数据结构 |
| `-f json` | 人工查看单条或少量样本;必须配合 `--max-events` 或 `--duration` |
| `--max-events <n>` | 收到 N 条后退出 |
| `--duration <duration>` | 到时退出,例如 `30s`、`10m` |
@@ -153,11 +165,11 @@ stderr 出现固定就绪行 `[event] ready event_key=<key> bus_pid=<pid> subscr
| `--filter-json <json>` | 使用个人事件 Filter DSL 过滤 |
| `--debug-raw-events` | 联调用:绕过本地过滤,输出当前 personal stream 实际收到的可解析事件 |
正常 Agent 消费不要使用 `--debug-raw-events`。它会输出当前连接收到的所有可解析事件,只用于判断服务端是否推到了本机连接。
正常 Agent 消费不要使用 `--debug-raw-events`。它会输出当前连接收到的所有可解析事件,只用于判断服务端是否推到了本机连接,并且不能与 `--flatten` 同时使用。
## Output parsing
`-f ndjson` 的 stdout 每行就是一个扁平业务事件对象。消息接收事件常见顶层字段:
Agent 使用 `--flatten -f ndjson`,stdout 每行是一个扁平业务事件对象。消息接收事件常见顶层字段:
| 字段 | 说明 |
|---|---|
@@ -173,7 +185,7 @@ stderr 出现固定就绪行 `[event] ready event_key=<key> bus_pid=<pid> subscr
| `create_time` | 消息创建时间 |
| `event_time` | 消息事件时间戳 |
直接按顶层字段解析,不要使用 `fromjson`,也不要依赖内部 transport payload 路径。图片、文件等媒体消息的 `content` 可能是可读描述;需要实际媒体文件时调用 `dws chat message download-media`。
在 `--flatten` 模式下直接按顶层字段解析,不要再使用 `fromjson` 或内部 payload 路径。不传 `--flatten` 时保持兼容 transport envelope,字段为 `type/event_type/data/headers`,业务 payload 需从 `.data | fromjson` 读取。图片、文件等媒体消息的 `content` 可能是可读描述;需要实际媒体文件时调用 `dws chat message download-media`。
所有动作事件都包含顶层 `type`、`event_id`、`timestamp`、`subscribe_id`、`message_id`、`conversation_id`、`sender`、`sender_open_dingtalk_id` 和 `event_time`。各类动作的专有字段如下:
@@ -204,6 +216,7 @@ stderr 出现固定就绪行 `[event] ready event_key=<key> bus_pid=<pid> subscr
dws event consume user_im_message_receive_group \
--group cidxxxxxxxx \
--query "报警,故障" \
--flatten \
-f ndjson
```
+2 -2
View File
@@ -28,7 +28,7 @@ func TestDWSDocsCommandTreeCoverage(t *testing.T) {
t.Skip("no command paths parsed from docs/dws (directory may not exist)")
}
index := buildCommandIndex(app.NewRootCommand())
index := buildCommandIndex(app.NewSchemaSourceRootCommand())
missing := make([]string, 0)
for _, path := range docPaths {
if _, ok := index[path]; ok {
@@ -52,7 +52,7 @@ func TestDWSDocsLocalFlagsCoverage(t *testing.T) {
}
docLeafSet := leafCommandSet(docPaths)
index := buildCommandIndex(app.NewRootCommand())
index := buildCommandIndex(app.NewSchemaSourceRootCommand())
missing := make([]string, 0)
fallbackMatched := 0
explicitMatched := 0
+7
View File
@@ -9,6 +9,12 @@ import (
)
func TestMain(m *testing.M) {
configDir, err := os.MkdirTemp("", "dws-cli-test-config-")
if err != nil {
panic(err)
}
os.Setenv("DWS_CONFIG_DIR", configDir)
// Set an empty catalog fixture so that EnvironmentLoader does not
// attempt live discovery (which would hang on unreachable MCP endpoints).
// Tests that construct app root commands must remain serial because root
@@ -17,5 +23,6 @@ func TestMain(m *testing.M) {
os.Setenv(cli.CatalogFixtureEnv, absFixture)
code := m.Run()
_ = os.RemoveAll(configDir)
os.Exit(code)
}
+323 -18
View File
@@ -300,7 +300,12 @@ func TestSyncToGiteeRunsTagReleaseCreationAndAssetReconciliationWithinOneBudget(
case r.Method == http.MethodGet && r.URL.Path == "/repos/owner/repo/releases/tags/v1.2.3":
releaseMu.Lock()
releaseLookups++
lookupAttempt := releaseLookups
releaseMu.Unlock()
if lookupAttempt == 1 {
http.Error(w, "temporary Gitee outage", http.StatusServiceUnavailable)
return
}
http.NotFound(w, r)
case r.Method == http.MethodPost && r.URL.Path == "/repos/owner/repo/releases":
if err := r.ParseMultipartForm(1 << 20); err != nil {
@@ -336,6 +341,8 @@ func TestSyncToGiteeRunsTagReleaseCreationAndAssetReconciliationWithinOneBudget(
"GITEE_TAG_TIMEOUT_SECONDS=5",
"GITEE_GIT_TIMEOUT_SECONDS=3",
"GITEE_RELEASE_LOOKUP_MAX_TIME=2",
"GITEE_RELEASE_LOOKUP_RETRIES=2",
"GITEE_RELEASE_LOOKUP_RETRY_DELAY=0",
"GITEE_RELEASE_CREATE_MAX_TIME=2",
"GITEE_RECONCILE_TIMEOUT_SECONDS=20",
"GITEE_CHILD_DEADLINE_RESERVE_SECONDS=1",
@@ -357,8 +364,8 @@ func TestSyncToGiteeRunsTagReleaseCreationAndAssetReconciliationWithinOneBudget(
}
releaseMu.Lock()
if releaseLookups != 1 || releaseCreates != 1 {
t.Errorf("release lookup/create calls = %d/%d, want 1/1", releaseLookups, releaseCreates)
if releaseLookups != 2 || releaseCreates != 1 {
t.Errorf("release lookup/create calls = %d/%d, want 2/1", releaseLookups, releaseCreates)
}
releaseMu.Unlock()
fake.mu.Lock()
@@ -374,11 +381,15 @@ func TestReconcileGiteeAssetsRecoversACommittedUploadWithLostResponse(t *testing
scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "reconcile-gitee-assets.sh"))
distDir := seedGiteeDist(t)
fake := newFakeGiteeRelease(true, false)
fake.listErrorResponsesAfterUpload = 1
fake.listEmptyResponsesAfterUpload = 1
server := httptest.NewServer(fake)
defer server.Close()
cmd := exec.Command("bash", scriptPath)
cmd.Env = giteeAssetEnv(distDir, server.URL, "2")
cmd.Env = append(giteeAssetEnv(distDir, server.URL, "2"),
"GITEE_POST_UPLOAD_VERIFY_ATTEMPTS=2",
)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("reconcile-gitee-assets.sh error = %v\noutput:\n%s", err, output)
@@ -409,6 +420,167 @@ func TestReconcileGiteeAssetsRecoversACommittedUploadWithLostResponse(t *testing
}
}
func TestReconcileGiteeAssetsDoesNotReplayAnAmbiguousUpload(t *testing.T) {
scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "reconcile-gitee-assets.sh"))
distDir := seedGiteeDist(t)
fake := newFakeGiteeRelease(true, false)
fake.listEmptyResponsesAfterUpload = 3
server := httptest.NewServer(fake)
defer server.Close()
cmd := exec.Command("bash", scriptPath)
cmd.Env = append(giteeAssetEnv(distDir, server.URL, "2"),
"GITEE_POST_UPLOAD_VERIFY_ATTEMPTS=2",
)
output, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("ambiguous upload unexpectedly succeeded:\n%s", output)
}
if !strings.Contains(string(output), "refusing to replay POST") {
t.Fatalf("ambiguous upload did not fail closed:\n%s", output)
}
fake.mu.Lock()
defer fake.mu.Unlock()
for _, name := range requiredGiteeAssets {
if got := fake.uploadCalls[name]; got != 1 {
t.Errorf("upload calls for %s = %d, want exactly 1", name, got)
}
}
}
func TestReconcileGiteeAssetsRetriesATransientListOutage(t *testing.T) {
scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "reconcile-gitee-assets.sh"))
distDir := seedGiteeDist(t)
fake := newFakeGiteeRelease(false, false)
fake.listFailuresRemaining = 2
server := httptest.NewServer(fake)
defer server.Close()
cmd := exec.Command("bash", scriptPath)
cmd.Env = append(giteeAssetEnv(distDir, server.URL, "1"),
"GITEE_LIST_RETRIES=3",
"GITEE_LIST_RETRY_DELAY=1",
"GITEE_LIST_RETRY_WINDOW_SECONDS=4",
)
started := time.Now()
output, err := cmd.CombinedOutput()
elapsed := time.Since(started)
if err != nil {
t.Fatalf("transient-list reconciliation error = %v\noutput:\n%s", err, output)
}
if !strings.Contains(string(output), "Gitee attachment list attempt 2/3 failed; retrying in 1s") {
t.Fatalf("transient list failures were not retried visibly:\n%s", output)
}
if elapsed < 1500*time.Millisecond {
t.Fatalf("transient list retry elapsed = %s, want production-style backoff", elapsed)
}
if !strings.Contains(string(output), "all 8 verified") {
t.Fatalf("transient-list reconciliation did not verify every asset:\n%s", output)
}
fake.mu.Lock()
defer fake.mu.Unlock()
if fake.listFailuresRemaining != 0 {
t.Fatalf("unconsumed list failures = %d, want 0", fake.listFailuresRemaining)
}
for _, name := range requiredGiteeAssets {
if got := fake.uploadCalls[name]; got != 1 {
t.Errorf("upload calls for %s = %d, want 1", name, got)
}
}
}
func TestReconcileGiteeAssetsDoesNotBurstRetriesInTheFinalWindowSecond(t *testing.T) {
scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "reconcile-gitee-assets.sh"))
distDir := seedGiteeDist(t)
fake := newFakeGiteeRelease(false, false)
fake.listFailuresRemaining = 1000
server := httptest.NewServer(fake)
defer server.Close()
cmd := exec.Command("bash", scriptPath)
cmd.Env = append(giteeAssetEnv(distDir, server.URL, "1"),
"GITEE_LIST_RETRIES=24",
"GITEE_LIST_RETRY_DELAY=1",
"GITEE_LIST_RETRY_WINDOW_SECONDS=1",
)
output, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("permanent list outage unexpectedly succeeded:\n%s", output)
}
fake.mu.Lock()
defer fake.mu.Unlock()
if fake.listCalls > len(requiredGiteeAssets)+1 {
t.Fatalf("list calls = %d, want at most one per asset plus final verification", fake.listCalls)
}
}
func TestReconcileGiteeAssetsDiscardsPartialOutputFromAMalformedList(t *testing.T) {
scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "reconcile-gitee-assets.sh"))
distDir := seedGiteeDist(t)
fake := newFakeGiteeRelease(false, false)
existingName := requiredGiteeAssets[0]
existingData, err := os.ReadFile(filepath.Join(distDir, existingName))
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", existingName, err)
}
fake.assets[1] = fakeGiteeAsset{id: 1, name: existingName, data: existingData}
fake.nextID = 2
fake.malformedListResponsesRemaining = 1
server := httptest.NewServer(fake)
defer server.Close()
cmd := exec.Command("bash", scriptPath)
cmd.Env = append(giteeAssetEnv(distDir, server.URL, "1"),
"GITEE_LIST_RETRIES=2",
"GITEE_LIST_RETRY_DELAY=0",
"GITEE_LIST_RETRY_WINDOW_SECONDS=2",
)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("malformed-list reconciliation error = %v\noutput:\n%s", err, output)
}
if !strings.Contains(string(output), existingName+" already correct on Gitee") {
t.Fatalf("partial failed-list output manufactured a stale or duplicate asset:\n%s", output)
}
if !strings.Contains(string(output), "all 8 verified") {
t.Fatalf("malformed-list reconciliation did not verify every asset:\n%s", output)
}
fake.mu.Lock()
defer fake.mu.Unlock()
if got := fake.uploadCalls[existingName]; got != 0 {
t.Errorf("upload calls for existing %s = %d, want 0", existingName, got)
}
}
func TestReconcileGiteeAssetsDisablesExpectContinueForLargeUploads(t *testing.T) {
scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "reconcile-gitee-assets.sh"))
distDir := seedGiteeDist(t)
mustWriteFile(
t,
filepath.Join(distDir, requiredGiteeAssets[0]),
[]byte(strings.Repeat("x", 2<<20)),
0o644,
)
fake := newFakeGiteeRelease(false, false)
fake.rejectExpectContinue = true
server := httptest.NewServer(fake)
defer server.Close()
cmd := exec.Command("bash", scriptPath)
cmd.Env = giteeAssetEnv(distDir, server.URL, "1")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("large-asset reconciliation error = %v\noutput:\n%s", err, output)
}
if !strings.Contains(string(output), "all 8 verified") {
t.Fatalf("large-asset reconciliation did not verify every asset:\n%s", output)
}
}
func TestReconcileGiteeAssetsFailsWhenAnyUploadIsMissing(t *testing.T) {
scriptPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "reconcile-gitee-assets.sh"))
distDir := seedGiteeDist(t)
@@ -515,7 +687,53 @@ func TestGiteeReleaseWorkflowUsesImmutableTagsAndBoundedRetryBudget(t *testing.T
}
perAssetSeconds := shellDefaultInt(t, reconciler, "GITEE_ASSET_TIMEOUT_SECONDS")
overallSeconds := shellDefaultInt(t, reconciler, "GITEE_OVERALL_TIMEOUT_SECONDS")
finalListSeconds := shellDefaultInt(t, reconciler, "GITEE_LIST_MAX_TIME")
listSeconds := shellDefaultInt(t, reconciler, "GITEE_LIST_MAX_TIME")
listRetries := shellDefaultInt(t, reconciler, "GITEE_LIST_RETRIES")
listRetryDelay := shellDefaultInt(t, reconciler, "GITEE_LIST_RETRY_DELAY")
listRetryWindow := shellDefaultInt(t, reconciler, "GITEE_LIST_RETRY_WINDOW_SECONDS")
uploadSeconds := shellDefaultInt(t, reconciler, "GITEE_UPLOAD_MAX_TIME")
verifySeconds := shellDefaultInt(t, reconciler, "GITEE_VERIFY_MAX_TIME")
postUploadVerifyAttempts := shellDefaultInt(t, reconciler, "GITEE_POST_UPLOAD_VERIFY_ATTEMPTS")
verifyRetryDelay := shellDefaultInt(t, reconciler, "GITEE_VERIFY_RETRY_DELAY")
uploadRetryDelay := shellDefaultInt(t, reconciler, "GITEE_UPLOAD_RETRY_DELAY")
const minimumLargeAssetUploadSeconds = 1200
if uploadSeconds < minimumLargeAssetUploadSeconds {
t.Fatalf(
"Gitee upload deadline = %ds, want at least %ds for near-10 MiB release assets",
uploadSeconds, minimumLargeAssetUploadSeconds,
)
}
const minimumTransientListOutageSeconds = 300
if listRetryWindow < minimumTransientListOutageSeconds {
t.Fatalf(
"Gitee list recovery budget = %ds, want at least %ds for a transient API outage",
listRetryWindow, minimumTransientListOutageSeconds,
)
}
if (listRetries-1)*listRetryDelay < listRetryWindow {
t.Fatalf(
"%d Gitee list attempts with %ds delay cannot span the configured %ds retry window after fast failures",
listRetries, listRetryDelay, listRetryWindow,
)
}
completeListRecoveryBudget := listRetryWindow
oneSlowSuccessBudget := 2*completeListRecoveryBudget + uploadSeconds + verifySeconds
if oneSlowSuccessBudget > perAssetSeconds {
t.Fatalf(
"one complete slow upload budget = %ds, exceeds per-asset deadline %ds",
oneSlowSuccessBudget, perAssetSeconds,
)
}
fastFailureRetryBudget := uploadSeconds + (postUploadVerifyAttempts+3)*listSeconds +
verifySeconds + (postUploadVerifyAttempts-1)*verifyRetryDelay + uploadRetryDelay
fastFailureRetryBudget += 2 * (completeListRecoveryBudget - listSeconds)
if fastFailureRetryBudget > perAssetSeconds {
t.Fatalf(
"fast-failure retry budget = %ds, exceeds per-asset deadline %ds",
fastFailureRetryBudget, perAssetSeconds,
)
}
finalListSeconds := completeListRecoveryBudget
completeAssetBudget := perAssetSeconds*len(requiredGiteeAssets) + finalListSeconds
if completeAssetBudget > overallSeconds {
t.Fatalf(
@@ -530,15 +748,16 @@ func TestGiteeReleaseWorkflowUsesImmutableTagsAndBoundedRetryBudget(t *testing.T
)
}
completeSyncBudget := tagSeconds + lookupSeconds + createSeconds + reconcileSeconds
childDeadlineReserveSeconds := shellDefaultInt(t, syncScript, "GITEE_CHILD_DEADLINE_RESERVE_SECONDS")
completeSyncBudget := tagSeconds + lookupSeconds + createSeconds + reconcileSeconds + childDeadlineReserveSeconds
if completeSyncBudget > syncSeconds {
t.Fatalf(
"complete sync budget = %ds (tag=%d + lookup=%d + create=%d + reconcile=%d), exceeds sync deadline %ds",
completeSyncBudget, tagSeconds, lookupSeconds, createSeconds, reconcileSeconds, syncSeconds,
"complete sync budget = %ds (tag=%d + lookup=%d + create=%d + reconcile=%d + child reserve=%d), exceeds sync deadline %ds",
completeSyncBudget, tagSeconds, lookupSeconds, createSeconds, reconcileSeconds, childDeadlineReserveSeconds, syncSeconds,
)
}
const workflowReserveMinutes = 5
const syncStepReserveMinutes = 4
releasePath := mustAbs(t, filepath.Join("..", "..", ".github", "workflows", "release.yml"))
releaseData, err := os.ReadFile(releasePath)
if err != nil {
@@ -550,21 +769,49 @@ func TestGiteeReleaseWorkflowUsesImmutableTagsAndBoundedRetryBudget(t *testing.T
"mirror-gitee-release:",
[]string{
"name: Check out sealed release source",
"name: Check out trusted release tooling",
"name: Fetch and verify sealed release tag",
"name: Restore finalized distribution files",
"name: Mirror release to Gitee (China)",
},
workflowReserveMinutes,
17,
)
releaseSyncStepSeconds := workflowTimeoutMinutesAfter(
t, string(releaseData), "mirror-gitee-release:", "name: Mirror release to Gitee (China)",
) * 60
if syncSeconds+workflowReserveMinutes*60 > releaseSyncStepSeconds {
if syncSeconds+syncStepReserveMinutes*60 > releaseSyncStepSeconds {
t.Fatalf(
"sync deadline %ds plus reserve exceeds release fallback step %ds",
syncSeconds, releaseSyncStepSeconds,
)
}
assertWorkflowBudget(
t,
string(releaseData),
"repair-channel:",
[]string{
"name: Check out trusted release tooling",
"name: Validate repair version",
"name: Verify immutable release authority",
"name: Check out sealed release source",
"name: Fetch and verify sealed release tag",
"name: Require successful Release workflow delivery",
"name: Download and verify immutable GitHub Release assets",
"name: Mirror release to Gitee (China)",
},
5,
)
repairSyncStepSeconds := workflowTimeoutMinutesAfter(
t, string(releaseData), "repair-channel:", "name: Mirror release to Gitee (China)",
) * 60
if syncSeconds+syncStepReserveMinutes*60 > repairSyncStepSeconds {
t.Fatalf(
"sync deadline %ds plus reserve exceeds repair step %ds",
syncSeconds, repairSyncStepSeconds,
)
}
localBuildPath := mustAbs(t, filepath.Join("..", "..", "scripts", "release", "build-and-publish-gitee.sh"))
localBuildData, err := os.ReadFile(localBuildPath)
if err != nil {
@@ -731,6 +978,9 @@ func giteeAssetEnv(distDir, apiURL, retries string) []string {
"GITEE_RELEASE_ID=1",
"GITEE_CURL_CONNECT_TIMEOUT=2",
"GITEE_CURL_MAX_TIME=2",
"GITEE_LIST_RETRIES=2",
"GITEE_LIST_RETRY_DELAY=0",
"GITEE_LIST_RETRY_WINDOW_SECONDS=2",
"GITEE_UPLOAD_MAX_TIME=2",
"GITEE_UPLOAD_RETRIES="+retries,
"GITEE_UPLOAD_RETRY_DELAY=0",
@@ -747,14 +997,22 @@ type fakeGiteeAsset struct {
}
type fakeGiteeRelease struct {
mu sync.Mutex
nextID int
assets map[int]fakeGiteeAsset
uploadCalls map[string]int
dropFirstResponse bool
droppedResponse bool
failUploads bool
uploadDelay time.Duration
mu sync.Mutex
nextID int
assets map[int]fakeGiteeAsset
uploadCalls map[string]int
dropFirstResponse bool
droppedResponse bool
failUploads bool
uploadDelay time.Duration
rejectExpectContinue bool
listFailuresRemaining int
malformedListResponsesRemaining int
listErrorResponsesRemaining int
listEmptyResponsesRemaining int
listErrorResponsesAfterUpload int
listEmptyResponsesAfterUpload int
listCalls int
}
func newFakeGiteeRelease(dropFirstResponse, failUploads bool) *fakeGiteeRelease {
@@ -799,7 +1057,42 @@ func (f *fakeGiteeRelease) ServeHTTP(w http.ResponseWriter, r *http.Request) {
func (f *fakeGiteeRelease) list(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
f.listCalls++
if f.listFailuresRemaining > 0 {
f.listFailuresRemaining--
http.Error(w, "temporary Gitee list outage", http.StatusServiceUnavailable)
return
}
if f.listErrorResponsesRemaining > 0 {
f.listErrorResponsesRemaining--
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{}`)
return
}
if f.listEmptyResponsesRemaining > 0 {
f.listEmptyResponsesRemaining--
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode([]any{})
return
}
baseURL := requestBaseURL(r)
if f.malformedListResponsesRemaining > 0 {
f.malformedListResponsesRemaining--
for _, asset := range f.assets {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode([]any{
map[string]any{
"id": asset.id,
"name": asset.name,
"browser_download_url": fmt.Sprintf("%s/download/%d", baseURL, asset.id),
},
nil,
})
return
}
http.Error(w, "malformed-list fixture requires one asset", http.StatusInternalServerError)
return
}
rows := make([]map[string]any, 0, len(f.assets))
for _, asset := range f.assets {
rows = append(rows, map[string]any{
@@ -813,6 +1106,10 @@ func (f *fakeGiteeRelease) list(w http.ResponseWriter, r *http.Request) {
}
func (f *fakeGiteeRelease) upload(w http.ResponseWriter, r *http.Request) {
if f.rejectExpectContinue && r.Header.Get("Expect") != "" {
http.Error(w, "Expect: 100-continue is not supported", http.StatusExpectationFailed)
return
}
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -847,6 +1144,14 @@ func (f *fakeGiteeRelease) upload(w http.ResponseWriter, r *http.Request) {
id := f.nextID
f.nextID++
f.assets[id] = fakeGiteeAsset{id: id, name: header.Filename, data: data}
if f.listErrorResponsesAfterUpload > 0 {
f.listErrorResponsesRemaining += f.listErrorResponsesAfterUpload
f.listErrorResponsesAfterUpload = 0
}
if f.listEmptyResponsesAfterUpload > 0 {
f.listEmptyResponsesRemaining += f.listEmptyResponsesAfterUpload
f.listEmptyResponsesAfterUpload = 0
}
drop := f.dropFirstResponse && !f.droppedResponse
if drop {
f.droppedResponse = true
+15 -15
View File
@@ -1000,27 +1000,27 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
**event_event_consume_at_001**
- Prompt: 监听有人 @ 我的消息
- Expected: `dws event consume user_im_message_receive_at -f ndjson`
- Expected: `dws event consume user_im_message_receive_at --flatten -f ndjson`
#### `dws event consume user_im_message_receive_o2o`
**event_event_consume_o2o_001**
- Prompt: 监听我和 userId test-user-001 的单聊消息
- Expected: `dws event consume user_im_message_receive_o2o --user test-user-001 -f ndjson`
- Expected: `dws event consume user_im_message_receive_o2o --user test-user-001 --flatten -f ndjson`
- Flags: `--user` = `test-user-001`
**event_event_consume_o2o_open_id_001**
- Prompt: 监听我和 openDingtalkId abc 的单聊消息
- Expected: `dws event consume user_im_message_receive_o2o --open-dingtalk-id abc -f ndjson`
- Expected: `dws event consume user_im_message_receive_o2o --open-dingtalk-id abc --flatten -f ndjson`
- Flags: `--open-dingtalk-id` = `abc`
**event_event_consume_o2o_003** `[ASK_USER]`
- Prompt: 监听我的个人单聊消息
- Expected: `dws event consume user_im_message_receive_o2o -f ndjson`
- Expected: `dws event consume user_im_message_receive_o2o --flatten -f ndjson`
**event_event_consume_o2o_auto_reply_001**
- Prompt: 监听我和 userId test-user-001 的单聊消息,并对方发什么自动回复什么
- Expected: `dws event consume user_im_message_receive_o2o --user test-user-001 -f ndjson`
- Expected: `dws event consume user_im_message_receive_o2o --user test-user-001 --flatten -f ndjson`
- Flags: `--user` = `test-user-001`
- Contract: 等待 stderr 的 `[event] ready`;从每行 NDJSON 顶层读取 `content` 和 `sender_open_dingtalk_id`,持续读取 stdout,不使用轮询或 output-dir watcher
@@ -1028,7 +1028,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
**event_event_consume_group_001**
- Prompt: 监听 openConversationId cid123 的群消息
- Expected: `dws event consume user_im_message_receive_group --group cid123 -f ndjson`
- Expected: `dws event consume user_im_message_receive_group --group cid123 --flatten -f ndjson`
- Flags: `--group` = `cid123`
- Contract: 群自动回复时直接读取事件顶层 `conversation_id`
@@ -1036,19 +1036,19 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
**event_event_consume_user_001**
- Prompt: 监听 userId test-user-001 发给我的消息,包括单聊和群聊
- Expected: `dws event consume user_im_message_receive_user --user test-user-001 -f ndjson`
- Expected: `dws event consume user_im_message_receive_user --user test-user-001 --flatten -f ndjson`
- Flags: `--user` = `test-user-001`
**event_event_consume_user_open_id_001**
- Prompt: 监听 openDingtalkId abc 发给我的消息,包括单聊和群聊
- Expected: `dws event consume user_im_message_receive_user --open-dingtalk-id abc -f ndjson`
- Expected: `dws event consume user_im_message_receive_user --open-dingtalk-id abc --flatten -f ndjson`
- Flags: `--open-dingtalk-id` = `abc`
#### `dws event consume user_im_message_read_o2o`
**event_event_consume_read_o2o_001**
- Prompt: 监听我发给 userId test-user-001 的单聊消息是否已读
- Expected: `dws event consume user_im_message_read_o2o --user test-user-001 -f ndjson`
- Expected: `dws event consume user_im_message_read_o2o --user test-user-001 --flatten -f ndjson`
- Flags: `--user` = `test-user-001`
- Contract: 从每行 NDJSON 顶层读取 `message_id`、`reader`、`reader_open_dingtalk_id`、`read_time`
@@ -1056,7 +1056,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
**event_event_consume_read_group_001**
- Prompt: 监听 openConversationId cid123 群里我发的消息是否已读
- Expected: `dws event consume user_im_message_read_group --group cid123 -f ndjson`
- Expected: `dws event consume user_im_message_read_group --group cid123 --flatten -f ndjson`
- Flags: `--group` = `cid123`
- Contract: 从每行 NDJSON 顶层读取 `conversation_id`、`reader`、`reader_open_dingtalk_id`、`read_time`
@@ -1064,7 +1064,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
**event_event_consume_recall_o2o_001**
- Prompt: 监听我和 userId test-user-001 的单聊消息撤回事件
- Expected: `dws event consume user_im_message_recall_o2o --user test-user-001 -f ndjson`
- Expected: `dws event consume user_im_message_recall_o2o --user test-user-001 --flatten -f ndjson`
- Flags: `--user` = `test-user-001`
- Contract: 从每行 NDJSON 顶层读取 `message_id`、`recaller`、`recaller_open_dingtalk_id`、`recall_time`
@@ -1072,7 +1072,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
**event_event_consume_recall_group_001**
- Prompt: 监听 openConversationId cid123 的群消息撤回事件
- Expected: `dws event consume user_im_message_recall_group --group cid123 -f ndjson`
- Expected: `dws event consume user_im_message_recall_group --group cid123 --flatten -f ndjson`
- Flags: `--group` = `cid123`
- Contract: 从每行 NDJSON 顶层读取 `conversation_id`、`recaller`、`recaller_open_dingtalk_id`、`recall_time`
@@ -1080,13 +1080,13 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
**event_event_consume_reaction_o2o_001**
- Prompt: 监听我和 userId test-user-001 的单聊消息贴表情事件
- Expected: `dws event consume user_im_message_reaction_o2o --user test-user-001 -f ndjson`
- Expected: `dws event consume user_im_message_reaction_o2o --user test-user-001 --flatten -f ndjson`
- Flags: `--user` = `test-user-001`
- Contract: 从每行 NDJSON 顶层读取 `operator`、`operator_open_dingtalk_id`、`reaction_name`、`operation_type`
**event_event_consume_reaction_o2o_open_id_001**
- Prompt: 监听我和 openDingtalkId abc 的单聊消息贴表情事件
- Expected: `dws event consume user_im_message_reaction_o2o --open-dingtalk-id abc -f ndjson`
- Expected: `dws event consume user_im_message_reaction_o2o --open-dingtalk-id abc --flatten -f ndjson`
- Flags: `--open-dingtalk-id` = `abc`
- Contract: 从每行 NDJSON 顶层读取 `operator`、`operator_open_dingtalk_id`、`reaction_name`、`operation_type`
@@ -1094,7 +1094,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
**event_event_consume_reaction_group_001**
- Prompt: 监听 openConversationId cid123 的群消息表情回应事件
- Expected: `dws event consume user_im_message_reaction_group --group cid123 -f ndjson`
- Expected: `dws event consume user_im_message_reaction_group --group cid123 --flatten -f ndjson`
- Flags: `--group` = `cid123`
- Contract: 从每行 NDJSON 顶层读取 `conversation_id`、`operator`、`reaction_name`、`reaction_text`、`operation_type`
+1 -1
View File
@@ -95,6 +95,7 @@ func TestEventSkillUsesFlatOutputContract(t *testing.T) {
text := string(content)
for _, required := range []string{
"[event] ready",
"--flatten",
"conversation_id",
"sender_open_dingtalk_id",
"reader_open_dingtalk_id",
@@ -109,7 +110,6 @@ func TestEventSkillUsesFlatOutputContract(t *testing.T) {
}
}
for _, retired := range []string{
".data | fromjson",
"payload.body.",
"尚无稳定业务样本",
"暂无稳定 payload schema",