Compare commits
132
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1920552ab0 | ||
|
|
f1b5330a4e | ||
|
|
f9ccff963f | ||
|
|
5cbc11d58f | ||
|
|
a0a995cfee | ||
|
|
354c4546d8 | ||
|
|
3ab22071fb | ||
|
|
a34f46794e | ||
|
|
f0b0bdbe48 | ||
|
|
1fe019994d | ||
|
|
85cb41423b | ||
|
|
02ccd9d134 | ||
|
|
c280b19568 | ||
|
|
bb5065410e | ||
|
|
0353215b1d | ||
|
|
3c7ed03bd6 | ||
|
|
a1f8ecb7f0 | ||
|
|
a34ca5a137 | ||
|
|
7887b9473f | ||
|
|
fd3c82aa91 | ||
|
|
9266632694 | ||
|
|
b0cbcd7a04 | ||
|
|
6251117bd0 | ||
|
|
566803c431 | ||
|
|
d6848da60b | ||
|
|
5f5d7ee21e | ||
|
|
53169a41af | ||
|
|
e90d5bac68 | ||
|
|
d725bdbaa3 | ||
|
|
9aea8c0b5c | ||
|
|
f54b964d62 | ||
|
|
11fbeb4851 | ||
|
|
2b1f38edae | ||
|
|
2f3797c1f6 | ||
|
|
a0b0d98180 | ||
|
|
990c85d36b | ||
|
|
b742343937 | ||
|
|
657df05d40 | ||
|
|
86f2b14449 | ||
|
|
86014c97cf | ||
|
|
94f3bba504 | ||
|
|
339eaa4b1b | ||
|
|
96774e6e23 | ||
|
|
1f77ba31f3 | ||
|
|
eb0bd69b82 | ||
|
|
c1d90672a8 | ||
|
|
3d2d287723 | ||
|
|
969292a8d7 | ||
|
|
4bcf71fb9e | ||
|
|
ac610f2d24 | ||
|
|
98f45cfe23 | ||
|
|
fbf97ce402 | ||
|
|
f5b029b1a5 | ||
|
|
75f92cf546 | ||
|
|
6d485f47eb | ||
|
|
6651a162c5 | ||
|
|
a9aa39c3e2 | ||
|
|
545ee17316 | ||
|
|
0c62938f74 | ||
|
|
b0cd419f28 | ||
|
|
aa487002b7 | ||
|
|
941bf01e30 | ||
|
|
b439c5fa09 | ||
|
|
67250a9da5 | ||
|
|
70243acb95 | ||
|
|
6cce7fdbd8 | ||
|
|
45b43e52bb | ||
|
|
95a5cc42ce | ||
|
|
fec750b09e | ||
|
|
ddd5f15b91 | ||
|
|
db50be868b | ||
|
|
a6220d7d8b | ||
|
|
81bf0d2a6b | ||
|
|
f3a95d34a3 | ||
|
|
2ea5acc2a3 | ||
|
|
5e1bac51e5 | ||
|
|
a37e6e6847 | ||
|
|
0ceb96c745 | ||
|
|
114503d52f | ||
|
|
9de1c9c304 | ||
|
|
840e1d665f | ||
|
|
f362c8c2a4 | ||
|
|
e73a1556ce | ||
|
|
186f2fa474 | ||
|
|
d91a93c43b | ||
|
|
08254e2a36 | ||
|
|
e2c15fe9c8 | ||
|
|
9fdf0d2cb3 | ||
|
|
b6325a4f8a | ||
|
|
fdd9e189d6 | ||
|
|
65ce71b8d4 | ||
|
|
6bfcac4d54 | ||
|
|
f819566c63 | ||
|
|
10d9aa3058 | ||
|
|
eebdf52da9 | ||
|
|
a0ca1fdb28 | ||
|
|
9eaee76a51 | ||
|
|
2588c711a7 | ||
|
|
5a93f80daa | ||
|
|
8260cf7f53 | ||
|
|
9fd38d9b9d | ||
|
|
58dfbc5b6e | ||
|
|
01782cd9d7 | ||
|
|
ad4ed41559 | ||
|
|
86f9054d5b | ||
|
|
d7c28bcfef | ||
|
|
287b079c18 | ||
|
|
c4d5139a50 | ||
|
|
867f20abec | ||
|
|
7c07b29de5 | ||
|
|
6ee0df8a9c | ||
|
|
33ceab6000 | ||
|
|
26b06fe0ff | ||
|
|
50f8ade1d7 | ||
|
|
b87cad1eb5 | ||
|
|
0f2eec145e | ||
|
|
7a5582f4f9 | ||
|
|
f050fbdebc | ||
|
|
e27dc9fe53 | ||
|
|
85587b9b62 | ||
|
|
10d93f310e | ||
|
|
99478c0060 | ||
|
|
64c2e8544c | ||
|
|
fc31fddd73 | ||
|
|
4298d0833b | ||
|
|
ee943d9b3f | ||
|
|
7e0957d9e8 | ||
|
|
a3c85a01a8 | ||
|
|
81f67c8d7b | ||
|
|
30314311e4 | ||
|
|
2a8c6c87cb | ||
|
|
017258e5b4 |
@@ -152,7 +152,6 @@ jobs:
|
||||
filename.startsWith('internal/interfacesnapshot/') ||
|
||||
filename.startsWith('internal/app/upgrade') ||
|
||||
filename.startsWith('internal/transport/') ||
|
||||
filename.startsWith('internal/recovery/') ||
|
||||
filename.startsWith('internal/syncdata/') ||
|
||||
filename.includes('/testdata/') ||
|
||||
filename.startsWith('testdata/') ||
|
||||
@@ -817,6 +816,7 @@ jobs:
|
||||
run: ./scripts/policy/run-platform-coverage-gate.sh --base-ref "$COVERAGE_BASE_REF" --profile coverage-windows.txt
|
||||
|
||||
- name: Upload Windows coverage artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-windows
|
||||
@@ -1175,7 +1175,7 @@ jobs:
|
||||
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
|
||||
COVERAGE_TARGET: "100"
|
||||
COVERAGE_ENFORCE_OVERALL: "false"
|
||||
COVERAGE_OVERALL_TOLERANCE: "0"
|
||||
COVERAGE_OVERALL_TOLERANCE: "0.1"
|
||||
run: |
|
||||
policy_profile=coverage-policy.txt
|
||||
if [ "$FULL_SUITE" != true ]; then
|
||||
|
||||
@@ -302,9 +302,8 @@ on the leaf:
|
||||
|
||||
```bash
|
||||
dws auth status # token_valid should be true
|
||||
dws cache refresh # deprecated no-op: prints a retirement notice (discovery cache is gone; refreshes nothing)
|
||||
dws schema <mcp-canonical> -f json
|
||||
# or CLI path: dws schema --cli-path "drive copy" -f json
|
||||
dws schema <mcp-canonical> --jq '{canonical_path,interface_ref,parameters}' -f json
|
||||
# or CLI path: dws schema --cli-path "drive copy" --jq '{canonical_path,interface_ref,parameters}' -f json
|
||||
```
|
||||
|
||||
Resolve MCP identity via declared `interface_ref` when CLI canonical ≠ MCP path
|
||||
@@ -321,8 +320,10 @@ Skill (evidence only)**.
|
||||
|
||||
Split work by product groups. Each agent must:
|
||||
|
||||
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live `dws schema`
|
||||
for its tools.
|
||||
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live
|
||||
`dws schema <leaf> --compact` for its tools. Mapping/interface/provenance
|
||||
audits may query the full leaf only through a narrow `--jq` / `--fields`
|
||||
projection; do not load an entire full leaf into Agent context.
|
||||
- Hand-write selection prose and leaf Contract / ProductDecl declarations;
|
||||
forbid wholesale JSON merges from review dumps.
|
||||
- Edit only its product’s leaf declarations (and `ProductDecl` when needed).
|
||||
@@ -471,13 +472,15 @@ path; a generator unit test or JSON count alone is insufficient.
|
||||
`parameters` object for commands without flags. Keep it suitable for the #602
|
||||
compatibility baseline and fail rather than silently emitting a partial
|
||||
export.
|
||||
- `schema --all` is not normal command discovery. Use overview -> product/group
|
||||
-> leaf for routine Agent work. `--compact` is supported for context-saving
|
||||
projections, but a compact full export is not a complete compatibility
|
||||
baseline.
|
||||
- `schema --all` is not normal command discovery. Use overview -> compact
|
||||
product/group -> compact leaf for routine Agent work. `--compact` is the
|
||||
reviewed positive-field allowlist for Agent context: new full/audit fields
|
||||
must not appear there until explicitly reviewed. A compact full export is not
|
||||
a complete compatibility baseline.
|
||||
- `dws <path> --help` defines whether Cobra exposes a path and which flags the
|
||||
executable accepts. A leaf Schema defines Agent selection, parameter mapping
|
||||
and constraints, and safety/confirmation semantics. A conflict is contract
|
||||
drift, not permission to guess.
|
||||
executable accepts. A compact leaf defines Agent selection, CLI parameters,
|
||||
constraints, and safety/confirmation semantics. Full leaf fields such as
|
||||
`property`, `interface_ref`, and provenance are audit facts. A conflict is
|
||||
contract drift, not permission to guess.
|
||||
- Schema and Help describe commands; neither returns DingTalk business data.
|
||||
After discovery, execute the real read/search/list command to obtain data.
|
||||
|
||||
+122
@@ -6,8 +6,130 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.57-beta.4] - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Expanded open CLI workflows** (#887) — adds calendar event-instance
|
||||
queries, Drive latest-file selection, Markdown diff, Mail calendar/export/
|
||||
share-to-chat workflows, and Minutes hot-word, permission, and audio-memo
|
||||
operations, with matching Schema and cross-platform coverage.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Multi-skill framework alignment** (#887) — folds long-tail skills into
|
||||
`dingtalk-misc`, renames the shared package to `dingtalk-shared`, removes
|
||||
stale Preview guidance, and reorganizes shared recipes and routing for more
|
||||
predictable Agent selection.
|
||||
- **Bounded Agent Schema delivery** (#887) — keeps compact and wire projections
|
||||
focused on executable contract facts, retires stale MCP metadata candidates,
|
||||
and teaches Agents to prefer `dws schema --compact` for bounded context.
|
||||
|
||||
### Deprecated
|
||||
|
||||
- **Recovery and discovery-cache compatibility surfaces** (#887) — keeps
|
||||
visible Deprecated `dws recovery` and `dws cache` compatibility stubs while
|
||||
retiring their former recovery engine and dynamic discovery-cache behavior.
|
||||
Recovery plan/execute/finalize now return an explicit “不再支持” notice, and
|
||||
Skills no longer teach either retired workflow.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Mail share-to-chat confirmation** (#887) — requires explicit confirmation
|
||||
before the first remote write, while preserving the confirmed sign-retry
|
||||
flow and covering both direct-success and retry responses.
|
||||
|
||||
## [1.0.57] - 2026-08-06
|
||||
|
||||
This stable release promotes the fully delivered `v1.0.57-beta.4` baseline.
|
||||
It includes the v1.0.57 beta-line command-contract, document, chat, OA, Wiki,
|
||||
and compatibility improvements, plus the multi-skill framework alignment and
|
||||
expanded calendar, Drive, Markdown, Mail, and Minutes workflows validated in
|
||||
the final prerelease.
|
||||
|
||||
- **Promote v1.0.57-beta.4** — publishes the final validated prerelease
|
||||
baseline as stable `v1.0.57` without adding post-beta product changes.
|
||||
|
||||
## [1.0.57-beta.3] - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Reviewed document shortcuts** (#880) — adds public document shortcuts for
|
||||
safe local downloads, content and history, review, media and style, and
|
||||
document access/sharing workflows, while retaining reviewed compatibility
|
||||
identities and confirmation safeguards for writes.
|
||||
- **Mentions in chat replies** (#881) — `chat message reply` now supports
|
||||
`--at-open-dingtalk-ids` and `--at-all`, forwarding reply mention fields and
|
||||
adding any required mention placeholders without changing existing send
|
||||
behavior.
|
||||
|
||||
## [1.0.57-beta.2] - 2026-08-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Stable Chat command compatibility** (#876) — restores the hidden migration
|
||||
entries for `chat send`, `chat history`, and their `im` aliases, preserving
|
||||
the v1.0.56 command surface while directing callers to the supported
|
||||
`chat message send/list` commands. Legacy flags now reach the same migration
|
||||
hints instead of failing during flag parsing.
|
||||
- **Drive download cancellation-test stability** (#876) — replaces a
|
||||
timing-sensitive worker-cancellation coverage test with a deterministic seam,
|
||||
reducing flaky CI without changing download behavior.
|
||||
|
||||
## [1.0.57-beta.1] - 2026-08-05
|
||||
|
||||
This beta starts the v1.0.57 line on top of v1.0.56. It packages the unified
|
||||
command-contract and runtime Schema architecture, complete Multi IM Chat
|
||||
coverage, document whiteboard and OA approval workflows, Wiki activity feeds,
|
||||
and compatibility and CI reliability fixes.
|
||||
|
||||
### Added
|
||||
|
||||
- **Contact personal-status updates** (#872) — adds `contact user update-ownness`
|
||||
(alias `set-ownness`) for updating a user's personal status text. The write
|
||||
operation maps reviewed `userId` and `ownnessText` parameters to the service
|
||||
contract and requires confirmation unless `--yes` is explicitly supplied.
|
||||
- **Document whiteboard workflows** (#861) — adds `doc whiteboard insert`,
|
||||
`whiteboard query/update`, and `doc media upload`. These commands support
|
||||
confirmed document-embedded whiteboard creation and updates, structured
|
||||
OpenNodes reads, and preparation of node-bound Vector/SVG resources.
|
||||
- **Complete Multi IM Chat coverage** (#860) — hardens deterministic group and
|
||||
stable-ID resolution, sending, querying, downloading, pagination, and JSON
|
||||
export. The remaining reviewed Chat Shortcuts enter Schema coverage, with
|
||||
destructive delete and clear operations aligned to confirmation gates.
|
||||
- **OA approval form workflows** (#853) — adds OA form-schema lookup,
|
||||
process forecast, and confirmed approval-instance creation, supporting both
|
||||
simple flags and complete `--request` payloads.
|
||||
- **Wiki activity-feed queries** (#862) — adds `wiki feed list` to retrieve
|
||||
workspace document activity, with cursor paging and optional file exclusion.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Unified command and Schema contract framework** (#830) — Leaf commands and
|
||||
Shortcuts now use the shared typed `corecmd` base for flags, constraints,
|
||||
confirmation, Help, and runtime Schema projection. Schema delivery assembles
|
||||
from leaf Contract declarations at runtime; the retired hint overlays,
|
||||
pinned MCP metadata, and committed Catalog artifacts are no longer delivery
|
||||
authorities.
|
||||
- **Faster macOS CI without reducing native coverage** (#857) — narrows the
|
||||
macOS race suite to Keychain, codesign, and Darwin-only tests while adding a
|
||||
reachability contract that prevents native-only tests from being silently
|
||||
excluded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Chat media-download JSON compatibility** (#854) — restores parseable
|
||||
`success`, `downloadUrl`, and `output` fields for
|
||||
`chat message download-media --format json` after a successful download,
|
||||
without progress output corrupting JSON stdout.
|
||||
|
||||
### Added
|
||||
|
||||
- **Document-embedded whiteboard workflows** — adds `doc whiteboard insert` for confirmed creation and part-ID verification, `whiteboard query/update` for structured OpenNodes reads and confirmed writes, and `doc media upload` for preparing node-bound Vector/SVG resources. The public adapter uses an explicit helper-only whiteboard endpoint, validates update envelopes locally, decodes `resultJson`, and publishes the full command, Schema, Skill, and safety contract migrated from `dws-wukong@e2da8ab947c6`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat reply mentions** — `dws chat message reply` can @ specified group members with `--at-open-dingtalk-ids` or @ everyone with `--at-all`, forwarding the existing `send_personal_message` mention fields and automatically adding missing current-user `<@id>` / `<@all>` placeholders.
|
||||
- **Pinned MCP metadata retired** — deletes `internal/cli/schema_mcp_metadata.json` and removes its embed/loader/fallback role from Schema assembly. Catalog now assembles from Contract/ParamDecl/Interface + Cobra only; `make fetch-mcp-metadata` remains an optional diagnostic dump under `artifacts/` and refuses the retired pin path. Policy bans the pin from reappearing.
|
||||
- **MCP service review retired** — deletes `schema_mcp_service_review.json` and removes its policy jq / outputguard / test disposition gate (`notify` → `out_of_surface`, snapshot hash pin). No replacement ledger.
|
||||
- **Hints retired; ContractDecl is the leaf Schema source** (#830) — `schema_hints/`, Manual/Schema hint overlays, and `schema_agent_metadata/` delivery are removed. Selection, safety, parameters, and interface facts declare on ProductDecl / leaf `Contract` (`corecmd.ContractDecl` + `contract.ParamDecl` / `Safety`). Authoring renamed `SchemaDecl` → `ContractDecl`; nested fields reuse `contract.*` directly.
|
||||
|
||||
@@ -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.56-beta.4"
|
||||
version "1.0.57-beta.3"
|
||||
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.56-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "f1f9b6394137edbd0b08d632aab34e92a0f3f81d80107a47de1bec9b384f0515"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-darwin-arm64.tar.gz"
|
||||
sha256 "b1ea300a76654751ea33540d8a244b0c81b5df3947786980f95a5a19362a097a"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "cd3c64d20723c420e2490405d0bf8eecfd7e2b8fc352f63f23de5847a1d38f55"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-darwin-amd64.tar.gz"
|
||||
sha256 "68b5f6e38bec994fa4db4bef5db1629c7bce799bb9837c447008c3325fc886e3"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "910918d88074534e680a2e320d3cb364ad092e96b9c422f9e75d11c9c0815dd8"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-linux-arm64.tar.gz"
|
||||
sha256 "350e74f1a2611975e476e113e50264975a98185c11ee889a83d9480c0f10181b"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "172fe0d84443be953d0c6f2c2433540e4b972fbe7776cff1417ec9c73723552b"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-linux-amd64.tar.gz"
|
||||
sha256 "2c27a9a884650a7a60545d9447f1b966667216e94c333ebf9d69f9b2ead96e04"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-skills.zip"
|
||||
sha256 "a3457befe858cbf3fe85848428b630bfd3a5f626256ed6b49415267948915152"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-skills.zip"
|
||||
sha256 "abaa8feaa3c61fff048cfd1139e1fc5b91c329eb666d2eb852797a3f5c4c0cac"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -10,7 +10,7 @@ SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob
|
||||
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
|
||||
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
|
||||
|
||||
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
|
||||
all: setup-hooks fmt lint build test rebuild
|
||||
|
||||
@@ -33,6 +33,7 @@ help:
|
||||
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
|
||||
@printf " make skill-command-integrity - Check dws commands referenced by skills exist\n"
|
||||
@printf " make skill-context-budget - Check generated Skill drift and common-path context budgets\n"
|
||||
@printf " make multi-im-skill-chain-integrity - Check reviewed IM intents keep one default Skill route\n"
|
||||
@printf " make cli-smoke - Verify help for every public top-level command\n"
|
||||
@printf " make mock-mcp-smoke - Verify HTTP and stdio MCP request/response transport\n"
|
||||
@printf " make test-schema-agent-examples - Contract-check all Agent examples and dry-run the eligible subset\n"
|
||||
@@ -87,6 +88,7 @@ policy: test-auth-legacy-compat
|
||||
@mkdir -p "$(POLICY_GOTMPDIR)"
|
||||
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-multi-im-skill-chain.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict
|
||||
@$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-param-concepts.sh
|
||||
@@ -126,6 +128,12 @@ skill-command-integrity:
|
||||
skill-context-budget:
|
||||
@./scripts/policy/check-skill-context-budget.sh
|
||||
|
||||
multi-im-skill-chain-integrity:
|
||||
@./scripts/policy/check-multi-im-skill-chain.sh
|
||||
|
||||
skill-mono-multi-content:
|
||||
@./scripts/policy/check-mono-multi-skill-content.sh
|
||||
|
||||
cli-smoke:
|
||||
@./scripts/policy/check-cli-smoke.sh
|
||||
|
||||
|
||||
@@ -71,9 +71,7 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
|
||||
| Mode | What gets installed | Best for |
|
||||
|------|----------------------|----------|
|
||||
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
| **multi** 🧪 **EXPERIMENTAL** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
|
||||
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** All product-scoped skills pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
|
||||
| **multi** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
|
||||
How to pick:
|
||||
|
||||
@@ -371,7 +369,7 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
|
||||
Use Cobra help and Schema for different parts of the command contract:
|
||||
|
||||
- `dws <path> --help` is the source of truth for whether a command exists and which flags the binary accepts.
|
||||
- `dws schema "<path>"` is the Agent contract for command selection, parameter mappings and constraints, risk, and confirmation semantics.
|
||||
- `dws schema "<path>" --compact` is the normative Agent view for command selection, CLI parameters and constraints, risk, and confirmation; use a full leaf with a narrow `--jq` projection for mapping or provenance audits.
|
||||
- If Help and Schema disagree, treat it as contract drift: pass only flags accepted by Cobra and use the more conservative safety semantics.
|
||||
- Schema describes commands; it does not read or search DingTalk business data. Execute the real product command after discovery.
|
||||
|
||||
@@ -380,21 +378,21 @@ Use Cobra help and Schema for different parts of the command contract:
|
||||
dws aitable record query --help
|
||||
|
||||
# Discover within a product, then inspect the selected leaf contract
|
||||
dws schema aitable
|
||||
dws schema "aitable record query"
|
||||
dws schema aitable --compact
|
||||
dws schema "aitable record query" --compact
|
||||
|
||||
# Execute the real business query
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
```
|
||||
|
||||
`dws schema --all` exports the complete contract for tooling, CI, audits, and compatibility baselines. Agents should prefer product/group discovery followed by a leaf query to avoid loading the full Catalog into context.
|
||||
`dws schema --all` exports the complete contract for tooling, CI, audits, and compatibility baselines. Agents should query progressively with `--compact`; its positive field allowlist prevents new full/audit fields from silently expanding Agent context.
|
||||
|
||||
### Agent Skills
|
||||
|
||||
The repo ships a complete Agent Skill system under `skills/`, organized into two layouts:
|
||||
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`.
|
||||
|
||||
Leaf safety/parameters/selection prose for Schema generation come from ProductDecl / ContractFinal declarations in Go. The former `internal/cli/schema_hints/` HintFile tree is fully retired and must not reappear.
|
||||
|
||||
@@ -443,7 +441,6 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
|
||||
| Intent guide | `skills/mono/references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) |
|
||||
| Global reference | `skills/mono/references/global-reference.md` | Auth, output formats, global flags |
|
||||
| Error codes | `skills/mono/references/error-codes.md` | Error codes + debugging workflows |
|
||||
| Recovery guide | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling |
|
||||
| Ready-made scripts | `skills/mono/scripts/*.py` | 13 batch operation scripts (see below) |
|
||||
|
||||
<details>
|
||||
@@ -539,7 +536,7 @@ For one-to-one and specified-sender events, use exactly one target identity: `--
|
||||
| Observability | `status` shows remote subscriptions, the personal bus, and local consumers |
|
||||
| Cross-platform | Unix Socket on macOS/Linux, Windows Named Pipe on Windows |
|
||||
|
||||
See `skills/multi/dingtalk-event/SKILL.md` for the Agent workflow and supported event parameters.
|
||||
See `skills/multi/dingtalk-misc/references/event.md` for the Agent workflow and supported event parameters.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -624,7 +621,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
|
||||
```bash
|
||||
# Built-in jq expressions
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
|
||||
dws schema "dev app create" --jq '.tool.required'
|
||||
dws schema "dev app create" --jq '.parameters'
|
||||
|
||||
# Return only specific fields
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
|
||||
@@ -636,9 +633,9 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
|
||||
<summary><strong>Schema Introspection</strong> — Agent command discovery and execution contracts</summary>
|
||||
|
||||
```bash
|
||||
dws schema aitable # discover product commands
|
||||
dws schema "aitable record query" # view the selected leaf contract
|
||||
dws schema "aitable record query" --jq '.tool.required' # view required fields
|
||||
dws schema aitable --compact # discover product commands
|
||||
dws schema "aitable record query" --compact # view the selected Agent leaf contract
|
||||
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # view required fields
|
||||
dws schema --all # full export for CI/audit/baselines
|
||||
```
|
||||
|
||||
|
||||
+11
-14
@@ -71,9 +71,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
| 模式 | 安装内容 | 适合场景 |
|
||||
|------|----------|----------|
|
||||
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
| **multi** 🧪 **试验版 / Preview** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。全部独立 skill 均通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
| **multi** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
|
||||
怎么选:
|
||||
|
||||
@@ -365,7 +363,7 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
|
||||
命令帮助和 Schema 分别负责命令契约的不同部分:
|
||||
|
||||
- `dws <path> --help` 是命令是否存在、当前二进制接受哪些 flags 的事实源。
|
||||
- `dws schema "<path>"` 是 Agent 选命令、参数映射与约束、风险和确认语义的契约。
|
||||
- `dws schema "<path>" --compact` 是 Agent 选命令、CLI 参数与约束、风险和确认语义的规范视图;映射或 provenance 审计使用 full leaf 配合 `--jq` 精确投影。
|
||||
- Help 与 Schema 冲突时视为契约漂移:执行只传 Cobra 接受的参数,安全语义取更保守值。
|
||||
- Schema 只描述命令,不读取或搜索钉钉业务数据;发现命令后仍需执行真实产品命令。
|
||||
|
||||
@@ -374,21 +372,21 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
|
||||
dws aitable record query --help
|
||||
|
||||
# 先在产品内发现命令,再查看选中 leaf 的契约
|
||||
dws schema aitable
|
||||
dws schema "aitable record query"
|
||||
dws schema aitable --compact
|
||||
dws schema "aitable record query" --compact
|
||||
|
||||
# 执行真实业务查询
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
```
|
||||
|
||||
`dws schema --all` 会完整导出命令契约,供工具、CI、审计和兼容性基线使用。Agent 应优先按产品/分组发现后查询 leaf,避免把整个 Catalog 加载进上下文。
|
||||
`dws schema --all` 会完整导出命令契约,供工具、CI、审计和兼容性基线使用。Agent 应使用 `--compact` 渐进查询;该视图采用正向字段白名单,full 新增的审计字段不会自动进入 Agent 上下文。
|
||||
|
||||
### Agent Skills
|
||||
|
||||
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
|
||||
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。
|
||||
|
||||
Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / ContractFinal 声明驱动。原 `internal/cli/schema_hints/` HintFile 目录已完全退役,不得重新引入。
|
||||
|
||||
@@ -437,7 +435,6 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
| 意图指南 | `skills/mono/references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
|
||||
| 全局参考 | `skills/mono/references/global-reference.md` | 认证、输出格式、全局 flag |
|
||||
| 错误码 | `skills/mono/references/error-codes.md` | 错误码 + 调试流程 |
|
||||
| Recovery 指南 | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 |
|
||||
| 现成脚本 | `skills/mono/scripts/*.py` | 13 个批量操作脚本(见下方) |
|
||||
|
||||
<details>
|
||||
@@ -533,7 +530,7 @@ dws event stop <subscribe_id>
|
||||
| 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers |
|
||||
| 跨平台 | macOS/Linux 使用 Unix Socket,Windows 使用 Named Pipe |
|
||||
|
||||
Agent 工作流和事件参数详见 `skills/multi/dingtalk-event/SKILL.md`。
|
||||
Agent 工作流和事件参数详见 `skills/multi/dingtalk-misc/references/event.md`。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -618,7 +615,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
|
||||
```bash
|
||||
# 内置 jq 表达式
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
|
||||
dws schema "dev app create" --jq '.tool.required'
|
||||
dws schema "dev app create" --jq '.parameters'
|
||||
|
||||
# 只返回指定字段
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
|
||||
@@ -630,9 +627,9 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
|
||||
<summary><strong>Schema 自省</strong> — Agent 命令发现与执行契约</summary>
|
||||
|
||||
```bash
|
||||
dws schema aitable # 发现产品命令
|
||||
dws schema "aitable record query" # 查看选中 leaf 契约
|
||||
dws schema "aitable record query" --jq '.tool.required' # 查看必填字段
|
||||
dws schema aitable --compact # 发现产品命令
|
||||
dws schema "aitable record query" --compact # 查看 Agent leaf 契约
|
||||
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # 定向查看必填字段
|
||||
dws schema --all # CI/审计/基线的全量导出
|
||||
```
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
- `internal/output`: response formatting (json, table, raw, pretty)
|
||||
- `internal/logging`: structured logging and argument sanitization
|
||||
- `internal/tui`: terminal UI helpers
|
||||
- `internal/recovery`: panic recovery and graceful degradation
|
||||
- `pkg/configmeta`: environment variable registry and documentation
|
||||
- `pkg/config`: configuration constants and paths
|
||||
- `pkg/edition`: edition detection (oss vs enterprise)
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ repository root while preserving repo-local guidance for automation.
|
||||
- Error message or category issues: inspect `internal/errors`
|
||||
- Audit log issues: inspect `internal/audit`
|
||||
- Plugin loading or command surface: inspect `internal/plugin`
|
||||
- Failure or degraded mode: inspect `internal/errors`, `internal/recovery`
|
||||
- Failure or degraded mode: inspect `internal/errors`
|
||||
|
||||
## Policy Checks
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# dws dev 命令集 · Agent 人肉手工评测集(10 条复合用例)
|
||||
|
||||
> 性质:**人肉手工评测集**——由测评人逐条手工跑、肉眼核对、人工判分,不是自动化脚本。
|
||||
> 用途:评测 agent(加载 `dingtalk-dev` 技能后)能否正确处理开放平台 dev 任务。
|
||||
> 用途:评测 agent(加载 `dingtalk-misc` 的 `references/devapp.md` 后)能否正确处理开放平台 dev 任务。
|
||||
> 特点:10 条**复合用例**,每条串多个子任务,一条覆盖一类完整场景;10 条合起来覆盖全部 34 个子命令 + 8 类横切行为。
|
||||
> 约定:所有命令应带 `--format json`;写操作应先 `--dry-run` 预览、用户确认后再 `--yes`;应用定位只用 `--unified-app-id`。
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 一键安装
|
||||
|
||||
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-dev` skill,**只需要 curl + tar,不需要 git / go / make**。
|
||||
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-misc` skill(开放平台应用文档落在 misc),**只需要 curl + tar,不需要 git / go / make**。
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
@@ -24,7 +24,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
|
||||
1. 从 `DingTalk-Real-AI/dingtalk-workspace-cli` 的最新 Release 下载对应平台的预编译二进制。
|
||||
2. 安装 `dws` 到默认目录 `~/.local/bin`。
|
||||
3. 从 Release 的 skills 包里安装 `dingtalk-dev` skill 到本机已检测到的 Agent 目录。
|
||||
3. 从 Release 的 skills 包里安装 `dingtalk-misc` skill 到本机已检测到的 Agent 目录。
|
||||
|
||||
支持这些环境变量(全部可选):
|
||||
|
||||
@@ -33,7 +33,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
| `DEVAPP_REPO` | 覆盖发布仓库,默认 `DingTalk-Real-AI/dingtalk-workspace-cli` |
|
||||
| `DEVAPP_VERSION` | 钉某个 release tag,默认取最新 release |
|
||||
| `DWS_INSTALL_DIR` | 二进制安装目录,默认 `~/.local/bin` |
|
||||
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-dev` skill 安装 |
|
||||
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-misc` skill 安装 |
|
||||
|
||||
> `dws dev` 已在正式版里,所以你也可以直接用标准安装脚本 `install.sh`,二者都会带上 `dws dev`。
|
||||
|
||||
|
||||
+11
-10
@@ -94,7 +94,7 @@ With `-f json`, error responses include structured payloads: `category`, `reason
|
||||
dws contact user search --query "Alice" -f table # Table (default, human-friendly / 表格,默认)
|
||||
dws contact user search --query "Alice" -f json # JSON (for agents and piping / 适合 agent)
|
||||
dws contact user search --query "Alice" -f raw # Raw API response / 原始响应
|
||||
dws schema -f pretty "calendar event create" # Pretty Agent schema view / Agent Schema 彩色查看
|
||||
dws schema -f pretty "calendar event create" --compact # Pretty Agent schema view / Agent Schema 彩色查看
|
||||
```
|
||||
|
||||
## Dry Run / 试运行
|
||||
@@ -119,27 +119,28 @@ Schema 的稳定 `canonical_path`、主 CLI 路径和 aliases 收集自命令树
|
||||
|
||||
```bash
|
||||
dws schema # 当前公开产品面的紧凑概览
|
||||
dws schema calendar # 展开一个产品
|
||||
dws schema "calendar event" # 展开一个命令分组
|
||||
dws schema "calendar event create" # 按 CLI 空格路径查询工具
|
||||
dws schema calendar.create_calendar_event # 按 canonical path 查询工具
|
||||
dws schema --cli-path "calendar event create" # 显式 CLI path
|
||||
dws schema "calendar event create" --compact # 支持:省略 provenance/debug 字段
|
||||
dws schema calendar --compact # Agent 产品视图
|
||||
dws schema "calendar event" --compact # Agent 分组视图
|
||||
dws schema "calendar event create" --compact # Agent leaf(CLI 空格路径)
|
||||
dws schema calendar.create_calendar_event --compact # Agent leaf(canonical path)
|
||||
dws schema --cli-path "calendar event create" --compact # Agent leaf(显式 CLI path)
|
||||
dws schema "calendar event create" # full leaf,仅用于映射/provenance 审计
|
||||
dws schema --all # 全部工具的完整 leaf Schema,用于审计/CI/baseline
|
||||
```
|
||||
|
||||
兼容入口 `dws schema list` 等价于根概览。`schema --all` 是完整导出:每个工具都包含完整 leaf 参数、约束和安全语义。它输出很大,只用于明确要求的全量导出、审计、CI 或参数 baseline;普通 Agent 任务应按概览、产品/分组、leaf 渐进查询,不要把 `--all` 直接注入上下文。`schema --all --compact` 虽受支持,但会裁掉 provenance 和接口映射字段,不能作为完整 baseline。
|
||||
|
||||
Leaf 查询、`--all` 中对应工具和 Catalog full tool 均由同一个 resolved `ToolSpec` 投影,内容必须一致;概览、产品/分组和 Catalog summary 也由该 `ToolSpec` 的统一 summary 投影生成。通过 alias 查询时,只允许 `cli_path` 和 `is_alias` 发生视图变化,参数、安全和接口契约不得变化。
|
||||
省略 `--compact` 的 full leaf、`--all` 中对应工具和 Catalog full tool 均由同一个 resolved `ToolSpec` 投影,内容必须一致;compact leaf 仅做字段白名单投影,不重新解析语义。概览、产品/分组和 Catalog summary 也来自同一 `ToolSpec`。通过 alias 查询时,只允许路径视图发生变化,参数、安全和接口契约不得变化。
|
||||
|
||||
`--compact` 是 Schema 的展示选项。当前版本支持该 flag;若兼容旧二进制时收到 `unknown_flag: --compact`,用同一个 Schema 查询去掉 `--compact` 重试。这只降低输出裁剪能力,不表示 leaf 不存在,也不能改用 Schema 查询业务数据。
|
||||
`--compact` 是 Schema 的稳定 Agent 字段白名单,也是普通 Agent 查询的规范选项。它保留 CLI 参数、组合约束、选择和安全语义,但有意省略 `interface_ref`、参数 `property/interface_type` 与 provenance。检查这些映射/审计字段时,使用 full leaf 并通过 `--jq` / `--fields` 精确投影。若兼容旧二进制时收到 `unknown_flag: --compact`,用同一个 Schema 查询去掉 `--compact` 重试;这只降低输出裁剪能力,不表示 leaf 缺失。
|
||||
|
||||
### Schema、Help 与业务数据的边界
|
||||
|
||||
| 问题 | 事实源 |
|
||||
|------|--------|
|
||||
| 命令是否由当前二进制暴露、Cobra 接受哪些 flags | `dws <path> --help` |
|
||||
| Agent 选哪个命令、参数映射与组合约束、risk/confirmation | 对应的 leaf `dws schema "<path>"` |
|
||||
| Agent 选哪个命令、CLI 参数与组合约束、risk/confirmation | 对应的 Agent leaf `dws schema "<path>" --compact` |
|
||||
| CLI↔RPC 参数映射、接口绑定与 provenance | full leaf 配合 `--jq` / `--fields` 精确投影 |
|
||||
| 当前钉钉中的文档、文件、日程、消息等业务数据 | 实际执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
|
||||
|
||||
Schema 与 Help 冲突表示发布契约漂移,不能静默猜测。执行参数必须以 Cobra 实际接受的 flag 为准;安全语义冲突时采用更保守的处理(例如先确认)或停止执行并报告漂移。完成命令发现后,仍必须执行真实业务命令;`dws schema` 本身不会读取或搜索业务内容。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 第一步:安装 dws
|
||||
|
||||
一键脚本会自动下载最新版二进制 + `dingtalk-dev` skill,只需要 curl(无需 go / git)。
|
||||
一键脚本会自动下载最新版二进制 + `dingtalk-misc` skill(开放平台应用文档落在 misc),只需要 curl(无需 go / git)。
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
|
||||
@@ -231,7 +231,8 @@ Cobra hard-required 是独立的 executable fact,并通过 `cli_required`/prov
|
||||
| 问题 | 事实源 |
|
||||
|---|---|
|
||||
| 当前二进制是否暴露命令、Cobra 接受哪些 flags | `dws <path> --help` |
|
||||
| Agent 选哪个命令、参数映射/required/约束、risk/confirmation | 对应 leaf `dws schema "<path>"` |
|
||||
| Agent 选哪个命令、CLI 参数/required/约束、risk/confirmation | Agent leaf `dws schema "<path>" --compact` |
|
||||
| CLI↔RPC 参数映射、接口绑定、provenance | full leaf 配合 `--jq` / `--fields` 精确投影 |
|
||||
| 钉钉中的文档、文件、日程、消息等实际数据 | 真正执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
|
||||
|
||||
Schema 和 Help 冲突是契约漂移,不能静默猜测:
|
||||
@@ -246,10 +247,10 @@ Schema 和 Help 冲突是契约漂移,不能静默猜测:
|
||||
|
||||
```bash
|
||||
dws schema # 产品紧凑概览
|
||||
dws schema calendar # 产品摘要
|
||||
dws schema "calendar event" # 分组摘要
|
||||
dws schema "calendar event create" # 完整 leaf
|
||||
dws schema "calendar event create" --compact # 支持:裁掉 provenance/debug 字段
|
||||
dws schema calendar --compact # Agent 产品摘要
|
||||
dws schema "calendar event" --compact # Agent 分组摘要
|
||||
dws schema "calendar event create" --compact # Agent leaf
|
||||
dws schema "calendar event create" # full leaf,仅用于映射/provenance 审计
|
||||
dws schema --all # 所有工具的完整 leaf 导出
|
||||
```
|
||||
|
||||
@@ -257,7 +258,7 @@ dws schema --all # 所有工具的完整 leaf 导
|
||||
|
||||
`schema --all` 必须包含最终 `SchemaIndex` 中每个 tool 的完整 leaf 参数、约束和安全语义;无业务参数的命令也要包含空 `parameters` 对象。它用于审计、CI 和参数防丢 baseline,但输出很大,普通 Agent 命令发现不得使用,应按 overview -> product/group -> leaf 渐进查询。
|
||||
|
||||
`--compact` 当前受支持,适合减少常规 leaf 查询上下文。`schema --all --compact` 也可执行,但会移除 provenance/debug 和接口映射字段,不能作为完整兼容性 baseline。
|
||||
`--compact` 是普通 Agent 查询的规范视图:通过正向字段白名单保留选参、约束与安全语义,full 新增字段不会自动进入 Agent 上下文。省略它的 leaf 包含参数 property、接口绑定和 provenance,只用于定向审计;`schema --all --compact` 也可执行,但不能作为完整兼容性 baseline。
|
||||
|
||||
兼容旧二进制时,如果 Schema 查询返回 `unknown_flag: --compact`,只去掉 `--compact` 重试同一个查询。这是展示能力降级,不代表 leaf 缺失,也不能改用 Schema 查询业务数据。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"generated_at": "2026-07-29T00:06:19.285348",
|
||||
"count": 265,
|
||||
"generated_at": "2026-08-05T22:43:52.497190",
|
||||
"count": 294,
|
||||
"results": [
|
||||
{
|
||||
"suite": "read",
|
||||
@@ -485,7 +485,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性。",
|
||||
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性;可选对资源去重后安全落盘并返回逐项失败 ledger。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -658,6 +658,16 @@
|
||||
"semantic_delta": "群邀请链接是一对一读取;Shortcut 未增加生命周期或分享编排。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "chat",
|
||||
"command": "+chat-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "对齐 lark-cli +chat-list:默认仅群聊,支持 --types group/p2p、--exclude-muted、page-size/page-token 别名,并投影 openConversationId/name/conversationType;不宣称 sort 或 bot 身份 p2p 剥离。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "chat",
|
||||
@@ -715,7 +725,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "统一群聊与两类单聊目标,输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文。",
|
||||
"semantic_delta": "统一群聊与两类单聊目标;省略时间时自动以当前时间向前读取最近消息,并输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文;可选对列表内资源去重后安全落盘并返回逐项失败 ledger。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1215,7 +1225,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 复用 HTTPS、相对路径、无覆盖和原子落盘防护,逐资源返回下载失败 ledger。",
|
||||
"semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 统一下载 mediaId 与 fileId,复用受信任下载域、相对路径、无覆盖和原子落盘防护,对重复资源去重并逐资源返回下载失败 ledger;安全本地下载沿用 read/not_required 契约,不产生非交互确认盲区。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1295,7 +1305,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "把临时资源 URL 解析、工作目录内安全路径、默认不覆盖、临时文件下载和原子发布封装为结构化单步结果。",
|
||||
"semantic_delta": "统一承接消息 mediaId 与钉盘 fileId:分别复用 IM 临时资源 URL 和 drive.download_file,只允许钉钉/OSS HTTPS 下载域且重定向复验并隔离跨域凭据,再通过工作目录内安全路径、默认不覆盖、临时文件下载和原子发布输出结构化结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1315,7 +1325,7 @@
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 文本/Markdown 发送;按身份校验目标与凭据,幂等键只在真实支持的 user 分支开放,媒体上传仍诚实留在 native leaf。",
|
||||
"semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 发送;current-user 支持文本、Markdown、mediaId 图片、安全相对路径本地文件上传、userId 姓名解析与幂等键,bot/webhook 仍只暴露下层真实支持的文本/Markdown 能力。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1344,8 +1354,8 @@
|
||||
"command": "+messages-send-card",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "创建流式卡片是一对一写入;完整卡片生命周期需由 send/update leaf 明确编排。",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "既可只创建流式卡片,也可在一次调用中创建、提取 bizId、写入内容并设置流式状态;dry-run 输出两步执行计划,更新失败时保留已创建的 bizId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1415,7 +1425,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;支持精确时间窗、page-all、50 条一组 mget 富化,并以 failure ledger 显式报告截断或富化失败。",
|
||||
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;展开下层按会话分组的 conversationMessagesList,支持精确时间窗、page-all、50 条一组 mget 富化,可选安全下载命中消息资源,并以 failure ledger 显式报告截断、富化或下载失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1435,7 +1445,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页。",
|
||||
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1708,123 +1718,454 @@
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+access-change",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "读取当前权限后再变更角色,避免把不存在的协作者当作成功更新。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+access-grant",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在第一次写入前解析全部接收人,再批量授予文档权限并输出逐项 ledger。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+access-revoke",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "预检目标协作者权限后移除并输出逐项结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+background-delete",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "以 clear 语义移除文档背景色。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+background-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "校验并设置 #RRGGBB 文档背景纯色。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+checkpoint-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "写入前保存版本快照,更新后读回验证并输出逐步 ledger。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+comment-create",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "无 selection 创建全文评论,有 selection 时定位文本并创建划词评论。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+comment-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "永久删除指定评论,并由静态安全契约强制确认。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+comment-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一评论类型、解决状态与分页过滤。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+comment-reply",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一评论回复、表情回复和 mention 参数。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+comment-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "更新指定评论正文与 mention。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+copy",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "旧 Doc 复制入口,仅为兼容保留;新的文件复制应使用 Drive 命令。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一 Markdown/JSONML 内容输入、目标位置与创建后保真写入。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+create-from-template",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "支持 templateId 直达或按名称搜索消歧后创建文档。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+doc-append",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史文档末尾追加命令及其稳定 Schema identity;新场景优先使用 +update。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+export",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "一体化提交、轮询导出任务并按 no-clobber 策略安全下载到本地。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+export-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "按 jobId 查询导出状态的恢复入口;常规场景使用一体化 +export。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+export-submit",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "导出中断恢复所需的专家入口;常规场景使用一体化 +export。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+fetch",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一 simple/with-ids/full 细节层级与 full/outline/range/section/keyword/tags 局部读取。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+find-doc",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史文档搜索命令及其稳定 Schema identity;新场景优先使用 +search。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+grant-and-share",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "先确保目标角色,再发送链接;消息失败保留逐人 ledger,并以非零退出报告 failed/partial_success。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+history-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一历史版本分页参数并返回可用于回滚的版本列表。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+history-revert",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "先验证目标版本存在,再执行回滚并读回当前文档状态。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+history-save",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "以文档历史语义命名手动版本快照,避免暴露底层 RPC 命名。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+import",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "一体化创建会话、上传、确认转换并轮询导入结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+inspect",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "聚合文档元信息,并按需读取样式、权限、历史、媒体和评论。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "旧 Doc 导航入口,仅为兼容保留;新的文件树导航应使用 Drive 命令。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+media-download",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "解析附件临时链接并通过受控相对路径、no-clobber、原子发布安全下载。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+media-insert",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "组合本地文件校验、上传凭证、OSS PUT、插块和验证。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+media-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "从文档块中提取图片、附件及其 block/resource 标识。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+media-preview",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "将正文媒体下载到受控临时目录并返回本地预览 artifact。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+move",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "旧 Doc 移动入口,仅为兼容保留;新的文件移动应使用 Drive 命令。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+resource-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "以幂等 clear 语义移除当前文档封面。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+resource-download",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "读取当前文档封面配置并安全下载资源到本地。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+resource-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "支持本地图片或 HTTPS 图片转存后设置文档封面。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+review",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "聚合未解决评论、划词引用和确定性上下文,不调用模型生成总结。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一关键词、最近访问、过滤、分页和稳定精简投影,作为文档定位的 canonical 入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+share",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名解析唯一用户后发送文档链接,不改变文档权限。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+share-doc",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史单人文档分享命令及其稳定 Schema identity;新场景优先使用 +share。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+template-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一 MY/PUBLIC 模板浏览和分页参数。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+template-search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按名称检索模板并返回可继续创建的 templateId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "统一追加、覆盖和 block 级精确修改,并集中处理内容输入、定位和确认。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+version-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史版本列表命令及其稳定 Schema identity;新场景优先使用 +history-list。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+version-revert",
|
||||
"risk": "high-risk-write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史版本回滚命令及其稳定 Schema identity;新场景优先使用 +history-revert。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+version-save",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史版本快照命令及其稳定 Schema identity;新场景优先使用 +history-save。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# DWS Skill 内容框架合同
|
||||
|
||||
> 本分支权威合同:`skills/mono` / `skills/multi` 的**内容组织**与 zip 内容树形状。
|
||||
> 不做安装/升级行为约定。质检见 [skill-mono-multi-qa.md](skill-mono-multi-qa.md)。
|
||||
> 对齐调研:[skill-wukong-align-plan.md](skill-wukong-align-plan.md)。
|
||||
|
||||
## 1. 两棵内容树
|
||||
|
||||
| 树 | 路径 | 角色 |
|
||||
|---|---|---|
|
||||
| **mono**(单 skill) | `skills/mono/` | 单一 `SKILL.md` 入口 + `references/products/*` 产品面 + 全局协议 |
|
||||
| **multi**(多 skill) | `skills/multi/` | 平铺 `dingtalk-*` 产品 skill + 必选 `dingtalk-shared` |
|
||||
|
||||
Agent / 安装面选哪棵树由**行为分支**决定;本文件只规定树内合同。
|
||||
|
||||
## 2. Multi 目录合同(如何新增一个产品 skill)
|
||||
|
||||
新建 `skills/multi/<name>/` 时必须满足:
|
||||
|
||||
1. **命名**
|
||||
- 产品 skill:`dingtalk-<product>`(小写、连字符)
|
||||
- 共享 skill:仅允许 `dingtalk-shared`
|
||||
2. **根文件**
|
||||
- 必有 `SKILL.md`(YAML frontmatter + 正文)
|
||||
- `references/` 推荐;无 reference 的 skill(如极简 profile)须在质检 omit 表登记
|
||||
- `scripts/` 可选;脚本须被本 skill 树内某 `.md` 引用,或进入 orphan allowlist
|
||||
3. **Frontmatter 最小集**(产品 / shared)
|
||||
- `name`:与目录名一致
|
||||
- `description`:非空,含触发意图与边界
|
||||
- `metadata.category`:`product` 或 `shared`(允许历史写法把 `cli_version` 放在 frontmatter 顶层)
|
||||
- `metadata.requires.bins`:含 `dws`
|
||||
4. **契约块**
|
||||
- 产品 skill 推荐内嵌 `<!-- DWS_RUNTIME_CONTRACT_START -->…END -->` **或** 明确 PREREQUISITE 指向 `dingtalk-shared`
|
||||
- `dingtalk-shared` 承载跨产品路由与全局协议落点
|
||||
5. **与 mono 映射**
|
||||
- 每个 mono `references/products/<stem>`(文件或目录)必须在
|
||||
`skills/content-qa/mono-multi-coverage.yaml` 有 `coverage` 或 `omit_coverage` 行
|
||||
|
||||
### 2.1 推荐骨架
|
||||
|
||||
```text
|
||||
skills/multi/dingtalk-example/
|
||||
├── SKILL.md
|
||||
├── references/
|
||||
│ ├── example.md # 主产品面
|
||||
│ └── … # 子章节 / 意图表
|
||||
└── scripts/ # 可选;须被 md 引用
|
||||
└── example_helper.py
|
||||
```
|
||||
|
||||
## 3. Mono 目录合同(质检对照基准)
|
||||
|
||||
```text
|
||||
skills/mono/
|
||||
├── SKILL.md
|
||||
├── references/
|
||||
│ ├── products/ # 覆盖质检主源
|
||||
│ ├── error-codes.md # 全局协议示例
|
||||
│ ├── error-codes.md
|
||||
│ └── …
|
||||
└── scripts/
|
||||
```
|
||||
|
||||
- `references/products/` 下每个顶层 stem(`.md` 去后缀或子目录名)计入覆盖索引。
|
||||
- 同 stem 的 `.md` + 子目录视为同一产品面(如 `doc.md` + `doc/`)。
|
||||
|
||||
## 4. 共享内容(`dingtalk-shared`)
|
||||
|
||||
| 职责 | 落点 |
|
||||
|---|---|
|
||||
| 跨产品路由 / 工作流 | `references/routing.md`、`workflow-routing.md`、`intent-guide.md` |
|
||||
| 运行时最小契约长文 | `references/runtime-contract.md`(受 context-budget 约束) |
|
||||
| 全局协议(确认门禁 / Schema 教学等) | `references/`;见质检基线 |
|
||||
| 与 mono 全局文同名迁移 | `error-codes`、`url-patterns`、`capability-limits`、`channel-login`、`global-reference`、`recipes/`(`conventions.md`、`meta.md`、`lite-catalog.md`) |
|
||||
|
||||
产品专属规则(如 AI 表格 `field-rules`)允许下沉到对应 `dingtalk-*`,须在覆盖表注明。
|
||||
|
||||
## 5. Zip 内容布局合同(形状,非安装默认)
|
||||
|
||||
发布物 `dws-skills.zip`(及 embed 同源)内容树形状:
|
||||
|
||||
| Zip 路径 | 含义 |
|
||||
|---|---|
|
||||
| `<root>/` | mono 内容副本(兼容旧面) |
|
||||
| `<root>/mono/` | 与 `skills/mono/` 同构 |
|
||||
| `<root>/multi/` | 与 `skills/multi/` 同构 |
|
||||
|
||||
质检可断言源树形状;**不**断言安装器默认解压哪棵。
|
||||
|
||||
## 6. 与悟空 `dingtalk-skills/` 对照(组织概念 only)
|
||||
|
||||
| 维度 | DWS `skills/multi` | 悟空 `dingtalk-skills/`(develop) |
|
||||
|---|---|---|
|
||||
| 布局 | flat `dingtalk-*` + `dingtalk-shared` | 同构 flat |
|
||||
| 集合 | 产品 skill + shared(含 event/profile/…;dev/skill 等长尾落在 misc) | 更小产品集(如 attendance/report 独立目录) |
|
||||
| 质检权威 | **mono 单 skill 树** | 不作为 DWS 覆盖基准 |
|
||||
| 不移植 | `_install.sh` / bundle / dual / Qwen overlay | — |
|
||||
|
||||
悟空独有命名(如 `dingtalk-attendance`)在 DWS 中由 `dingtalk-misc` 承接对应 mono `attendance*` / `report` / `oa` / `sheet` / `dev` 等面——见覆盖表。
|
||||
|
||||
## 7. 变更流程
|
||||
|
||||
1. 改 / 增内容 → 更新 `skills/content-qa/mono-multi-coverage.yaml`(coverage 或 omit)
|
||||
2. 跑 `make skill-mono-multi-content`(或 `make policy`)
|
||||
3. 失败则修内容或更新 reviewed omit(disposition + 原因),**禁止**用安装默认值绕过
|
||||
@@ -0,0 +1,65 @@
|
||||
# Mono↔Multi Skill 内容质检规格
|
||||
|
||||
> 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。
|
||||
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。
|
||||
> 执行:`make skill-mono-multi-content`(已挂入 `make policy`)。
|
||||
|
||||
## 1. 质检矩阵
|
||||
|
||||
| ID | 类型 | 输入 | 通过准则 |
|
||||
|---|---|---|---|
|
||||
| **G1 形状** | 结构 | `skills/multi/*` | 仅 `dingtalk-*`(含必选 `dingtalk-shared`);每目录有 `SKILL.md` |
|
||||
| **G2 结构** | 结构 | 各 `SKILL.md` frontmatter | `name`==目录名;非空 `description`;`category`∈{product,shared};`requires.bins` 含 `dws` |
|
||||
| **G3 覆盖** | 覆盖 | mono `references/products/*` 顶层 stem | 每 stem ∈ `coverage` 或 `omit_coverage`;coverage 目标 skill/refs 存在 |
|
||||
| **G4 漂移** | 漂移 | scripts、成对文件、全局协议 | orphan 脚本 ∈ allowlist;paired 一致;全局协议存在或 ∈ `omit_global` |
|
||||
|
||||
已有门禁(继续复用,不替代本矩阵):`check-skill-commands`、`check-skill-context-budget`、`check-multi-im-skill-chain`、`skill_docs_policy`、whiteboard 成对测试。
|
||||
|
||||
## 2. 有意省略 / 延期登记格式
|
||||
|
||||
YAML(见 coverage 文件):
|
||||
|
||||
```yaml
|
||||
omit_coverage:
|
||||
- mono: simple
|
||||
disposition: covered_by # covered_by | defer | wontfix
|
||||
via: dingtalk-misc # optional
|
||||
reason: "拆入 oa/devdoc…"
|
||||
|
||||
omit_global:
|
||||
- id: field-rules-global
|
||||
mono_path: references/field-rules.md
|
||||
expected_multi: dingtalk-aitable/references/field-rules.md
|
||||
disposition: covered_by
|
||||
reason: "AI 表格字段规则已下沉到 dingtalk-aitable;G3/coverage 不强制全局同名"
|
||||
|
||||
orphan_scripts_allowlist:
|
||||
- path: dingtalk-misc/scripts/report_received_today.py
|
||||
disposition: defer
|
||||
reason: "pending report.md reference"
|
||||
```
|
||||
|
||||
**处置原则**:质检失败 → 修**内容**或更新 reviewed omit;**不**改安装/升级默认。
|
||||
|
||||
## 3. 缺口基线(相对 mono)
|
||||
|
||||
| ID | 项 | disposition | 说明 |
|
||||
|---|---|---|---|
|
||||
| M1 | recovery-guide / RECOVERY_EVENT_ID 闭环 | **removed** | 已从 mono/multi skill 文档删除;不做移植 |
|
||||
| M2 | confirmation_required 全局协议 | **done** | `dingtalk-shared/references/confirmation.md` + SKILL 导航 |
|
||||
| M3 | Schema 渐进查询教学 | **done** | `dingtalk-shared/references/schema-usage.md` |
|
||||
| M4 | `report_inbox_today.py` | `defer` / orphan 侧 | 验证后迁 misc 或删 |
|
||||
| M5 | multi LICENSE/NOTICE | `defer` | 内容或打包注入 |
|
||||
| M6 | aiapp 路由 vs orphan 脚本 | **done(标明未产品化)** | mono 死链移除;`unsupported-scripts.md` |
|
||||
| X1 | yida/finance/aiapp orphan scripts | **done(登记)** | 由 unsupported-scripts 具名引用 |
|
||||
| X2 | chat 死链 `extract_media_id.py` | n/a | 现仅为反模式提及 |
|
||||
| X3 | routing → markdown 错路径 | **done** | 已指 `dingtalk-misc/references/markdown.md`;drive 尾链已修 |
|
||||
| X4 | event 缺 metadata | **done** | |
|
||||
| X5 | multi skill 横幅 /「优先 mono」文案 | **done** | 横幅已全部移除 |
|
||||
| X6 | SAFETY_PREAMBLE_INJECT 无注入器 | **done** | 标记已移除 |
|
||||
|
||||
产品面覆盖:见 YAML `coverage`——mono products 均有 multi 承接(misc 聚合 attendance/oa/sheet/…)。
|
||||
|
||||
## 4. 与悟空
|
||||
|
||||
借鉴 frontmatter / 断链 / requires 等**检查维度**;不运行悟空 bundle zip 校验脚本。覆盖权威始终是 DWS mono。
|
||||
@@ -0,0 +1,272 @@
|
||||
# DWS multi-skill **内容框架**对齐方案(相对 dws-wukong develop)
|
||||
|
||||
> 状态:**执行中** — Phase 1–3 已落地;M2/M3 已补;**M1 recovery 闭环已从 skill 删除(不做移植)**。
|
||||
> 合同短文:[skill-content-framework.md](skill-content-framework.md)
|
||||
> 质检规格:[skill-mono-multi-qa.md](skill-mono-multi-qa.md)
|
||||
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`
|
||||
> 门禁:`make skill-mono-multi-content`(已入 `make policy`)
|
||||
>
|
||||
> 撰写 / 收窄 / 质检增补 / 执行:2026-08-05
|
||||
> 工作树:`/Users/john/GolandProjects/open-source/dws-multi-skill-align`
|
||||
> 分支:`feat/multi-skill-framework-align`(自 `origin/main` @ `a37e6e68`)
|
||||
> **本分支范围:只做 skill 内容的这个框架**(目录布局、文档契约、共享内容约定、zip 内容树合同、**相对 mono 的内容质检**)。
|
||||
> **不做**安装/升级引擎、agent-home、脚本 skill-install 行为翻转。
|
||||
>
|
||||
> 对照仓:
|
||||
>
|
||||
> | 仓 | 路径 | 基线 |
|
||||
> |---|---|---|
|
||||
> | DWS OSS CLI(本工作树) | `dws-multi-skill-align` | `origin/main` |
|
||||
> | dws-wukong | `~/GolandProjects/open-source/dws-wukong` | `origin/develop` @ `ab76629a`(调研时) |
|
||||
> | 行为参考(**另一分支**) | `dws-skill-mode-migration` @ `402429ac`/`d5c8982c` | 安装默认 multi / upgrade 强制 multi —— **不在本分支排期** |
|
||||
> | 内容缺口留档(参考) | 同迁移分支 `docs/skill-capability-completion.md`(M1–M6 / X1 等) | **仅作质检目标线索**,非本分支权威 |
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
1. **本分支 = skill 内容框架 + 相对 mono 的内容质检**:固化 `skills/multi` 组织合同,并用 **mono 单 skill 布局作对照基准**做覆盖/结构/漂移门禁(文档 + CI 内容护栏)。
|
||||
2. **对齐悟空**:只取内容树组织概念;质检以 **DWS-native** 设计为主(已有 policy/测试可复用)。悟空 `validate-multiskill-bundle.py` 仅借鉴「frontmatter / 断链 / requires」类检查思路,**不**移植 bundle/安装校验。
|
||||
3. **安装/升级行为**与 `402429ac`/`d5c8982c` → **单独 follow-up 分支**,本方案只登记。
|
||||
4. 质检 **不改**默认安装哪棵树;只保证 multi 内容相对 mono **可解释、可覆盖、可回归**。
|
||||
|
||||
### 0.1 IN SCOPE
|
||||
|
||||
| 类别 | 包含 |
|
||||
|---|---|
|
||||
| 内容树结构 | `skills/mono/` 与 `skills/multi/<name>/` 目录合同 |
|
||||
| 单 skill 约定 | `SKILL.md` frontmatter / 契约块 / Golden Route;`references/`;可选 `scripts/` |
|
||||
| 共享内容 | `dingtalk-shared` 职责与被引用方式;与 mono 全局文映射(文档级) |
|
||||
| 命名与集合 | `dingtalk-*` + `dingtalk-shared`;相对悟空的共有/独有清单(文档) |
|
||||
| Zip **内容布局合同** | `mono/` / `multi/` / 根 mono 副本的内容含义与树形状;不改安装默认 |
|
||||
| **Mono↔multi 内容质检** | 覆盖、结构、漂移三类门禁;复用/扩展现有 policy 与测试;缺口修复属内容编辑(另批或同分支内容 Phase) |
|
||||
| 内容架构文档 | 本文件 + 可选短文(架构合同 + 质检矩阵) |
|
||||
|
||||
### 0.2 OUT OF SCOPE
|
||||
|
||||
| 类别 | 去向 |
|
||||
|---|---|
|
||||
| 安装默认 multi、upgrade always-multi | Follow-up 分支(`402429ac`/`d5c8982c`) |
|
||||
| `LocateSkillsRoot` / `skill_setup` / `paths.go` / `skillhome` / install 脚本行为 | 同上 |
|
||||
| 安装/运行时 manifest、state.json、mode 切换、telemetry header | 拒绝或行为分支 |
|
||||
| 悟空 `_install.sh` / dual / Qwen / RewindDesktop / pod | 拒绝 |
|
||||
| 非 skill 内容的 CLI 功能(schema/shortcut 代码等) | 拒绝 |
|
||||
| 把质检做成「改安装默认值」的后门 | 拒绝 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 内容现状盘点
|
||||
|
||||
### 1.1 DWS `skills/mono`(质检对照基准 · 单 skill)
|
||||
|
||||
```text
|
||||
skills/mono/
|
||||
├── SKILL.md
|
||||
├── references/
|
||||
│ ├── products/<area>.md|…/ # 产品能力面(质检「覆盖」主源)
|
||||
│ ├── error-codes.md、… # 全局协议(无 recovery 闭环)
|
||||
│ └── best_practices/…
|
||||
└── scripts/
|
||||
```
|
||||
|
||||
### 1.2 DWS `skills/multi`(内容主体)
|
||||
|
||||
```text
|
||||
skills/multi/
|
||||
├── dingtalk-shared/ # 跨产品契约 / routing / 全局协议应落点
|
||||
└── dingtalk-*/ # 19 产品 + 各 references、scripts
|
||||
```
|
||||
|
||||
仅 DWS 有(悟空无):dev, event, hrbrain, markdown, pat, profile, skill。
|
||||
|
||||
### 1.3 悟空 `dingtalk-skills/`(内容组织对照,非质检权威)
|
||||
|
||||
Flat `dingtalk-*` + `dingtalk-shared`;单 skill 骨架同构。**不作为 mono 覆盖基准**(集合更小、不同源)。
|
||||
|
||||
### 1.4 Zip 内容布局合同
|
||||
|
||||
| Zip 路径 | 内容含义 |
|
||||
|---|---|
|
||||
| `<root>/` | mono 副本(兼容) |
|
||||
| `<root>/mono/` | 显式 mono 内容源 |
|
||||
| `<root>/multi/` | 与 `skills/multi/` 同构 |
|
||||
|
||||
质检可断言「源树形状」;**不**断言安装面默认选哪棵。
|
||||
|
||||
### 1.5 现有 DWS skill 内容质检资产(复用清单)
|
||||
|
||||
| 资产 | 作用 | 与 mono↔multi 质检关系 |
|
||||
|---|---|---|
|
||||
| `scripts/policy/check-skill-commands.sh` + `skill-command-check/` | Skill 文内 `dws …` 命令路径存在性 | **复用**(命令真实性);非覆盖映射 |
|
||||
| `scripts/policy/check-skill-context-budget.sh` | chat/event/mono/`dingtalk-shared` 上下文预算与冷启动约束 | **复用**(结构/预算);可扩展 shared 引用规则 |
|
||||
| `scripts/policy/check-multi-im-skill-chain.sh` + `multi-im-skill-chain/` | IM 意图单默认路由、retired scripts、handoff | **复用**(chat/event 链);面窄 |
|
||||
| `test/unit/skill_docs_policy_test.go` | 退役命令、event 扁平输出契约等 | **复用**;可加 mono↔multi 断言 |
|
||||
| `test/unit/whiteboard_skill_docs_test.go` | mono/multi whiteboard recipes **字节一致** | **样板**:产品面「同源文件」门禁范式 |
|
||||
| `test/skill_static`(`-tags skill_verify`) | 文内命令 vs Cobra;multi 查 flag | **复用**(opt-in 深度);非 CI 默认全量时可保持 tags |
|
||||
| `test/skill_e2e` / `test/run_skill_tests.py` | 执行层 / 用例驱动 | **偏行为**;本分支质检默认不依赖 e2e |
|
||||
| `Makefile` → `policy` 含 context-budget、multi-im-skill-chain;`skill-command-integrity` 独立 | 已有 CI 钩子 | 新门禁优先挂同类 policy / `test/unit` |
|
||||
|
||||
**缺口(尚无的门禁)**:系统的「mono `references/products/*` → multi 目录/文」覆盖表;frontmatter 全集完备性;orphan scripts。全局协议中 **确认门禁 / Schema 教学已补**;**recovery 闭环已从 skill 移除(不再作为缺口)**。
|
||||
|
||||
### 1.6 悟空侧类比质检
|
||||
|
||||
| 悟空 | 说明 | 本分支 |
|
||||
|---|---|---|
|
||||
| `scripts/validate-multiskill-bundle.py` | 校验 **已打好的 bundle zip**:frontmatter keys/category、`requires`、markdown 断链、scenario 编排 | **Adapt 思路** → DWS 源树(`skills/multi` + 对照 mono),不跑 zip 安装语义 |
|
||||
| `sync-monolith-to-multiskill.py` | mono→multi 派生 | **不**作默认质检手段;DWS 直接维护 multi |
|
||||
|
||||
结论:**DWS-native mono↔multi 质检**;悟空仅参考检查维度。
|
||||
|
||||
---
|
||||
|
||||
## 2. Diff(内容组织 + 质检视角)
|
||||
|
||||
### 2.1 已同构
|
||||
|
||||
Flat `dingtalk-*` + `dingtalk-shared`;`SKILL.md` + `references/`(+ 可选 `scripts/`)。
|
||||
|
||||
### 2.2 分叉与已知内容风险(质检要盯的)
|
||||
|
||||
| 风险 ID | 现象(线索) | 质检类型 |
|
||||
|---|---|---|
|
||||
| **C-cov** | mono `products/*` 能力面在 multi 无对应 skill/reference,或未登记「有意省略」 | 覆盖 |
|
||||
| **C-struct** | multi 缺 frontmatter 字段、`references/`、`DWS_RUNTIME_CONTRACT`、对 `dingtalk-shared` 引用不一致 | 结构 |
|
||||
| **C-drift-global** | 曾关注 recovery / 确认 / Schema;现确认与 Schema 已在 `dingtalk-shared`,**recovery skill 文档已删除** | 漂移(协议) |
|
||||
| **C-drift-orphan** | multi(或 mono)scripts/refs 无文档引用;或 routing 指向无索引产品(留档 X1/M6) | 漂移(孤儿) |
|
||||
| **C-pair** | 应对齐的成对文件(如 whiteboard recipes)内容不一致 | 漂移(成对) |
|
||||
|
||||
### 2.3 Reject
|
||||
|
||||
悟空安装包校验整文件照搬、内容集 19→12 砍产品、安装行为门禁冒充内容质检。
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals / Non-goals
|
||||
|
||||
### 3.1 Goals
|
||||
|
||||
1. 固化 multi **内容目录合同**与 mono↔multi **映射说明**。
|
||||
2. 建立 **质检矩阵**(覆盖 / 结构 / 漂移)并以 mono 为对照基准;有意省略必须 reviewed 登记。
|
||||
3. **复用** §1.5 资产;新增门禁走 `scripts/policy` 或 `test/unit`,内容-only。
|
||||
4. (可选)纯内容元数据;**禁止**被安装引擎读取改行为。
|
||||
5. 质检失败 → 修 **内容**或更新「有意省略」表,不改 setup/upgrade。
|
||||
|
||||
### 3.2 Non-goals
|
||||
|
||||
安装/升级翻转;cherry-pick 行为提交;取消产品;悟空客户端;非 skill CLI 功能;用质检驱动默认 multi 安装。
|
||||
|
||||
---
|
||||
|
||||
## 4. 分期(内容框架 + 质检 · 均无安装引擎)
|
||||
|
||||
> 批准前 **零编码**(含不实现新 gates)。**已执行**:Phase 1–3 见文首状态。
|
||||
|
||||
### Phase 0 — 方案冻结(本文)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | 本文件;§7(含质检轨)勾选 |
|
||||
| **验收** | owner 重新批准 → ✅「现在开始执行」 |
|
||||
|
||||
### Phase 1 — Multi 内容目录合同 + 架构短文 ✅
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | `skills/multi` 目录合同;与悟空内容树对照表;zip `multi/` 同构合同 |
|
||||
| **触达** | `docs/skill-content-framework.md` |
|
||||
| **验收** | 可指导「如何新增 dingtalk-* 内容目录」 |
|
||||
|
||||
### Phase 2 — Mono↔multi **内容质检规格**(矩阵 + 缺口基线) ✅
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | 质检规格 + 覆盖/omit 机读表 + 缺口 disposition |
|
||||
| **触达** | `docs/skill-mono-multi-qa.md`、`skills/content-qa/mono-multi-coverage.yaml` |
|
||||
| **验收** | 矩阵可人工抽查;缺口均有 disposition |
|
||||
|
||||
### Phase 3 — 质检落地:CI 内容护栏(复用 + 新 gate) ✅
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | G1–G4 自动门禁 |
|
||||
| **触达** | `test/unit/mono_multi_skill_content_test.go`、`scripts/policy/check-mono-multi-skill-content.sh`、`Makefile` |
|
||||
| **验收** | `make skill-mono-multi-content` 绿;已知缺口走 reviewed omit |
|
||||
|
||||
### Phase 4 — 可选:内容包元数据 + 缺口修复波次
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围 A** | 纯内容 layout/skill 列表元数据(人不读安装器) |
|
||||
| **范围 B** | 按 Phase 2 disposition **修内容**:确认 / Schema 已补;**recovery skill 文档已删除(wontfix 移植)**;orphan 脚本仍走 allowlist(M4 等) |
|
||||
| **验收** | 元数据不驱动安装;修复项关闭对应质检失败或转入 omit |
|
||||
|
||||
### 延期登记(非本分支)
|
||||
|
||||
| 主题 | 载体 |
|
||||
|---|---|
|
||||
| 默认 multi + upgrade always-multi | 行为分支 ← `402429ac`/`d5c8982c` |
|
||||
| skillhome / 安装面 bootstrap | 行为分支 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Port / Adapt / Reject
|
||||
|
||||
| 项 | 决策 | 说明 |
|
||||
|---|---|---|
|
||||
| flat + `dingtalk-shared` 内容模型 | **Port** | 已有;合同 + 质检加固 |
|
||||
| 悟空 bundle frontmatter/断链/requires 检查维度 | **Adapt** | 做成 DWS 源树门禁,不校验 bundle zip/安装 |
|
||||
| whiteboard 式 mono/multi 成对一致 | **Port(范式)** | 推广到 reviewed 文件对 |
|
||||
| `validate-multiskill-bundle.py` 整脚本 | **Reject** | 绑定悟空 zip/Qwen 语义 |
|
||||
| `_install.sh` / dual / overlay | **Reject** | 非内容 |
|
||||
| 行为 cherry-pick | **Defer** | 另分支 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 与 `402429ac` / `d5c8982c`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 本分支 cherry-pick? | **否** |
|
||||
| 质检是否替代行为翻转? | **否** |
|
||||
| 行为分支 | 另开;可与内容/质检并行 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 批准清单(请重新勾选)
|
||||
|
||||
**范围**
|
||||
|
||||
- [x] 本分支 = skill **内容**框架 + **mono↔multi 内容质检**(§0.1);无安装/升级引擎
|
||||
- [x] `402429ac`/`d5c8982c` 及 setup/paths/install 脚本行为 **不在本分支**
|
||||
- [x] 取消产品与悟空客户端链路仍拒绝
|
||||
|
||||
**内容框架 Phase**
|
||||
|
||||
- [x] **Phase 1**:multi 目录合同 + 悟空内容树对照短文
|
||||
|
||||
**质检轨 Phase**
|
||||
|
||||
- [x] **Phase 2**:质检矩阵 + mono↔multi 覆盖/缺口基线规格(先文档,可执行)
|
||||
- [x] **Phase 3**:CI 内容护栏(G1–G4)—— 本迭代做 / 拆 PR / 只要规格暂不落地
|
||||
- [x] 质检失败处置原则:修内容或 reviewed omit,**不**改安装默认
|
||||
|
||||
**可选**
|
||||
|
||||
- [ ] **Phase 4A** 纯内容元数据:做 / 不做 / 以后
|
||||
- [x] **Phase 4B** recovery skill 文档 **removed/wontfix**;确认/Schema 已补;剩余 orphan(M4 等)仍 defer / allowlist
|
||||
|
||||
**Follow-up 知悉**
|
||||
|
||||
- [ ] 安装默认 multi + upgrade always-multi → **另一分支**
|
||||
|
||||
---
|
||||
|
||||
## 8. 下一步
|
||||
|
||||
**Phase 1–3 已落地**(合同短文 + 质检规格 + `skills/content-qa` + CI 门禁)。
|
||||
Phase 4B:recovery 已删除(不做移植);确认/Schema 已补。剩余 defer:orphan scripts(M4 等)、LICENSE/NOTICE(M5)、Phase 4A 元数据。
|
||||
安装默认 multi 等行为仍走 **另一分支**。
|
||||
|
||||
---
|
||||
|
||||
*锚点:`skills/mono`、`skills/multi`、§1.5 policy/测试、wukong `dingtalk-skills/`(组织对照 only)。*
|
||||
@@ -335,12 +335,6 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
|
||||
edition.Override(&edition.Hooks{ConfigDir: func() string { return "" }})
|
||||
captureRuntimeFailure(executor.Invocation{}, nil, nil)
|
||||
captureRuntimeFailure(executor.Invocation{}, errors.New("raw"), nil)
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "doc", "download", "--node", "n"}
|
||||
if got := runtimeCommandPath(executor.Invocation{}); len(got) != 2 {
|
||||
t.Fatalf("runtime command path = %#v", got)
|
||||
}
|
||||
os.Args = oldArgs
|
||||
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
if hostControlProviderFromEnv() != "" {
|
||||
@@ -360,6 +354,10 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
|
||||
|
||||
func TestCrossPlatformCoverageConfigAndCacheCommandRemainingCoverage(t *testing.T) {
|
||||
for _, command := range []*cobra.Command{newConfigCommand(), newCacheCommand()} {
|
||||
command.SetOut(io.Discard)
|
||||
rootWrap := &cobra.Command{Use: "dws"}
|
||||
rootWrap.PersistentFlags().String("format", "json", "")
|
||||
rootWrap.AddCommand(command)
|
||||
command.SetOut(io.Discard)
|
||||
if err := command.RunE(command, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -389,18 +387,18 @@ func TestCrossPlatformCoverageConfigAndCacheCommandRemainingCoverage(t *testing.
|
||||
for _, format := range []string{"json", "pretty", "table"} {
|
||||
_ = cacheRoot.PersistentFlags().Set("format", format)
|
||||
cacheCmd.SetOut(io.Discard)
|
||||
if err := printCacheCompatNotice(cacheCmd, "status"); err != nil {
|
||||
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
fail := errors.New("write")
|
||||
cacheCmd.SetOut(appFailWriter{err: fail})
|
||||
_ = cacheRoot.PersistentFlags().Set("format", "pretty")
|
||||
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
|
||||
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); !errors.Is(err, fail) {
|
||||
t.Fatalf("pretty write error = %v", err)
|
||||
}
|
||||
_ = cacheRoot.PersistentFlags().Set("format", "table")
|
||||
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
|
||||
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); !errors.Is(err, fail) {
|
||||
t.Fatalf("table write error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const cacheUnsupportedMessage = "dws cache 不再支持:服务发现已下线,当前版本使用编译期静态端点目录;dws cache 仅保留为兼容入口,不会刷新端点。"
|
||||
|
||||
const cacheReplacementHint = "如遇 endpoint_not_resolved,请先执行 dws upgrade 获取包含最新 internal/syncdata 端点的版本;仍失败时检查 internal/syncdata.StaticServers() 是否覆盖目标 product/server。"
|
||||
|
||||
type cacheCompatNotice struct {
|
||||
Status string `json:"status"`
|
||||
Command string `json:"command"`
|
||||
@@ -28,24 +32,32 @@ type cacheCompatNotice struct {
|
||||
Replacement string `json:"replacement,omitempty"`
|
||||
}
|
||||
|
||||
// newCacheCommand keeps a visible Deprecated compatibility surface for
|
||||
// historical argv (refresh/status/clean). Behavior is a successful no-op notice.
|
||||
// Skills must not teach this path. Deprecated leaves are excluded from Schema
|
||||
// via cobra.IsAvailableCommand() — do not add schema_command_exclusions entries.
|
||||
func newCacheCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "cache",
|
||||
Short: "服务发现缓存兼容入口(静态端点模式已弃用)",
|
||||
Hidden: true,
|
||||
Short: "不再支持:服务发现缓存兼容入口",
|
||||
Long: "此命令组仅为历史 argv 兼容保留。静态端点模式下无需服务发现缓存;Skill / Agent 请勿引导此路径。",
|
||||
Deprecated: "不再支持;" + cacheUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
return printCacheCompatNotice(cmd, "dws cache")
|
||||
},
|
||||
}
|
||||
for _, name := range []string{"refresh", "status", "clean"} {
|
||||
subName := name
|
||||
sub := &cobra.Command{
|
||||
Use: name,
|
||||
Short: "已弃用:静态端点模式无需服务发现缓存",
|
||||
Use: subName,
|
||||
Short: "不再支持:静态端点模式无需服务发现缓存",
|
||||
Deprecated: "不再支持;" + cacheUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return printCacheCompatNotice(cmd, name)
|
||||
return printCacheCompatNotice(cmd, "dws cache "+subName)
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(sub)
|
||||
@@ -56,9 +68,9 @@ func newCacheCommand() *cobra.Command {
|
||||
func printCacheCompatNotice(cmd *cobra.Command, command string) error {
|
||||
notice := cacheCompatNotice{
|
||||
Status: "deprecated",
|
||||
Command: "dws cache " + command,
|
||||
Message: "服务发现已下线,当前版本使用编译期静态端点目录;dws cache 仅保留为兼容入口,不会刷新端点。",
|
||||
Replacement: "如遇 endpoint_not_resolved,请先执行 dws upgrade 获取包含最新 internal/syncdata 端点的版本;仍失败时检查 internal/syncdata.StaticServers() 是否覆盖目标 product/server。",
|
||||
Command: command,
|
||||
Message: cacheUnsupportedMessage,
|
||||
Replacement: cacheReplacementHint,
|
||||
}
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
@@ -66,8 +78,7 @@ func printCacheCompatNotice(cmd *cobra.Command, command string) error {
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(notice)
|
||||
case "pretty":
|
||||
data, _ := json.MarshalIndent(notice, "", " ")
|
||||
var err error
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
|
||||
_, err := fmt.Fprintln(cmd.OutOrStdout(), string(data))
|
||||
return err
|
||||
default:
|
||||
_, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n%s\n", notice.Command, notice.Message, notice.Replacement)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageCacheDeprecatedCompatShim(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
group := mustFindCommand(t, root, "cache")
|
||||
if group.Hidden || group.Deprecated == "" || !group.Runnable() {
|
||||
t.Fatalf("cache group contract: hidden=%v deprecated=%q runnable=%v", group.Hidden, group.Deprecated, group.Runnable())
|
||||
}
|
||||
if group.IsAvailableCommand() {
|
||||
t.Fatal("deprecated cache group must not be IsAvailableCommand")
|
||||
}
|
||||
|
||||
for _, leaf := range []string{"refresh", "status", "clean"} {
|
||||
cmd := mustFindCommand(t, root, "cache", leaf)
|
||||
if cmd.Hidden || cmd.Deprecated == "" || !cmd.Runnable() {
|
||||
t.Fatalf("cache %s contract: hidden=%v deprecated=%q runnable=%v", leaf, cmd.Hidden, cmd.Deprecated, cmd.Runnable())
|
||||
}
|
||||
if cmd.IsAvailableCommand() {
|
||||
t.Fatalf("deprecated cache %s must not be IsAvailableCommand", leaf)
|
||||
}
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"cache", "refresh", "--format", "json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("cache refresh compatibility stub: %v\n%s", err, out.String())
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{`"status":"deprecated"`, `"command":"dws cache refresh"`, "不再支持", "服务发现已下线"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("cache refresh output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
for _, format := range []string{"", "json", "pretty", "table"} {
|
||||
var buf bytes.Buffer
|
||||
parent := &cobra.Command{Use: "dws"}
|
||||
parent.PersistentFlags().String("format", format, "")
|
||||
parent.SetOut(&buf)
|
||||
sub := &cobra.Command{Use: "cache"}
|
||||
parent.AddCommand(sub)
|
||||
if err := printCacheCompatNotice(sub, "dws cache status"); err != nil {
|
||||
t.Fatalf("format=%q: %v", format, err)
|
||||
}
|
||||
text := buf.String()
|
||||
if !strings.Contains(text, "不再支持") && !strings.Contains(text, "服务发现已下线") {
|
||||
t.Fatalf("format=%q missing notice:\n%s", format, text)
|
||||
}
|
||||
if format == "" || format == "json" || format == "pretty" {
|
||||
if !strings.Contains(text, `"status":"deprecated"`) && !strings.Contains(text, `"status": "deprecated"`) {
|
||||
t.Fatalf("format=%q missing deprecated JSON status:\n%s", format, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, format := range []string{"pretty", "table"} {
|
||||
parent := &cobra.Command{Use: "dws"}
|
||||
parent.PersistentFlags().String("format", format, "")
|
||||
parent.SetOut(failWriter{})
|
||||
sub := &cobra.Command{Use: "cache"}
|
||||
parent.AddCommand(sub)
|
||||
if err := printCacheCompatNotice(sub, "dws cache clean"); err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("format=%q write failure = %v, want write failed", format, err)
|
||||
}
|
||||
}
|
||||
|
||||
parent := newCacheCommand()
|
||||
var parentOut bytes.Buffer
|
||||
rootWrap := &cobra.Command{Use: "dws"}
|
||||
rootWrap.PersistentFlags().String("format", "json", "")
|
||||
rootWrap.SetOut(&parentOut)
|
||||
rootWrap.AddCommand(parent)
|
||||
parent.SetOut(&parentOut)
|
||||
if err := parent.RunE(parent, nil); err != nil {
|
||||
t.Fatalf("cache parent RunE = %v, want nil success", err)
|
||||
}
|
||||
if !strings.Contains(parentOut.String(), `"command":"dws cache"`) {
|
||||
t.Fatalf("cache parent notice missing command:\n%s", parentOut.String())
|
||||
}
|
||||
|
||||
cache := newCacheCommand()
|
||||
cache.SetOut(&bytes.Buffer{})
|
||||
cache.SetArgs([]string{"status"})
|
||||
if err := cache.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,6 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
@@ -227,115 +226,6 @@ func TestCrossPlatformCoverageDocDownloadPureCoverage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryPureCoverage(t *testing.T) {
|
||||
if _, err := decodeRecoveryAttempts(nil, nil, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := decodeRecoveryAttempts(json.RawMessage("null"), nil, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := decodeRecoveryAttempts(json.RawMessage(`[{"command_summary":"one"}]`), nil, "", ""); err != nil || len(got) != 1 {
|
||||
t.Fatalf("array attempts = %#v %v", got, err)
|
||||
}
|
||||
if _, err := decodeRecoveryAttempts(json.RawMessage("{"), nil, "", ""); err == nil {
|
||||
t.Fatal("malformed attempts succeeded")
|
||||
}
|
||||
if got, err := decodeRecoveryAttempts(json.RawMessage("2"), []string{"a"}, "ok", ""); err != nil || len(got) != 2 {
|
||||
t.Fatalf("legacy attempts = %#v %v", got, err)
|
||||
}
|
||||
if legacyRecoveryAttempts(0, nil, "", "") != nil || len(legacyRecoveryAttempts(1, nil, "", "")) != 1 {
|
||||
t.Fatal("legacy attempts edge mismatch")
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"dws", "--debug", "doc", "get", "--node", "n"},
|
||||
{"dws", "--format=json", "doc", "--", "ignored"},
|
||||
{"dws", "--unknown", "value", "doc"},
|
||||
} {
|
||||
old := os.Args
|
||||
os.Args = args
|
||||
_ = currentCommandPath()
|
||||
os.Args = old
|
||||
}
|
||||
for _, inv := range []executor.Invocation{
|
||||
{LegacyPath: "legacy path"},
|
||||
{CanonicalProduct: "doc", Tool: "get"},
|
||||
{CanonicalProduct: "doc"},
|
||||
{},
|
||||
} {
|
||||
old := os.Args
|
||||
os.Args = []string{"dws"}
|
||||
_ = runtimeCommandPath(inv)
|
||||
os.Args = old
|
||||
}
|
||||
if cloneRecoveryArgs(nil) != nil {
|
||||
t.Fatal("empty recovery args should clone to nil")
|
||||
}
|
||||
original := map[string]any{"x": 1}
|
||||
clone := cloneRecoveryArgs(original)
|
||||
clone["x"] = 2
|
||||
if original["x"] != 1 {
|
||||
t.Fatal("recovery args were not cloned")
|
||||
}
|
||||
|
||||
if got, _ := (*recoveryRuntime)(nil).Search(context.Background(), "query", recovery.RecoveryContext{}); got.DocSearch.Status != "skipped" {
|
||||
t.Fatalf("nil recovery search = %#v", got)
|
||||
}
|
||||
if got, _ := (&recoveryRuntime{}).Search(context.Background(), " ", recovery.RecoveryContext{}); got.DocSearch.Status != "skipped" {
|
||||
t.Fatalf("blank recovery search = %#v", got)
|
||||
}
|
||||
if _, err := (*recoveryRuntime)(nil).CallToolDirect(context.Background(), "x", "y", nil); err == nil {
|
||||
t.Fatal("nil recovery runtime call succeeded")
|
||||
}
|
||||
if _, err := (&recoveryRuntime{}).resolveEndpoint(context.Background(), "missing", "tool"); err == nil || !strings.Contains(err.Error(), `endpoint not resolved for product "missing" (tool "tool")`) {
|
||||
t.Fatalf("missing recovery endpoint error = %v", err)
|
||||
} else {
|
||||
var apiErr *apperrors.Error
|
||||
if !errors.As(err, &apiErr) || apiErr.Category != apperrors.CategoryAPI || apiErr.Operation != "discovery.resolve" || apiErr.Reason != "endpoint_not_resolved" {
|
||||
t.Fatalf("missing recovery endpoint classification = %#v", err)
|
||||
}
|
||||
}
|
||||
t.Setenv("DINGTALK_OK_MCP_URL", " https://catalog.test ")
|
||||
runtime := &recoveryRuntime{}
|
||||
if got, err := runtime.resolveEndpoint(context.Background(), "ok", "tool"); err != nil || got != "https://catalog.test" {
|
||||
t.Fatalf("recovery endpoint override = %q %v", got, err)
|
||||
}
|
||||
if recoveryRuntimeToken(nil) != "" || recoveryRuntimeToken(&GlobalFlags{Token: " token "}) != "token" {
|
||||
t.Fatal("recovery token mismatch")
|
||||
}
|
||||
if toRecoveryToolResponse(nil) != nil {
|
||||
t.Fatal("nil recovery response should stay nil")
|
||||
}
|
||||
response := toRecoveryToolResponse(&transport.ToolCallResult{IsError: true, Blocks: []transport.ContentBlock{{Type: "text", Text: "body"}}})
|
||||
if response == nil || !response.IsError || len(response.Content) != 1 {
|
||||
t.Fatalf("recovery response = %#v", response)
|
||||
}
|
||||
|
||||
items := []any{map[string]any{"title": "A", "url": "u", "desc": "d"}, "skip", map[string]any{}}
|
||||
for _, payload := range []map[string]any{
|
||||
nil,
|
||||
{"items": items},
|
||||
{"data": map[string]any{"items": items}},
|
||||
{"result": map[string]any{"items": items}},
|
||||
} {
|
||||
_ = parseDocSearchItemsFromMap(payload)
|
||||
}
|
||||
if toDocSearchItems("bad") != nil {
|
||||
t.Fatal("non-list doc items accepted")
|
||||
}
|
||||
result := &transport.ToolCallResult{Content: map[string]any{}, Blocks: []transport.ContentBlock{{Text: "{"}, {Text: `{"items":[{"title":"B"}]}`}}}
|
||||
if got := parseDocSearchItems(result); len(got) != 1 {
|
||||
t.Fatalf("block doc items = %#v", got)
|
||||
}
|
||||
if parseDocSearchItems(nil) != nil {
|
||||
t.Fatal("nil doc result should be nil")
|
||||
}
|
||||
searchItems := []recovery.DocSearchItem{{Title: "query", URL: "u"}, {Title: "other"}, {Title: "third"}, {Title: "fourth"}}
|
||||
if len(rerankDocSearchHits("query", recovery.RecoveryContext{ToolName: "tool", CommandPath: []string{"doc"}}, searchItems)) != 3 || rerankDocSearchHits("", recovery.RecoveryContext{}, nil) != nil {
|
||||
t.Fatal("doc search reranking mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSmallAppRegistryAndRootCoverage(t *testing.T) {
|
||||
RegisterPluginAuth("coverage-registry", &PluginAuth{Token: "token"})
|
||||
t.Cleanup(func() {
|
||||
@@ -511,59 +401,6 @@ func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) {
|
||||
_ = defaultPATMCPEndpoint()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryLoadExecutionCoverage(t *testing.T) {
|
||||
if _, err := loadRecoveryExecution(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("missing recovery execution succeeded")
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "execution.json")
|
||||
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadRecoveryExecution(path); err == nil {
|
||||
t.Fatal("malformed recovery execution succeeded")
|
||||
}
|
||||
for name, body := range map[string]string{
|
||||
"legacy": `{"action":" one ","attempt":2,"result":" ok ","error":" bad "}`,
|
||||
"modern": `{"actions":["one"],"attempts":[{"command_summary":"one"}],"error_summary":"bad"}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := loadRecoveryExecution(path); err != nil || len(got.Actions) != 1 || len(got.Attempts) == 0 {
|
||||
t.Fatalf("loaded execution = %#v %v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryRuntimeHTTP(t *testing.T) {
|
||||
var result map[string]any = map[string]any{"content": []map[string]any{{"type": "text", "text": `{"items":[{"title":"query result","url":"u"}]}`}}}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result})
|
||||
}))
|
||||
defer server.Close()
|
||||
SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: server.URL, CLI: mcptypes.CLIOverlay{ID: "devdoc", Tools: []mcptypes.CLITool{{Name: "search_open_platform_docs_rag"}}}}})
|
||||
t.Cleanup(func() { SetDynamicServers(nil) })
|
||||
runtime := &recoveryRuntime{transport: transport.NewClient(server.Client()), flags: &GlobalFlags{Token: "token"}}
|
||||
got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{ToolName: "search"})
|
||||
if err != nil || got.DocSearch.Status != "success" || len(got.KBHits) == 0 {
|
||||
t.Fatalf("recovery search = %#v %v", got, err)
|
||||
}
|
||||
result = map[string]any{"isError": true, "content": []map[string]any{{"type": "text", "text": "failed"}}}
|
||||
if _, err := runtime.CallToolDirect(context.Background(), "devdoc", "search_open_platform_docs_rag", nil); err == nil {
|
||||
t.Fatal("recovery MCP error succeeded")
|
||||
}
|
||||
server.Close()
|
||||
if _, err := runtime.CallToolDirect(context.Background(), "devdoc", "search_open_platform_docs_rag", nil); err == nil {
|
||||
t.Fatal("recovery network error succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventCommandPureCoverage(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
@@ -760,7 +597,7 @@ func TestCrossPlatformCoverageVersionCacheCompletionCoverage(t *testing.T) {
|
||||
child := &cobra.Command{Use: "child"}
|
||||
root.AddCommand(child)
|
||||
child.SetOut(io.Discard)
|
||||
if err := printCacheCompatNotice(child, "status"); err != nil {
|
||||
if err := printCacheCompatNotice(child, "dws cache status"); err != nil {
|
||||
t.Fatalf("cache %s: %v", format, err)
|
||||
}
|
||||
root.RemoveCommand(child)
|
||||
@@ -1747,9 +1584,6 @@ func TestCrossPlatformCoverageDoctorCommandCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, jsonOut := range []bool{false, true} {
|
||||
if got := doctorCheckCache(io.Discard, jsonOut); got.Status != statusPass {
|
||||
t.Fatal("cache check failed")
|
||||
}
|
||||
if got := doctorCheckPerf(io.Discard, jsonOut); got.Status != statusPass {
|
||||
t.Fatalf("perf check = %#v", got)
|
||||
}
|
||||
@@ -2168,7 +2002,7 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
}
|
||||
_ = os.Symlink(filepath.Join(mono, "SKILL.md"), filepath.Join(mono, "linked.md"))
|
||||
multi := filepath.Join(t.TempDir(), "multi")
|
||||
for _, name := range []string{"dws-shared", "dingtalk-a", "dingtalk-b"} {
|
||||
for _, name := range []string{"dingtalk-shared", "dingtalk-a", "dingtalk-b"} {
|
||||
dir := filepath.Join(multi, name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -2193,10 +2027,10 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if output, warnings, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") || warnings == "" {
|
||||
t.Fatalf("multi setup = %q / %q, %v", output, warnings, err)
|
||||
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") {
|
||||
t.Fatalf("multi setup = %q, %v", output, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws-shared", "SKILL.md")); err != nil {
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-shared", "SKILL.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--dry-run", "--exclude", "b"); err != nil || !strings.Contains(output, "DRY-RUN") {
|
||||
@@ -2220,7 +2054,7 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
all := []string{"dingtalk-a", "dingtalk-b", "dws-shared"}
|
||||
all := []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}
|
||||
for _, tc := range []struct {
|
||||
include []string
|
||||
exclude []string
|
||||
@@ -2238,7 +2072,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
t.Errorf("filter %#v/%#v = %v", tc.include, tc.exclude, err)
|
||||
}
|
||||
}
|
||||
for _, selected := range [][]string{nil, {"dws-shared"}, {"dingtalk-a"}} {
|
||||
for _, selected := range [][]string{nil, {"dingtalk-shared"}, {"dingtalk-a"}} {
|
||||
_ = ensureMandatorySharedSkill(selected, all)
|
||||
}
|
||||
_ = ensureMandatorySharedSkill([]string{"dingtalk-a"}, []string{"dingtalk-a"})
|
||||
|
||||
@@ -302,7 +302,6 @@ func TestCrossPlatformCoverageRootUtilityAndTimingCoverage(t *testing.T) {
|
||||
_ = newConfigCommand()
|
||||
_ = newCacheCommand()
|
||||
_ = newVersionCommand()
|
||||
_ = newRecoveryCommand(&GlobalFlags{})
|
||||
_ = newAPICommand(&GlobalFlags{})
|
||||
_ = NewRootCommand(context.Background())
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ func newDoctorCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "doctor",
|
||||
Short: "环境健康检查",
|
||||
Long: "一键检查登录态、网络连通性、缓存状态和版本更新,快速定位常见问题。",
|
||||
Long: "一键检查登录态、网络连通性和版本更新,快速定位常见问题。",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: runDoctor,
|
||||
@@ -92,9 +92,6 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
|
||||
networkResult := doctorCheckNetwork(cmd.Context(), w, jsonOut, networkTimeout)
|
||||
checks = append(checks, networkResult)
|
||||
|
||||
cacheResult := doctorCheckCache(w, jsonOut)
|
||||
checks = append(checks, cacheResult)
|
||||
|
||||
versionResult := doctorCheckVersion(w, jsonOut, networkTimeout)
|
||||
checks = append(checks, versionResult)
|
||||
|
||||
@@ -297,24 +294,6 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Cache check ─────────────────────────────────────────────────────────
|
||||
|
||||
func doctorCheckCache(w io.Writer, jsonOut bool) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, tui.Dim("检查缓存状态... "))
|
||||
}
|
||||
|
||||
r := checkResult{
|
||||
Name: "cache",
|
||||
Status: statusPass,
|
||||
Message: "静态端点模式, 无需缓存",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Version check ───────────────────────────────────────────────────────
|
||||
|
||||
func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
|
||||
|
||||
@@ -109,31 +109,6 @@ func TestPrintCheckResultNoHint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCheckCacheEmpty(t *testing.T) {
|
||||
t.Setenv("DWS_CACHE_DIR", t.TempDir())
|
||||
|
||||
var buf bytes.Buffer
|
||||
r := doctorCheckCache(&buf, false)
|
||||
|
||||
if r.Status != statusPass {
|
||||
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCheckCacheEmptyJSON(t *testing.T) {
|
||||
t.Setenv("DWS_CACHE_DIR", t.TempDir())
|
||||
|
||||
var buf bytes.Buffer
|
||||
r := doctorCheckCache(&buf, true)
|
||||
|
||||
if r.Status != statusPass {
|
||||
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Error("expected no output in JSON mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCheckAuthReportsKeychainUnavailable(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ func newEventCommand() *cobra.Command {
|
||||
RunE: func(c *cobra.Command, _ []string) error { return c.Help() },
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newEventListenIMCommand(),
|
||||
newEventConsumeCommand(),
|
||||
newEventListCommand(),
|
||||
newEventSchemaCommand(),
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
// 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 (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type listenIMOptions struct {
|
||||
Kind string
|
||||
Events []string
|
||||
UserID string
|
||||
OpenDingTalkID string
|
||||
UserQuery string
|
||||
ChatID string
|
||||
ChatQuery string
|
||||
QueryCSV string
|
||||
MaxEvents int
|
||||
Duration time.Duration
|
||||
DryRun bool
|
||||
ControlBaseURL string
|
||||
StreamTicketMode string
|
||||
StreamTicketURL string
|
||||
StreamSourceID string
|
||||
}
|
||||
|
||||
type listenIMPlan struct {
|
||||
EventKeys []string
|
||||
UserID string
|
||||
OpenDingTalkID string
|
||||
GroupID string
|
||||
ResolvedTargets []any
|
||||
}
|
||||
|
||||
type eventTargetReader struct{}
|
||||
|
||||
func (eventTargetReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
|
||||
text, err := helpers.CallMCPReadToolTextOnServer(product, tool, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &data); err != nil {
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
var eventListenIMReader = func() targetresolver.Reader { return eventTargetReader{} }
|
||||
|
||||
func newEventListenIMCommand() *cobra.Command {
|
||||
var opts listenIMOptions
|
||||
cmd := &cobra.Command{
|
||||
Use: "+listen-im",
|
||||
Short: "按 IM 意图解析目标并监听一个或多个个人消息事件",
|
||||
Long: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey," +
|
||||
"自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期。",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
plan, err := compileListenIMPlan(eventListenIMReader(), opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event +listen-im: %w", err)
|
||||
}
|
||||
consumeOpts := personalConsumeOptions{
|
||||
EventKey: firstArg(plan.EventKeys),
|
||||
EventKeys: plan.EventKeys,
|
||||
Flatten: true,
|
||||
UserID: plan.UserID,
|
||||
OpenDingTalkID: plan.OpenDingTalkID,
|
||||
GroupID: plan.GroupID,
|
||||
QueryCSV: opts.QueryCSV,
|
||||
ControlBaseURL: opts.ControlBaseURL,
|
||||
StreamTicketMode: opts.StreamTicketMode,
|
||||
StreamTicketURL: opts.StreamTicketURL,
|
||||
StreamSourceID: opts.StreamSourceID,
|
||||
Common: commonConsumeOptions{
|
||||
FormatRaw: "ndjson",
|
||||
MaxEvents: opts.MaxEvents,
|
||||
Duration: opts.Duration,
|
||||
DryRun: opts.DryRun,
|
||||
},
|
||||
}
|
||||
return eventRunPersonalConsume(c, consumeOpts)
|
||||
},
|
||||
}
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&opts.Kind, "kind", "at-me", "监听意图: at-me|sender|group|all-direct|all-group")
|
||||
f.StringSliceVar(&opts.Events, "events", []string{"message"}, "事件种类: message,reaction,read,recall")
|
||||
f.StringVar(&opts.UserID, "user", "", "指定发送人/单聊对端 userId")
|
||||
f.StringVar(&opts.OpenDingTalkID, "open-dingtalk-id", "", "指定发送人/单聊对端 openDingTalkId")
|
||||
f.StringVar(&opts.UserQuery, "user-query", "", "按姓名/花名唯一解析指定发送人")
|
||||
f.StringVar(&opts.ChatID, "chat-id", "", "指定群 openConversationId")
|
||||
f.StringVar(&opts.ChatQuery, "chat-query", "", "按群名唯一解析指定群")
|
||||
f.StringVar(&opts.QueryCSV, "query", "", "消息文本关键词过滤,逗号分隔;仅 message 事件")
|
||||
f.IntVar(&opts.MaxEvents, "max-events", 0, "收到 N 条后退出 (0 = 不限)")
|
||||
f.DurationVar(&opts.Duration, "duration", 0, "运行时长上限 (Go duration,如 30s/5m;0 = 不限)")
|
||||
f.BoolVar(&opts.DryRun, "dry-run", false, "解析目标并打印订阅计划,不创建订阅或连接 bus")
|
||||
f.StringVar(&opts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base 派生 /dws")
|
||||
f.StringVar(&opts.StreamTicketMode, "stream-ticket-mode", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_MODE")), "个人 Stream 建联模式;默认 normal")
|
||||
f.StringVar(&opts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")), "个人 Stream sourceId;开源版默认 open")
|
||||
f.StringVar(&opts.StreamTicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")), "个人 Stream 取票 URL")
|
||||
hideEventInternalFlags(cmd, "personal-event-base-url", "stream-ticket-mode", "stream-source-id", "stream-ticket-url")
|
||||
cli.AnnotateRuntimeFlagEnum(cmd, "kind", "at-me", "sender", "group", "all-direct", "all-group")
|
||||
cli.AnnotateRuntimeFlagEnum(cmd, "events", "message", "reaction", "read", "recall")
|
||||
cli.AnnotateRuntimeConstraints(cmd, cli.RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: [][]string{{"user", "open-dingtalk-id", "user-query", "chat-id", "chat-query"}},
|
||||
})
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "event",
|
||||
Name: "listen_im",
|
||||
CanonicalPath: "event.listen_im",
|
||||
CLIPath: "event +listen-im",
|
||||
PrimaryCLIPath: "event +listen-im",
|
||||
},
|
||||
Description: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey,自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed IM event facade: it deterministically maps kind/events to public personal EventKeys, resolves one natural user/chat target with the shared typed resolver, then delegates one single- or multi-event invocation to the existing subscription, bus, ready-marker, NDJSON, rollback, cancellation, and cleanup lifecycle.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按 @我、姓名、群名或全量范围监听一个或多个 IM 消息事件",
|
||||
UseWhen: []string{
|
||||
"已知要监听 @我、指定发送人、指定群、全部单聊或全部群聊的 message/reaction/read/recall 事件时使用;姓名用 --user-query、群名用 --chat-query,CLI 会唯一解析目标并把多个兼容事件合并到一个消费生命周期。",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"需要群标题/成员/解散等生命周期事件、显式 EventKey、复用 subscribe_id、Filter DSL、原始 transport envelope 或其它底层 consume 控制时使用 event consume;只查历史消息时使用 chat 查询入口",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws event +listen-im --kind at-me --max-events 1",
|
||||
"dws event +listen-im --kind group --events message,reaction --chat-id <openConversationId> --duration 10m",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "chat-id", Property: "chatId"},
|
||||
{Name: "chat-query", Property: "chatQuery"},
|
||||
{Name: "dry-run", Property: "dryRun"},
|
||||
{Name: "duration", Property: "duration"},
|
||||
{Name: "events", Property: "events"},
|
||||
{Name: "kind", Property: "kind"},
|
||||
{Name: "max-events", Property: "maxEvents"},
|
||||
{Name: "open-dingtalk-id", Property: "openDingtalkId"},
|
||||
{Name: "query", Property: "query"},
|
||||
{Name: "user", Property: "user"},
|
||||
{Name: "user-query", Property: "userQuery"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func compileListenIMPlan(reader targetresolver.Reader, opts listenIMOptions) (listenIMPlan, error) {
|
||||
kind := strings.ToLower(strings.TrimSpace(opts.Kind))
|
||||
if kind == "" {
|
||||
kind = "at-me"
|
||||
}
|
||||
events := uniqueListenIMValues(opts.Events)
|
||||
if len(events) == 0 {
|
||||
return listenIMPlan{}, apperrors.NewValidation("--events 至少包含一个事件种类")
|
||||
}
|
||||
if strings.TrimSpace(opts.QueryCSV) != "" {
|
||||
for _, eventName := range events {
|
||||
if eventName != "message" {
|
||||
return listenIMPlan{}, apperrors.NewValidation("--query 只支持 message 事件")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plan := listenIMPlan{}
|
||||
var err error
|
||||
switch kind {
|
||||
case "at-me", "all-direct", "all-group":
|
||||
if listenIMTargetCount(opts) != 0 {
|
||||
return listenIMPlan{}, apperrors.NewValidation(fmt.Sprintf("--kind %s 不接受用户或群目标", kind))
|
||||
}
|
||||
case "sender":
|
||||
if listenIMUserTargetCount(opts) != 1 || listenIMChatTargetCount(opts) != 0 {
|
||||
return listenIMPlan{}, apperrors.NewValidation("--kind sender 必须且只能指定 --user、--open-dingtalk-id 或 --user-query 之一")
|
||||
}
|
||||
plan.UserID = strings.TrimSpace(opts.UserID)
|
||||
plan.OpenDingTalkID = strings.TrimSpace(opts.OpenDingTalkID)
|
||||
if query := strings.TrimSpace(opts.UserQuery); query != "" {
|
||||
resolved, resolveErr := targetresolver.ResolveUser(reader, query, targetresolver.IdentityAny)
|
||||
if resolveErr != nil {
|
||||
return listenIMPlan{}, resolveErr
|
||||
}
|
||||
plan.ResolvedTargets = append(plan.ResolvedTargets, resolved)
|
||||
plan.UserID = resolved.Selected.UserID
|
||||
if plan.UserID == "" {
|
||||
plan.OpenDingTalkID = resolved.Selected.OpenDingTalkID
|
||||
}
|
||||
}
|
||||
case "group":
|
||||
if listenIMChatTargetCount(opts) != 1 || listenIMUserTargetCount(opts) != 0 {
|
||||
return listenIMPlan{}, apperrors.NewValidation("--kind group 必须且只能指定 --chat-id 或 --chat-query 之一")
|
||||
}
|
||||
plan.GroupID = strings.TrimSpace(opts.ChatID)
|
||||
if query := strings.TrimSpace(opts.ChatQuery); query != "" {
|
||||
resolved, resolveErr := targetresolver.ResolveChat(reader, query)
|
||||
if resolveErr != nil {
|
||||
return listenIMPlan{}, resolveErr
|
||||
}
|
||||
plan.ResolvedTargets = append(plan.ResolvedTargets, resolved)
|
||||
plan.GroupID = resolved.Selected.OpenConversationID
|
||||
}
|
||||
default:
|
||||
return listenIMPlan{}, apperrors.NewValidation("--kind 必须是 at-me、sender、group、all-direct 或 all-group")
|
||||
}
|
||||
|
||||
plan.EventKeys, err = listenIMEventKeys(kind, events)
|
||||
if err != nil {
|
||||
return listenIMPlan{}, err
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func listenIMEventKeys(kind string, events []string) ([]string, error) {
|
||||
mapping := map[string]map[string]string{
|
||||
"at-me": {"message": personal.EventMention},
|
||||
"sender": {"message": personal.EventFromUser, "reaction": personal.EventReactionO2O, "read": personal.EventReadO2O, "recall": personal.EventRecallO2O},
|
||||
"group": {"message": personal.EventInChat, "reaction": personal.EventReactionGroup, "read": personal.EventReadGroup, "recall": personal.EventRecallGroup},
|
||||
"all-direct": {"message": personal.EventAllSingleChat},
|
||||
"all-group": {"message": personal.EventAllGroupChat},
|
||||
}
|
||||
byEvent := mapping[kind]
|
||||
keys := make([]string, 0, len(events))
|
||||
for _, eventName := range events {
|
||||
key := byEvent[eventName]
|
||||
if key == "" {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--kind %s 不支持 event %s", kind, eventName))
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func listenIMUserTargetCount(opts listenIMOptions) int {
|
||||
return nonEmptyListenIMCount(opts.UserID, opts.OpenDingTalkID, opts.UserQuery)
|
||||
}
|
||||
|
||||
func listenIMChatTargetCount(opts listenIMOptions) int {
|
||||
return nonEmptyListenIMCount(opts.ChatID, opts.ChatQuery)
|
||||
}
|
||||
|
||||
func listenIMTargetCount(opts listenIMOptions) int {
|
||||
return listenIMUserTargetCount(opts) + listenIMChatTargetCount(opts)
|
||||
}
|
||||
|
||||
func nonEmptyListenIMCount(values ...string) int {
|
||||
count := 0
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func uniqueListenIMValues(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"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/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type listenIMFakeReader struct {
|
||||
responses map[string]map[string]any
|
||||
calls []string
|
||||
}
|
||||
|
||||
type listenIMErrorReader struct{ err error }
|
||||
|
||||
func (r listenIMErrorReader) CallMCPData(string, string, map[string]any) (map[string]any, error) {
|
||||
return nil, r.err
|
||||
}
|
||||
|
||||
type listenIMHelperCaller struct {
|
||||
text string
|
||||
err error
|
||||
}
|
||||
|
||||
func (c listenIMHelperCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
return c.result()
|
||||
}
|
||||
|
||||
func (c listenIMHelperCaller) CallReadTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
return c.result()
|
||||
}
|
||||
|
||||
func (c listenIMHelperCaller) result() (*edition.ToolResult, error) {
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: c.text}}}, nil
|
||||
}
|
||||
|
||||
func (listenIMHelperCaller) Format() string { return "json" }
|
||||
func (listenIMHelperCaller) DryRun() bool { return false }
|
||||
func (listenIMHelperCaller) Fields() string { return "" }
|
||||
func (listenIMHelperCaller) JQ() string { return "" }
|
||||
|
||||
func (f *listenIMFakeReader) CallMCPData(product, tool string, _ map[string]any) (map[string]any, error) {
|
||||
key := product + "/" + tool
|
||||
f.calls = append(f.calls, key)
|
||||
if response, ok := f.responses[key]; ok {
|
||||
return response, nil
|
||||
}
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompileListenIMPlanResolvesGroupAndMapsMultipleEvents(t *testing.T) {
|
||||
reader := &listenIMFakeReader{responses: map[string]map[string]any{
|
||||
"im/search_groups": {
|
||||
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
|
||||
},
|
||||
}}
|
||||
plan, err := compileListenIMPlan(reader, listenIMOptions{
|
||||
Kind: "group",
|
||||
Events: []string{"message", "reaction", "recall"},
|
||||
ChatQuery: "项目群",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantKeys := []string{personal.EventInChat, personal.EventReactionGroup, personal.EventRecallGroup}
|
||||
if !reflect.DeepEqual(plan.EventKeys, wantKeys) || plan.GroupID != "cid-1" {
|
||||
t.Fatalf("plan = %#v, want keys=%v group=cid-1", plan, wantKeys)
|
||||
}
|
||||
if !reflect.DeepEqual(reader.calls, []string{"im/search_groups"}) {
|
||||
t.Fatalf("resolver calls = %#v", reader.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompileListenIMPlanReturnsStructuredAmbiguityBeforeSubscription(t *testing.T) {
|
||||
reader := &listenIMFakeReader{responses: map[string]map[string]any{
|
||||
"contact/search_contact_by_key_word": {
|
||||
"result": []any{
|
||||
map[string]any{"name": "张三", "userId": "u1"},
|
||||
map[string]any{"name": "张三", "userId": "u2"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
_, err := compileListenIMPlan(reader, listenIMOptions{
|
||||
Kind: "sender",
|
||||
Events: []string{"message"},
|
||||
UserQuery: "张三",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("ambiguous sender unexpectedly compiled")
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "resolution_ambiguous" {
|
||||
t.Fatalf("ambiguity error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventListenIMCommandDelegatesOneCompiledConsumeLifecycle(t *testing.T) {
|
||||
reader := &listenIMFakeReader{responses: map[string]map[string]any{
|
||||
"im/search_groups": {
|
||||
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
|
||||
},
|
||||
}}
|
||||
oldReader := eventListenIMReader
|
||||
oldRun := eventRunPersonalConsume
|
||||
t.Cleanup(func() {
|
||||
eventListenIMReader = oldReader
|
||||
eventRunPersonalConsume = oldRun
|
||||
})
|
||||
eventListenIMReader = func() targetresolver.Reader { return reader }
|
||||
var captured personalConsumeOptions
|
||||
var calls int
|
||||
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
|
||||
calls++
|
||||
captured = opts
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := newEventListenIMCommand()
|
||||
cmd.SetArgs([]string{
|
||||
"--kind", "group",
|
||||
"--events", "message,reaction",
|
||||
"--chat-query", "项目群",
|
||||
"--max-events", "2",
|
||||
"--duration", "30s",
|
||||
"--dry-run",
|
||||
})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("consume lifecycle calls = %d, want 1", calls)
|
||||
}
|
||||
if !reflect.DeepEqual(captured.EventKeys, []string{personal.EventInChat, personal.EventReactionGroup}) ||
|
||||
captured.GroupID != "cid-1" || !captured.Flatten || !captured.Common.DryRun ||
|
||||
captured.Common.MaxEvents != 2 || captured.Common.Duration.String() != "30s" {
|
||||
t.Fatalf("captured options = %#v", captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompileListenIMPlanRejectsIncompatibleKindAndTargets(t *testing.T) {
|
||||
reader := &listenIMFakeReader{}
|
||||
cases := []listenIMOptions{
|
||||
{Kind: "at-me", Events: []string{"reaction"}},
|
||||
{Kind: "all-group", Events: []string{"message"}, ChatID: "cid"},
|
||||
{Kind: "sender", Events: []string{"message"}},
|
||||
{Kind: "group", Events: []string{"message"}, ChatID: "cid", ChatQuery: "群"},
|
||||
{Kind: "group", Events: []string{"message", "reaction"}, ChatID: "cid", QueryCSV: "关键词"},
|
||||
}
|
||||
for _, opts := range cases {
|
||||
if _, err := compileListenIMPlan(reader, opts); err == nil {
|
||||
t.Errorf("options unexpectedly accepted: %#v", opts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageListenIMCompletionBranches(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
text string
|
||||
err error
|
||||
ok bool
|
||||
}{
|
||||
{name: "transport", err: errors.New("transport")},
|
||||
{name: "empty", text: " ", ok: true},
|
||||
{name: "invalid json", text: "{invalid"},
|
||||
{name: "valid", text: `{"result":{"ok":true}}`, ok: true},
|
||||
} {
|
||||
t.Run("reader "+tc.name, func(t *testing.T) {
|
||||
helpers.InitDeps(listenIMHelperCaller{text: tc.text, err: tc.err})
|
||||
data, err := (eventTargetReader{}).CallMCPData("im", "search_groups", nil)
|
||||
if (err == nil) != tc.ok {
|
||||
t.Fatalf("data=%#v error=%v ok=%v", data, err, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if plan, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Events: []string{" MESSAGE ", "message"}}); err != nil || len(plan.EventKeys) != 1 {
|
||||
t.Fatalf("default/deduplicated plan = %#v, %v", plan, err)
|
||||
}
|
||||
if _, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Kind: "at-me"}); err == nil {
|
||||
t.Fatal("empty event set unexpectedly accepted")
|
||||
}
|
||||
if _, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Kind: "unknown", Events: []string{"message"}}); err == nil {
|
||||
t.Fatal("unknown kind unexpectedly accepted")
|
||||
}
|
||||
|
||||
reader := &listenIMFakeReader{responses: map[string]map[string]any{
|
||||
"contact/search_contact_by_key_word": {
|
||||
"result": []any{map[string]any{"name": "甲", "openDingTalkId": "D-user"}},
|
||||
},
|
||||
}}
|
||||
plan, err := compileListenIMPlan(reader, listenIMOptions{Kind: "sender", Events: []string{"message"}, UserQuery: "甲"})
|
||||
if err != nil || plan.UserID != "" || plan.OpenDingTalkID != "D-user" {
|
||||
t.Fatalf("open-id sender plan = %#v, %v", plan, err)
|
||||
}
|
||||
wantErr := errors.New("resolution failed")
|
||||
if _, err := compileListenIMPlan(listenIMErrorReader{err: wantErr}, listenIMOptions{Kind: "sender", Events: []string{"message"}, UserQuery: "甲"}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("sender resolution error = %v", err)
|
||||
}
|
||||
if _, err := compileListenIMPlan(listenIMErrorReader{err: wantErr}, listenIMOptions{Kind: "group", Events: []string{"message"}, ChatQuery: "群"}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("group resolution error = %v", err)
|
||||
}
|
||||
|
||||
cmd := newEventListenIMCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"--kind", "sender"})
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "event +listen-im") {
|
||||
t.Fatalf("command compile error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventListenIME2ELifecycleCleansAndRollsBack(t *testing.T) {
|
||||
newReader := func() *listenIMFakeReader {
|
||||
return &listenIMFakeReader{responses: map[string]map[string]any{
|
||||
"im/search_groups": {
|
||||
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
|
||||
},
|
||||
}}
|
||||
}
|
||||
installFacade := func(t *testing.T, reader *listenIMFakeReader) {
|
||||
t.Helper()
|
||||
oldReader := eventListenIMReader
|
||||
oldRun := eventRunPersonalConsume
|
||||
t.Cleanup(func() {
|
||||
eventListenIMReader = oldReader
|
||||
eventRunPersonalConsume = oldRun
|
||||
})
|
||||
eventListenIMReader = func() targetresolver.Reader { return reader }
|
||||
eventRunPersonalConsume = runPersonalEventConsume
|
||||
}
|
||||
installLifecycle := func(t *testing.T) {
|
||||
t.Helper()
|
||||
restore := installPersonalManySeams(t)
|
||||
t.Cleanup(restore)
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{
|
||||
AccessToken: "token", CorpID: "corp", UserID: "user",
|
||||
ClientID: "client", SourceID: "open",
|
||||
}, nil
|
||||
}
|
||||
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
|
||||
}
|
||||
|
||||
t.Run("ready then clean every created subscription", func(t *testing.T) {
|
||||
reader := newReader()
|
||||
installFacade(t, reader)
|
||||
installLifecycle(t)
|
||||
var created, deleted, removed []string
|
||||
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
created = append(created, opts.EventKey)
|
||||
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}, opts.EventKey, "group", nil
|
||||
}
|
||||
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
|
||||
deleted = append(deleted, id)
|
||||
return nil
|
||||
}
|
||||
personalRemoveRunStates = func(_ string, ids []string) error {
|
||||
removed = append(removed, ids...)
|
||||
return nil
|
||||
}
|
||||
personalConsumeRunMany = func(_ context.Context, cfg consume.Config, specs []consume.ConsumerSpec) error {
|
||||
if len(specs) != 2 || !cfg.Flatten {
|
||||
t.Fatalf("consume specs/config = %#v / %#v", specs, cfg)
|
||||
}
|
||||
fmt.Fprintf(cfg.Stderr, "[event] ready event_count=%d bus_pid=123\n", len(specs))
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := newEventListenIMCommand()
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SetArgs([]string{
|
||||
"--kind", "group", "--events", "message,reaction",
|
||||
"--chat-query", "项目群", "--max-events", "1",
|
||||
})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantEvents := []string{personal.EventInChat, personal.EventReactionGroup}
|
||||
if !reflect.DeepEqual(created, wantEvents) {
|
||||
t.Fatalf("created = %#v, want %#v", created, wantEvents)
|
||||
}
|
||||
wantDeleted := []string{"sub-" + personal.EventReactionGroup, "sub-" + personal.EventInChat}
|
||||
if !reflect.DeepEqual(deleted, wantDeleted) || !reflect.DeepEqual(removed, wantDeleted) {
|
||||
t.Fatalf("deleted=%#v removed=%#v want=%#v", deleted, removed, wantDeleted)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "[event] ready event_count=2") {
|
||||
t.Fatalf("missing ready marker: %s", stderr.String())
|
||||
}
|
||||
if !reflect.DeepEqual(reader.calls, []string{"im/search_groups"}) {
|
||||
t.Fatalf("resolver calls = %#v", reader.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("second create failure rolls back first without starting consumer", func(t *testing.T) {
|
||||
reader := newReader()
|
||||
installFacade(t, reader)
|
||||
installLifecycle(t)
|
||||
wantErr := errors.New("second subscription failed")
|
||||
calls := 0
|
||||
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return nil, "", "", wantErr
|
||||
}
|
||||
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "group", nil
|
||||
}
|
||||
var deleted, removed []string
|
||||
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
|
||||
deleted = append(deleted, id)
|
||||
return nil
|
||||
}
|
||||
personalRemoveRunStates = func(_ string, ids []string) error {
|
||||
removed = append(removed, ids...)
|
||||
return nil
|
||||
}
|
||||
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error {
|
||||
t.Fatal("consumer started after partial subscription failure")
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := newEventListenIMCommand()
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs([]string{
|
||||
"--kind", "group", "--events", "message,reaction",
|
||||
"--chat-query", "项目群",
|
||||
})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), wantErr.Error()) {
|
||||
t.Fatalf("error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if !reflect.DeepEqual(deleted, []string{"sub-first"}) || !reflect.DeepEqual(removed, []string{"sub-first"}) {
|
||||
t.Fatalf("rollback deleted=%#v removed=%#v", deleted, removed)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
|
||||
func TestCrossPlatformCoverageEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
event := newEventCommand()
|
||||
markdown := &cobra.Command{Use: "markdown"}
|
||||
@@ -37,7 +37,7 @@ func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
|
||||
leaves = append(leaves, command.Name())
|
||||
}
|
||||
sort.Strings(leaves)
|
||||
want := []string{"consume", "list", "schema", "status", "stop"}
|
||||
want := []string{"+listen-im", "consume", "list", "schema", "status", "stop"}
|
||||
if len(leaves) != len(want) {
|
||||
t.Fatalf("public event leaves = %v, want %v", leaves, want)
|
||||
}
|
||||
@@ -48,7 +48,7 @@ func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginCannotReplaceBuiltInEventCommand(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePluginCannotReplaceBuiltInEventCommand(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
builtIn := newEventCommand()
|
||||
root.AddCommand(builtIn)
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// TestMultiSkillSharedContractKeepsAccountSafetyRule pins the multi-account
|
||||
// safety rule that release run 30437390088 found missing: the MultiSkill e2e
|
||||
// contract asserts the exact phrase below inside the installed
|
||||
// dws-shared/SKILL.md, so removing it from the embedded skill source must
|
||||
// dingtalk-shared/SKILL.md, so removing it from the embedded skill source must
|
||||
// fail at PR time instead of at release time.
|
||||
func TestMultiSkillSharedContractKeepsAccountSafetyRule(t *testing.T) {
|
||||
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
|
||||
@@ -19,12 +19,12 @@ func TestMultiSkillSharedContractKeepsAccountSafetyRule(t *testing.T) {
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, "dws-shared", "SKILL.md"))
|
||||
data, err := os.ReadFile(filepath.Join(dir, "dingtalk-shared", "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read embedded dws-shared/SKILL.md: %v", err)
|
||||
t.Fatalf("read embedded dingtalk-shared/SKILL.md: %v", err)
|
||||
}
|
||||
const rule = "禁止选择第一项、最近登录或最近使用账号"
|
||||
if !strings.Contains(string(data), rule) {
|
||||
t.Fatalf("embedded dws-shared/SKILL.md lost the mandatory account safety rule %q", rule)
|
||||
t.Fatalf("embedded dingtalk-shared/SKILL.md lost the mandatory account safety rule %q", rule)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,43 +33,43 @@ func contains(ss []string, want string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// dws-shared must ship even when --skill narrows the set to a single product.
|
||||
// dingtalk-shared must ship even when --skill narrows the set to a single product.
|
||||
func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
|
||||
src := writeMultiSkillSrc(t, "dws-shared", "dingtalk-aitable", "dingtalk-calendar")
|
||||
src := writeMultiSkillSrc(t, "dingtalk-shared", "dingtalk-aitable", "dingtalk-calendar")
|
||||
all, err := listMultiSkillNames(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !contains(all, "dws-shared") {
|
||||
t.Fatalf("listMultiSkillNames did not enumerate dws-shared: %v", all)
|
||||
if !contains(all, "dingtalk-shared") {
|
||||
t.Fatalf("listMultiSkillNames did not enumerate dingtalk-shared: %v", all)
|
||||
}
|
||||
filtered, err := filterMultiSkillNames(all, []string{"aitable"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if contains(filtered, "dws-shared") {
|
||||
t.Fatalf("precondition: filter should drop dws-shared for -s aitable: %v", filtered)
|
||||
if contains(filtered, "dingtalk-shared") {
|
||||
t.Fatalf("precondition: filter should drop dingtalk-shared for -s aitable: %v", filtered)
|
||||
}
|
||||
final := ensureMandatorySharedSkill(filtered, all)
|
||||
if !contains(final, "dws-shared") {
|
||||
t.Fatalf("ensureMandatorySharedSkill must re-add dws-shared: %v", final)
|
||||
if !contains(final, "dingtalk-shared") {
|
||||
t.Fatalf("ensureMandatorySharedSkill must re-add dingtalk-shared: %v", final)
|
||||
}
|
||||
|
||||
// Actually install with the filtered+mandatory set and assert dws-shared landed.
|
||||
// Actually install with the filtered+mandatory set and assert dingtalk-shared landed.
|
||||
dest := t.TempDir()
|
||||
var out, errOut bytes.Buffer
|
||||
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
|
||||
t.Fatalf("install: %v (%s)", err, errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "dws-shared", "SKILL.md")); err != nil {
|
||||
t.Fatalf("dws-shared not installed with -s aitable: %v", err)
|
||||
if _, err := os.Stat(filepath.Join(dest, "dingtalk-shared", "SKILL.md")); err != nil {
|
||||
t.Fatalf("dingtalk-shared not installed with -s aitable: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "dingtalk-aitable", "SKILL.md")); err != nil {
|
||||
t.Fatalf("dingtalk-aitable not installed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// When the source has no dws-shared (older layout), nothing is forced.
|
||||
// When the source has no dingtalk-shared (older layout), nothing is forced.
|
||||
func TestP1SharedNoopWhenAbsent(t *testing.T) {
|
||||
src := writeMultiSkillSrc(t, "dingtalk-aitable")
|
||||
all, err := listMultiSkillNames(src)
|
||||
@@ -77,7 +77,7 @@ func TestP1SharedNoopWhenAbsent(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
final := ensureMandatorySharedSkill([]string{"dingtalk-aitable"}, all)
|
||||
if contains(final, "dws-shared") {
|
||||
t.Fatalf("must not invent dws-shared when source lacks it: %v", final)
|
||||
if contains(final, "dingtalk-shared") {
|
||||
t.Fatalf("must not invent dingtalk-shared when source lacks it: %v", final)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
|
||||
"attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
|
||||
"calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
|
||||
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
|
||||
"chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"},
|
||||
"chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
|
||||
"chat +category-create": {"chat", "+category-create", "--title", "Fixture Cat", "--yes"},
|
||||
@@ -37,6 +38,7 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
|
||||
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
|
||||
"chat +search-msg": {"chat", "+search-msg", "--group", "fixture-conversation", "--query", "fixture", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--no-enrich"},
|
||||
"chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--text", "hello fixture", "--yes"},
|
||||
"chat +unread-chats": {"chat", "+unread-chats", "--count", "7", "--exclude-muted"},
|
||||
"chat bot find": {"chat", "bot", "find", "--query", "fixture", "--limit", "7"},
|
||||
@@ -119,6 +121,7 @@ var paramAliasNewIMCases = []struct {
|
||||
emitted string
|
||||
canonical string
|
||||
}{
|
||||
{command: "chat +chat-messages", emitted: "chat", canonical: "group"},
|
||||
{command: "chat +bot-find", emitted: "name", canonical: "query"},
|
||||
{command: "chat bot find", emitted: "name", canonical: "query"},
|
||||
{command: "chat +bot-search", emitted: "query", canonical: "name"},
|
||||
@@ -129,6 +132,7 @@ var paramAliasNewIMCases = []struct {
|
||||
{command: "chat +messages-list-unread-conversations", emitted: "limit", canonical: "count"},
|
||||
{command: "chat +messages-list-unread-conversations", emitted: "size", canonical: "count"},
|
||||
{command: "chat +messages-send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
|
||||
{command: "chat +search-msg", emitted: "chat", canonical: "group"},
|
||||
{command: "chat +unread-chats", emitted: "limit", canonical: "count"},
|
||||
{command: "chat +unread-chats", emitted: "size", canonical: "count"},
|
||||
{command: "chat bot search", emitted: "query", canonical: "name"},
|
||||
@@ -169,7 +173,7 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
|
||||
}
|
||||
|
||||
func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
|
||||
func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
@@ -220,6 +224,7 @@ func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads
|
||||
if ctx == nil {
|
||||
t.Fatal("complete alias command skipped PreParse")
|
||||
}
|
||||
normalizeParamAliasVolatileDefaults(fixture.Command, canonicalCaller, aliasCaller)
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
|
||||
}
|
||||
@@ -252,7 +257,7 @@ func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
|
||||
func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
|
||||
activeAliases := 0
|
||||
for _, test := range paramAliasNewIMCases {
|
||||
test := test
|
||||
@@ -292,6 +297,7 @@ func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
|
||||
if ctx == nil {
|
||||
t.Fatal("complete alias command skipped PreParse")
|
||||
}
|
||||
normalizeParamAliasVolatileDefaults(test.command, canonicalCaller, aliasCaller)
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
|
||||
}
|
||||
@@ -302,6 +308,23 @@ func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// +chat-messages supplies the current wall-clock time when callers omit
|
||||
// --time. Alias equivalence concerns the resolved target and transport shape;
|
||||
// a suite crossing a second boundary must not make that default appear
|
||||
// alias-dependent.
|
||||
func normalizeParamAliasVolatileDefaults(command string, callers ...*paramAliasCaptureCaller) {
|
||||
if command != "chat +chat-messages" {
|
||||
return
|
||||
}
|
||||
for _, caller := range callers {
|
||||
for i := range caller.calls {
|
||||
if caller.calls[i].tool == "list_conversation_message_v2" || caller.calls[i].tool == "list_individual_chat_message" {
|
||||
delete(caller.calls[i].args, "time")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
|
||||
complete, ok := paramAliasCompleteCommands[command]
|
||||
if variants := paramAliasCompleteCommandVariants[command]; variants != nil {
|
||||
|
||||
@@ -1,496 +1,118 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
recoverySavePlan = (*recovery.Store).SavePlan
|
||||
recoverySaveAnalysis = (*recovery.Store).SaveAnalysis
|
||||
)
|
||||
const recoveryUnsupportedMessage = "dws recovery 不再支持:失败快照恢复计划/执行/闭环已下线,请改用 doctor / schema / 对应业务命令排查。"
|
||||
|
||||
func newRecoveryCommand(flags *GlobalFlags) *cobra.Command {
|
||||
var (
|
||||
planUseLast bool
|
||||
planEventID string
|
||||
executeUseLast bool
|
||||
executeEventID string
|
||||
finalEventID string
|
||||
finalOutcome string
|
||||
executionFile string
|
||||
)
|
||||
|
||||
runtime := newRecoveryRuntime(flags)
|
||||
type recoveryCompatNotice struct {
|
||||
Status string `json:"status"`
|
||||
Command string `json:"command"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// newRecoveryCommand keeps a visible Deprecated compatibility surface for
|
||||
// historical argv and Interface Integrity. Behavior is unchanged: every leaf
|
||||
// returns an explicit unsupported notice. Skills must not teach this path.
|
||||
func newRecoveryCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "recovery",
|
||||
Short: "错误恢复辅助命令",
|
||||
Long: "读取失败快照,生成恢复分析,并回写恢复结果。",
|
||||
Short: "不再支持:错误恢复辅助命令(兼容入口)",
|
||||
Long: "此命令组仅为历史 argv 兼容保留,不再读取失败快照或生成恢复计划。Skill / Agent 请勿引导此路径。",
|
||||
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
return printRecoveryUnsupported(cmd, "dws recovery")
|
||||
},
|
||||
}
|
||||
|
||||
planCmd := &cobra.Command{
|
||||
Use: "plan",
|
||||
Short: "基于失败快照生成恢复计划",
|
||||
Short: "不再支持:基于失败快照生成恢复计划",
|
||||
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
store := recovery.NewStore(defaultConfigDir())
|
||||
last, err := loadRecoverySnapshot(store, planUseLast, planEventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
planner := recovery.NewPlanner(runtime)
|
||||
plan := planner.PlanWithOptions(cmd.Context(), last.Context, recovery.PlanOptions{
|
||||
EventID: last.EventID,
|
||||
EnableDocSearch: true,
|
||||
})
|
||||
recovery.HydratePlanForEvent(last.EventID, last.Context, last.Replay, &plan)
|
||||
if err := recoverySavePlan(store, last.EventID, plan); err != nil {
|
||||
return fmt.Errorf("保存恢复计划失败: %w", err)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"event_id": last.EventID,
|
||||
"context": last.Context,
|
||||
"plan": plan,
|
||||
}
|
||||
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
|
||||
return printRecoveryUnsupported(cmd, "dws recovery plan")
|
||||
},
|
||||
}
|
||||
planCmd.Flags().BoolVar(&planUseLast, "last", false, "读取最近一次失败快照")
|
||||
planCmd.Flags().StringVar(&planEventID, "event-id", "", "按 event_id 读取失败快照")
|
||||
planCmd.Flags().Bool("last", false, "旧版兼容参数;recovery 不再支持")
|
||||
planCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
|
||||
|
||||
executeCmd := &cobra.Command{
|
||||
Use: "execute",
|
||||
Short: "生成面向 Agent 的恢复分析包",
|
||||
Short: "不再支持:生成面向 Agent 的恢复分析包",
|
||||
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
store := recovery.NewStore(defaultConfigDir())
|
||||
last, err := loadRecoverySnapshot(store, executeUseLast, executeEventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
planner := recovery.NewPlanner(runtime)
|
||||
executor := recovery.NewExecutor(planner, runtime)
|
||||
bundle := executor.Execute(cmd.Context(), *last)
|
||||
if err := recoverySaveAnalysis(store, last.EventID, bundle.Plan, bundle); err != nil {
|
||||
return fmt.Errorf("保存恢复分析失败: %w", err)
|
||||
}
|
||||
|
||||
return output.WriteCommandPayload(cmd, bundle, output.FormatJSON)
|
||||
return printRecoveryUnsupported(cmd, "dws recovery execute")
|
||||
},
|
||||
}
|
||||
executeCmd.Flags().BoolVar(&executeUseLast, "last", false, "读取最近一次失败快照")
|
||||
executeCmd.Flags().StringVar(&executeEventID, "event-id", "", "按 event_id 读取失败快照")
|
||||
executeCmd.Flags().Bool("last", false, "旧版兼容参数;recovery 不再支持")
|
||||
executeCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
|
||||
|
||||
finalizeCmd := &cobra.Command{
|
||||
Use: "finalize",
|
||||
Short: "回写恢复闭环结果",
|
||||
Short: "不再支持:回写恢复闭环结果",
|
||||
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if strings.TrimSpace(finalEventID) == "" {
|
||||
return fmt.Errorf("必须提供 --event-id")
|
||||
}
|
||||
if strings.TrimSpace(finalOutcome) == "" {
|
||||
return fmt.Errorf("必须提供 --outcome")
|
||||
}
|
||||
switch finalOutcome {
|
||||
case "recovered", "failed", "handoff":
|
||||
default:
|
||||
return fmt.Errorf("--outcome 仅支持 recovered|failed|handoff")
|
||||
}
|
||||
|
||||
store := recovery.NewStore(defaultConfigDir())
|
||||
var execution *recovery.RecoveryExecution
|
||||
if strings.TrimSpace(executionFile) != "" {
|
||||
loaded, err := loadRecoveryExecution(executionFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
execution = &loaded
|
||||
}
|
||||
if err := store.Finalize(finalEventID, finalOutcome, execution); err != nil {
|
||||
return fmt.Errorf("回写恢复结果失败: %w", err)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"event_id": finalEventID,
|
||||
"outcome": finalOutcome,
|
||||
"success": true,
|
||||
}
|
||||
if execution != nil {
|
||||
payload["execution_recorded"] = true
|
||||
}
|
||||
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
|
||||
return printRecoveryUnsupported(cmd, "dws recovery finalize")
|
||||
},
|
||||
}
|
||||
finalizeCmd.Flags().StringVar(&finalEventID, "event-id", "", "恢复事件 ID")
|
||||
finalizeCmd.Flags().StringVar(&finalOutcome, "outcome", "", "恢复结果: recovered|failed|handoff")
|
||||
finalizeCmd.Flags().StringVar(&executionFile, "execution-file", "", "Agent 执行详情 JSON 文件")
|
||||
finalizeCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
|
||||
finalizeCmd.Flags().String("outcome", "", "旧版兼容参数;recovery 不再支持")
|
||||
finalizeCmd.Flags().String("execution-file", "", "旧版兼容参数;recovery 不再支持")
|
||||
|
||||
cmd.AddCommand(planCmd, executeCmd, finalizeCmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func loadRecoverySnapshot(store *recovery.Store, useLast bool, eventID string) (*recovery.LastError, error) {
|
||||
if useLast && strings.TrimSpace(eventID) != "" {
|
||||
return nil, fmt.Errorf("--last 和 --event-id 不能同时使用")
|
||||
func printRecoveryUnsupported(cmd *cobra.Command, command string) error {
|
||||
notice := recoveryCompatNotice{
|
||||
Status: "unsupported",
|
||||
Command: command,
|
||||
Message: recoveryUnsupportedMessage,
|
||||
}
|
||||
switch {
|
||||
case useLast:
|
||||
last, err := store.LoadLastError()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取失败快照失败: %w", err)
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "", "json":
|
||||
if err := json.NewEncoder(cmd.OutOrStdout()).Encode(notice); err != nil {
|
||||
return err
|
||||
}
|
||||
return last, nil
|
||||
case strings.TrimSpace(eventID) != "":
|
||||
last, err := store.LoadErrorByEvent(strings.TrimSpace(eventID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取失败快照失败: %w", err)
|
||||
return apperrors.NewValidation(recoveryUnsupportedMessage)
|
||||
case "pretty":
|
||||
data, _ := json.MarshalIndent(notice, "", " ")
|
||||
if _, err := fmt.Fprintln(cmd.OutOrStdout(), string(data)); err != nil {
|
||||
return err
|
||||
}
|
||||
return last, nil
|
||||
return apperrors.NewValidation(recoveryUnsupportedMessage)
|
||||
default:
|
||||
return nil, fmt.Errorf("必须通过 --last 或 --event-id 指定失败快照")
|
||||
}
|
||||
}
|
||||
|
||||
func loadRecoveryExecution(path string) (recovery.RecoveryExecution, error) {
|
||||
var execution recovery.RecoveryExecution
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return execution, fmt.Errorf("读取恢复执行详情失败: %w", err)
|
||||
}
|
||||
var payload recoveryExecutionPayload
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return execution, fmt.Errorf("解析恢复执行详情失败: %w", err)
|
||||
}
|
||||
execution.Actions = append([]string(nil), payload.Actions...)
|
||||
if len(execution.Actions) == 0 && strings.TrimSpace(payload.Action) != "" {
|
||||
execution.Actions = []string{strings.TrimSpace(payload.Action)}
|
||||
}
|
||||
execution.Result = strings.TrimSpace(payload.Result)
|
||||
execution.ErrorSummary = strings.TrimSpace(payload.ErrorSummary)
|
||||
if execution.ErrorSummary == "" {
|
||||
execution.ErrorSummary = strings.TrimSpace(payload.Error)
|
||||
}
|
||||
|
||||
attempts, err := decodeRecoveryAttempts(payload.Attempts, execution.Actions, execution.Result, execution.ErrorSummary)
|
||||
if err != nil {
|
||||
return execution, fmt.Errorf("解析恢复执行详情失败: %w", err)
|
||||
}
|
||||
if len(attempts) == 0 && payload.Attempt > 0 {
|
||||
attempts = legacyRecoveryAttempts(payload.Attempt, execution.Actions, execution.Result, execution.ErrorSummary)
|
||||
}
|
||||
execution.Attempts = attempts
|
||||
return execution, nil
|
||||
}
|
||||
|
||||
type recoveryExecutionPayload struct {
|
||||
Action string `json:"action,omitempty"`
|
||||
Actions []string `json:"actions,omitempty"`
|
||||
Attempt int `json:"attempt,omitempty"`
|
||||
Attempts json.RawMessage `json:"attempts,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorSummary string `json:"error_summary,omitempty"`
|
||||
}
|
||||
|
||||
func decodeRecoveryAttempts(raw json.RawMessage, actions []string, result, errorSummary string) ([]recovery.RecoveryAttempt, error) {
|
||||
trimmed := strings.TrimSpace(string(raw))
|
||||
if trimmed == "" || trimmed == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "[") {
|
||||
var attempts []recovery.RecoveryAttempt
|
||||
if err := json.Unmarshal(raw, &attempts); err != nil {
|
||||
return nil, err
|
||||
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", notice.Command, notice.Message); err != nil {
|
||||
return err
|
||||
}
|
||||
return attempts, nil
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := json.Unmarshal(raw, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return legacyRecoveryAttempts(count, actions, result, errorSummary), nil
|
||||
}
|
||||
|
||||
func legacyRecoveryAttempts(count int, actions []string, result, errorSummary string) []recovery.RecoveryAttempt {
|
||||
if count <= 0 {
|
||||
return nil
|
||||
}
|
||||
summary := strings.TrimSpace(strings.Join(actions, ", "))
|
||||
if summary == "" {
|
||||
summary = "legacy execution attempt"
|
||||
}
|
||||
attempts := make([]recovery.RecoveryAttempt, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
attempts = append(attempts, recovery.RecoveryAttempt{
|
||||
CommandSummary: summary,
|
||||
Result: result,
|
||||
ErrorSummary: errorSummary,
|
||||
Source: "legacy_execution_file",
|
||||
})
|
||||
}
|
||||
return attempts
|
||||
}
|
||||
|
||||
type recoveryRuntime struct {
|
||||
transport *transport.Client
|
||||
flags *GlobalFlags
|
||||
}
|
||||
|
||||
func newRecoveryRuntime(flags *GlobalFlags) *recoveryRuntime {
|
||||
var httpClient *http.Client
|
||||
if flags != nil && flags.Timeout > 0 {
|
||||
httpClient = &http.Client{Timeout: time.Duration(flags.Timeout) * time.Second}
|
||||
}
|
||||
client := transport.NewClient(httpClient)
|
||||
client.ExtraHeaders = resolveIdentityHeaders()
|
||||
return &recoveryRuntime{
|
||||
transport: client,
|
||||
flags: flags,
|
||||
return apperrors.NewValidation(recoveryUnsupportedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *recoveryRuntime) Search(ctx context.Context, query string, rc recovery.RecoveryContext) (recovery.KnowledgeRetrieval, error) {
|
||||
const (
|
||||
searchPage = 1
|
||||
searchSize = 5
|
||||
)
|
||||
requestArgs := map[string]any{
|
||||
"keyword": query,
|
||||
"page": searchPage,
|
||||
"size": searchSize,
|
||||
}
|
||||
|
||||
retrieval := recovery.KnowledgeRetrieval{
|
||||
DocSearch: recovery.DocSearch{
|
||||
Provider: "open_platform_docs",
|
||||
Query: query,
|
||||
Page: searchPage,
|
||||
Size: searchSize,
|
||||
Status: "empty",
|
||||
Request: &recovery.ToolCallRecord{
|
||||
ServerID: "devdoc",
|
||||
ToolName: "search_open_platform_docs_rag",
|
||||
Arguments: cloneRecoveryArgs(requestArgs),
|
||||
},
|
||||
},
|
||||
}
|
||||
if r == nil || strings.TrimSpace(query) == "" {
|
||||
retrieval.DocSearch.Status = "skipped"
|
||||
return retrieval, nil
|
||||
}
|
||||
result, err := r.CallToolDirect(ctx, "devdoc", "search_open_platform_docs_rag", requestArgs)
|
||||
if result != nil {
|
||||
retrieval.DocSearch.Response = toRecoveryToolResponse(result)
|
||||
}
|
||||
if err != nil {
|
||||
retrieval.DocSearch.Status = "error"
|
||||
retrieval.DocSearch.Error = err.Error()
|
||||
return retrieval, err
|
||||
}
|
||||
|
||||
retrieval.DocSearch.Items = parseDocSearchItems(result)
|
||||
if len(retrieval.DocSearch.Items) > 0 {
|
||||
retrieval.DocSearch.Status = "success"
|
||||
retrieval.KBHits = rerankDocSearchHits(query, rc, retrieval.DocSearch.Items)
|
||||
}
|
||||
return retrieval, nil
|
||||
}
|
||||
|
||||
func (r *recoveryRuntime) CallToolDirect(ctx context.Context, serverID, toolName string, args map[string]any) (*transport.ToolCallResult, error) {
|
||||
if r == nil || r.transport == nil {
|
||||
return nil, fmt.Errorf("recovery runtime not initialized")
|
||||
}
|
||||
endpoint, err := r.resolveEndpoint(ctx, serverID, toolName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authToken, err := resolveRuntimeAuthToken(ctx, recoveryRuntimeToken(r.flags))
|
||||
if err != nil {
|
||||
return nil, tokenResolutionError(err)
|
||||
}
|
||||
tc := r.transport.WithAuth(authToken, resolveIdentityHeaders())
|
||||
result, err := tc.CallTool(ctx, endpoint, toolName, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.IsError {
|
||||
return &result, apperrors.NewAPI(
|
||||
extractMCPErrorMessage(result),
|
||||
apperrors.WithOperation("tools/call"),
|
||||
apperrors.WithReason("mcp_tool_error"),
|
||||
apperrors.WithServerKey(serverID),
|
||||
)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (r *recoveryRuntime) resolveEndpoint(_ context.Context, productID, toolName string) (string, error) {
|
||||
if endpoint, ok := directRuntimeEndpoint(productID, toolName); ok {
|
||||
return endpoint, nil
|
||||
}
|
||||
return "", endpointNotResolvedError(productID, toolName, "no dynamic endpoint registered for product or tool")
|
||||
}
|
||||
|
||||
func recoveryRuntimeToken(flags *GlobalFlags) string {
|
||||
if flags == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(flags.Token)
|
||||
}
|
||||
|
||||
func toRecoveryToolResponse(result *transport.ToolCallResult) *recovery.ToolResponse {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
response := &recovery.ToolResponse{IsError: result.IsError}
|
||||
if len(result.Blocks) > 0 {
|
||||
response.Content = make([]recovery.ToolResponseBlock, 0, len(result.Blocks))
|
||||
for _, block := range result.Blocks {
|
||||
response.Content = append(response.Content, recovery.ToolResponseBlock{
|
||||
Type: block.Type,
|
||||
Text: block.Text,
|
||||
})
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func parseDocSearchItems(result *transport.ToolCallResult) []recovery.DocSearchItem {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
if items := parseDocSearchItemsFromMap(result.Content); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
for _, block := range result.Blocks {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(block.Text), &payload); err == nil {
|
||||
if items := parseDocSearchItemsFromMap(payload); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseDocSearchItemsFromMap(payload map[string]any) []recovery.DocSearchItem {
|
||||
if len(payload) == 0 {
|
||||
return nil
|
||||
}
|
||||
if items := toDocSearchItems(payload["items"]); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
if data, ok := payload["data"].(map[string]any); ok {
|
||||
if items := toDocSearchItems(data["items"]); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
if result, ok := payload["result"].(map[string]any); ok {
|
||||
if items := toDocSearchItems(result["items"]); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toDocSearchItems(raw any) []recovery.DocSearchItem {
|
||||
list, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
items := make([]recovery.DocSearchItem, 0, len(list))
|
||||
for _, entry := range list {
|
||||
object, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item := recovery.DocSearchItem{}
|
||||
if title, ok := object["title"].(string); ok {
|
||||
item.Title = title
|
||||
}
|
||||
if url, ok := object["url"].(string); ok {
|
||||
item.URL = url
|
||||
}
|
||||
if desc, ok := object["desc"].(string); ok {
|
||||
item.Desc = desc
|
||||
}
|
||||
if item.Title != "" || item.URL != "" || item.Desc != "" {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func rerankDocSearchHits(query string, rc recovery.RecoveryContext, items []recovery.DocSearchItem) []recovery.KBHit {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
keywords := strings.Fields(strings.ToLower(strings.TrimSpace(query)))
|
||||
type scoredHit struct {
|
||||
hit recovery.KBHit
|
||||
score float64
|
||||
}
|
||||
scored := make([]scoredHit, 0, len(items))
|
||||
for _, item := range items {
|
||||
text := strings.ToLower(strings.Join(append([]string{
|
||||
item.Title,
|
||||
item.URL,
|
||||
item.Desc,
|
||||
rc.ToolName,
|
||||
}, rc.CommandPath...), " "))
|
||||
score := 0.0
|
||||
for _, keyword := range keywords {
|
||||
if strings.Contains(text, keyword) {
|
||||
score += 1
|
||||
}
|
||||
}
|
||||
scored = append(scored, scoredHit{
|
||||
hit: recovery.KBHit{
|
||||
Source: "open_platform_docs",
|
||||
Title: item.Title,
|
||||
URL: item.URL,
|
||||
Snippet: item.Desc,
|
||||
Score: score,
|
||||
},
|
||||
score: score,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(scored, func(i, j int) bool {
|
||||
return scored[i].score > scored[j].score
|
||||
})
|
||||
limit := len(scored)
|
||||
if limit > 3 {
|
||||
limit = 3
|
||||
}
|
||||
hits := make([]recovery.KBHit, 0, limit)
|
||||
for _, item := range scored[:limit] {
|
||||
hits = append(hits, item.hit)
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryDeprecatedUnsupportedShim(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
group := mustFindCommand(t, root, "recovery")
|
||||
if group.Hidden || group.Deprecated == "" || !group.Runnable() {
|
||||
t.Fatalf("recovery group contract: hidden=%v deprecated=%q runnable=%v", group.Hidden, group.Deprecated, group.Runnable())
|
||||
}
|
||||
|
||||
for _, leaf := range []string{"plan", "execute", "finalize"} {
|
||||
cmd := mustFindCommand(t, root, "recovery", leaf)
|
||||
if cmd.Hidden || cmd.Deprecated == "" || !cmd.Runnable() {
|
||||
t.Fatalf("recovery %s contract: hidden=%v deprecated=%q runnable=%v", leaf, cmd.Hidden, cmd.Deprecated, cmd.Runnable())
|
||||
}
|
||||
wantFlags := []string{"event-id"}
|
||||
switch leaf {
|
||||
case "plan", "execute":
|
||||
wantFlags = append(wantFlags, "last")
|
||||
case "finalize":
|
||||
wantFlags = append(wantFlags, "outcome", "execution-file")
|
||||
}
|
||||
for _, flag := range wantFlags {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Fatalf("recovery %s missing --%s", leaf, flag)
|
||||
}
|
||||
}
|
||||
for _, child := range newRecoveryCommand().Commands() {
|
||||
if child.Name() != leaf {
|
||||
continue
|
||||
}
|
||||
if err := child.RunE(child, nil); err == nil || !strings.Contains(err.Error(), "不再支持") {
|
||||
t.Fatalf("recovery %s RunE = %v, want 不再支持", leaf, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, format := range []string{"", "json", "pretty", "table"} {
|
||||
var out bytes.Buffer
|
||||
cmd := &cobra.Command{Use: "dws"}
|
||||
cmd.PersistentFlags().String("format", format, "")
|
||||
cmd.SetOut(&out)
|
||||
sub := &cobra.Command{Use: "recovery"}
|
||||
cmd.AddCommand(sub)
|
||||
err := printRecoveryUnsupported(sub, "dws recovery plan")
|
||||
if err == nil {
|
||||
t.Fatalf("format=%q returned nil error", format)
|
||||
}
|
||||
typed, ok := err.(*apperrors.Error)
|
||||
if !ok || typed.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("format=%q error = %T/%v, want validation Error", format, err, err)
|
||||
}
|
||||
got := out.String() + err.Error()
|
||||
if !strings.Contains(got, "不再支持") {
|
||||
t.Fatalf("format=%q missing 不再支持:\n%s", format, got)
|
||||
}
|
||||
if format == "" || format == "json" || format == "pretty" {
|
||||
if !strings.Contains(got, `"status":"unsupported"`) && !strings.Contains(got, `"status": "unsupported"`) {
|
||||
t.Fatalf("format=%q missing unsupported JSON status:\n%s", format, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, format := range []string{"json", "pretty", "table"} {
|
||||
cmd := &cobra.Command{Use: "dws"}
|
||||
cmd.PersistentFlags().String("format", format, "")
|
||||
cmd.SetOut(failWriter{})
|
||||
sub := &cobra.Command{Use: "recovery"}
|
||||
cmd.AddCommand(sub)
|
||||
if err := printRecoveryUnsupported(sub, "dws recovery plan"); err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("format=%q write failure = %v, want write failed", format, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := newRecoveryCommand().RunE(newRecoveryCommand(), nil); err == nil || !strings.Contains(err.Error(), "不再支持") {
|
||||
t.Fatalf("recovery parent RunE = %v, want 不再支持", err)
|
||||
}
|
||||
captureRuntimeFailure(executor.Invocation{}, nil, nil)
|
||||
}
|
||||
|
||||
type failWriter struct{}
|
||||
|
||||
func (failWriter) Write([]byte) (int, error) {
|
||||
return 0, errWriteFailed
|
||||
}
|
||||
|
||||
var errWriteFailed = errors.New("write failed")
|
||||
@@ -1,151 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
func recoveryCoverageRun(cmdArgs ...string) (string, error) {
|
||||
cmd := newRecoveryCommand(&GlobalFlags{})
|
||||
out := &strings.Builder{}
|
||||
cmd.SetOut(out)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(cmdArgs)
|
||||
err := cmd.Execute()
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryCommandRemainingCoverage(t *testing.T) {
|
||||
oldSavePlan, oldSaveAnalysis := recoverySavePlan, recoverySaveAnalysis
|
||||
t.Cleanup(func() {
|
||||
recoverySavePlan, recoverySaveAnalysis = oldSavePlan, oldSaveAnalysis
|
||||
})
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
store := recovery.NewStore(configDir)
|
||||
last, err := store.Capture(recovery.RecoveryContext{ServerID: "doc", ToolName: "get"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recoverySavePlan = func(*recovery.Store, string, recovery.RecoveryPlan) error { return errors.New("save plan") }
|
||||
if _, err := recoveryCoverageRun("plan", "--last"); err == nil {
|
||||
t.Fatal("injected plan save failure succeeded")
|
||||
}
|
||||
recoverySavePlan = oldSavePlan
|
||||
recoverySaveAnalysis = func(*recovery.Store, string, recovery.RecoveryPlan, recovery.RecoveryBundle) error {
|
||||
return errors.New("save analysis")
|
||||
}
|
||||
if _, err := recoveryCoverageRun("execute", "--last"); err == nil {
|
||||
t.Fatal("injected analysis save failure succeeded")
|
||||
}
|
||||
recoverySaveAnalysis = oldSaveAnalysis
|
||||
|
||||
parent := newRecoveryCommand(nil)
|
||||
parent.SetOut(io.Discard)
|
||||
if err := parent.RunE(parent, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, err := recoveryCoverageRun("plan", "--last"); err != nil || !strings.Contains(out, last.EventID) {
|
||||
t.Fatalf("recovery plan = %q, %v", out, err)
|
||||
}
|
||||
if out, err := recoveryCoverageRun("execute", "--event-id", last.EventID); err != nil || out == "" {
|
||||
t.Fatalf("recovery execute = %q, %v", out, err)
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"finalize"},
|
||||
{"finalize", "--event-id", last.EventID},
|
||||
{"finalize", "--event-id", last.EventID, "--outcome", "unknown"},
|
||||
{"finalize", "--event-id", last.EventID, "--outcome", "recovered", "--execution-file", "missing"},
|
||||
} {
|
||||
if _, err := recoveryCoverageRun(args...); err == nil {
|
||||
t.Fatalf("recovery finalize %#v should fail", args)
|
||||
}
|
||||
}
|
||||
executionPath := filepath.Join(t.TempDir(), "execution.json")
|
||||
if err := os.WriteFile(executionPath, []byte(`{"action":"retry","attempt":1,"result":"ok"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "handoff", "--execution-file", executionPath); err != nil || !strings.Contains(out, "execution_recorded") {
|
||||
t.Fatalf("recovery finalize = %q, %v", out, err)
|
||||
}
|
||||
if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "failed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := loadRecoverySnapshot(store, true, last.EventID); err == nil {
|
||||
t.Fatal("conflicting snapshot selectors should fail")
|
||||
}
|
||||
if _, err := loadRecoverySnapshot(store, false, "missing"); err == nil {
|
||||
t.Fatal("missing event snapshot should fail")
|
||||
}
|
||||
if _, err := loadRecoverySnapshot(store, false, ""); err == nil {
|
||||
t.Fatal("empty snapshot selector should fail")
|
||||
}
|
||||
missingStore := recovery.NewStore(t.TempDir())
|
||||
if _, err := loadRecoverySnapshot(missingStore, true, ""); err == nil {
|
||||
t.Fatal("missing latest snapshot should fail")
|
||||
}
|
||||
|
||||
eventsPath := filepath.Join(configDir, "recovery", "recovery_events.jsonl")
|
||||
if err := os.Remove(eventsPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(eventsPath, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := recoveryCoverageRun("plan", "--last"); err == nil {
|
||||
t.Fatal("recovery plan save should fail")
|
||||
}
|
||||
if _, err := recoveryCoverageRun("execute", "--last"); err == nil {
|
||||
t.Fatal("recovery analysis save should fail")
|
||||
}
|
||||
if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "recovered"); err == nil {
|
||||
t.Fatal("recovery finalization save should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryExecutionAndRuntimeRemainingCoverage(t *testing.T) {
|
||||
t.Setenv("DINGTALK_DEVDOC_MCP_URL", "http://127.0.0.1:1")
|
||||
path := filepath.Join(t.TempDir(), "execution.json")
|
||||
if err := os.WriteFile(path, []byte(`{"attempts":{}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadRecoveryExecution(path); err == nil {
|
||||
t.Fatal("invalid attempts should fail")
|
||||
}
|
||||
if _, err := decodeRecoveryAttempts([]byte(`[{}`), nil, "", ""); err == nil {
|
||||
t.Fatal("invalid attempt array should fail")
|
||||
}
|
||||
|
||||
SetDynamicServers(nil)
|
||||
runtime := &recoveryRuntime{
|
||||
transport: transport.NewClient(nil),
|
||||
flags: &GlobalFlags{Token: "token"},
|
||||
}
|
||||
if _, err := runtime.CallToolDirect(context.Background(), "missing", "tool", nil); err == nil || !strings.Contains(err.Error(), `endpoint not resolved for product "missing" (tool "tool")`) {
|
||||
t.Fatalf("direct resolution error = %v", err)
|
||||
}
|
||||
if got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{}); err == nil || got.DocSearch.Status != "error" {
|
||||
t.Fatalf("search error = %#v, %v", got, err)
|
||||
}
|
||||
if got := parseDocSearchItems(&transport.ToolCallResult{Content: map[string]any{}, Blocks: []transport.ContentBlock{{Text: "not-json"}}}); got != nil {
|
||||
t.Fatalf("empty doc search items = %#v", got)
|
||||
}
|
||||
for _, payload := range []map[string]any{
|
||||
{"data": map[string]any{}},
|
||||
{"result": map[string]any{}},
|
||||
} {
|
||||
if got := parseDocSearchItemsFromMap(payload); got != nil {
|
||||
t.Fatalf("empty nested doc search items = %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,94 +1,10 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
)
|
||||
|
||||
func captureRuntimeFailure(invocation executor.Invocation, rawErr, wrappedErr error) {
|
||||
if rawErr == nil && wrappedErr == nil {
|
||||
return
|
||||
}
|
||||
store := recovery.NewStore(defaultConfigDir())
|
||||
if store == nil || !store.Enabled() {
|
||||
return
|
||||
}
|
||||
input := recovery.CaptureInput{
|
||||
CommandPath: runtimeCommandPath(invocation),
|
||||
ServerID: strings.TrimSpace(invocation.CanonicalProduct),
|
||||
ToolName: strings.TrimSpace(invocation.Tool),
|
||||
Args: cloneRecoveryArgs(invocation.Params),
|
||||
Argv: append([]string(nil), os.Args[1:]...),
|
||||
RawErr: rawErr,
|
||||
WrappedErr: wrappedErr,
|
||||
}
|
||||
_, _ = store.Capture(recovery.BuildContext(input), recovery.BuildReplay(input))
|
||||
}
|
||||
|
||||
func runtimeCommandPath(invocation executor.Invocation) []string {
|
||||
if path := currentCommandPath(); len(path) > 0 {
|
||||
return path
|
||||
}
|
||||
if legacy := strings.Fields(strings.TrimSpace(invocation.LegacyPath)); len(legacy) > 0 {
|
||||
return legacy
|
||||
}
|
||||
if product := strings.TrimSpace(invocation.CanonicalProduct); product != "" {
|
||||
if tool := strings.TrimSpace(invocation.Tool); tool != "" {
|
||||
return []string{product, tool}
|
||||
}
|
||||
return []string{product}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func currentCommandPath() []string {
|
||||
boolFlags := map[string]struct{}{
|
||||
"--verbose": {},
|
||||
"-v": {},
|
||||
"--debug": {},
|
||||
"--mock": {},
|
||||
"--dry-run": {},
|
||||
"--yes": {},
|
||||
"-y": {},
|
||||
"--help": {},
|
||||
"-h": {},
|
||||
"--json": {},
|
||||
}
|
||||
path := make([]string, 0, len(os.Args))
|
||||
skipNext := false
|
||||
for _, arg := range os.Args[1:] {
|
||||
if skipNext {
|
||||
skipNext = false
|
||||
continue
|
||||
}
|
||||
if arg == "--" {
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
if strings.Contains(arg, "=") {
|
||||
continue
|
||||
}
|
||||
if _, ok := boolFlags[arg]; ok {
|
||||
continue
|
||||
}
|
||||
skipNext = true
|
||||
continue
|
||||
}
|
||||
path = append(path, arg)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func cloneRecoveryArgs(args map[string]any) map[string]any {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
// captureRuntimeFailure previously persisted a recovery snapshot for
|
||||
// `dws recovery`. The recovery package is gone; keep a no-op seam so runner
|
||||
// failure paths stay stable while the visible Deprecated shim remains.
|
||||
func captureRuntimeFailure(_ executor.Invocation, _, _ error) {}
|
||||
|
||||
+3
-12
@@ -39,7 +39,6 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
@@ -51,15 +50,11 @@ import (
|
||||
|
||||
type outputFileContextKey struct{}
|
||||
|
||||
const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID="
|
||||
|
||||
var (
|
||||
rootNormalizeProcessProfileArgs = normalizeProcessProfileArgs
|
||||
rootExecuteCommand = (*cobra.Command).ExecuteC
|
||||
rootNewRootCommandWithEngine = NewRootCommandWithEngine
|
||||
rootRunPreParse = pipeline.RunPreParse
|
||||
rootLatestRecoveryCapture = recovery.LatestCapture
|
||||
rootResetRecoveryState = recovery.ResetRuntimeState
|
||||
rootStopAllStdioClients = StopAllStdioClients
|
||||
rootLoadPlugins = loadPlugins
|
||||
rootMkdirAll = os.MkdirAll
|
||||
@@ -107,7 +102,6 @@ func Execute() (exitCode int) {
|
||||
ctx = WithTimingCollector(ctx, timing)
|
||||
|
||||
initStart := time.Now()
|
||||
rootResetRecoveryState()
|
||||
engine := newPipelineEngine()
|
||||
root := rootNewRootCommandWithEngine(ctx, engine)
|
||||
timing.Record("cmd_init", time.Since(initStart))
|
||||
@@ -133,9 +127,6 @@ func Execute() (exitCode int) {
|
||||
_, _ = fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
|
||||
if last := rootLatestRecoveryCapture(); last != nil && last.EventID != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "%s%s\n", recoveryEventStderrPrefix, last.EventID)
|
||||
}
|
||||
return apperrors.ExitCode(err)
|
||||
}
|
||||
return 0
|
||||
@@ -477,10 +468,10 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
newCatalogCommand(),
|
||||
newConfigCommand(),
|
||||
newDoctorCommand(),
|
||||
newRecoveryCommand(),
|
||||
newEventCommand(),
|
||||
newAuditCommand(),
|
||||
newCompletionCommand(root),
|
||||
newRecoveryCommand(flags),
|
||||
newUpgradeCommand(),
|
||||
newVersionCommand(),
|
||||
newPluginCommand(),
|
||||
@@ -724,8 +715,8 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
var builtinCommandNames = map[string]bool{
|
||||
"auth": true, "api": true, "audit": true, "cache": true, "config": true,
|
||||
"doctor": true, "event": true, "completion": true, "skill": true,
|
||||
"plugin": true, "profile": true, "version": true, "help": true,
|
||||
"recovery": true, "schema": true, "mcp": true, "upgrade": true,
|
||||
"plugin": true, "profile": true, "recovery": true, "version": true, "help": true,
|
||||
"schema": true, "mcp": true, "upgrade": true,
|
||||
}
|
||||
|
||||
// commandNameSet returns a new set containing every name in base plus extras.
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
@@ -24,8 +23,6 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
oldPreParse := rootRunPreParse
|
||||
oldLatest := rootLatestRecoveryCapture
|
||||
oldReset := rootResetRecoveryState
|
||||
oldStop := rootStopAllStdioClients
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
@@ -33,20 +30,16 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
|
||||
rootExecuteCommand = oldExecute
|
||||
rootNewRootCommandWithEngine = oldNewRoot
|
||||
rootRunPreParse = oldPreParse
|
||||
rootLatestRecoveryCapture = oldLatest
|
||||
rootResetRecoveryState = oldReset
|
||||
rootStopAllStdioClients = oldStop
|
||||
os.Args = oldArgs
|
||||
})
|
||||
os.Args = []string{"dws"}
|
||||
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
rootResetRecoveryState = func() {}
|
||||
rootStopAllStdioClients = func() {}
|
||||
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
|
||||
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
}
|
||||
rootLatestRecoveryCapture = func() *recovery.LastError { return nil }
|
||||
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil }
|
||||
if code := Execute(); code != 0 {
|
||||
t.Fatalf("successful Execute code = %d", code)
|
||||
@@ -59,7 +52,6 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
|
||||
wantErr := errors.New("unknown command missing")
|
||||
rootLatestRecoveryCapture = func() *recovery.LastError { return &recovery.LastError{EventID: "evt-test"} }
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr }
|
||||
if code := Execute(); code == 0 {
|
||||
t.Fatal("failed Execute returned zero")
|
||||
|
||||
@@ -72,6 +72,39 @@ func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) {
|
||||
|
||||
func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
for _, path := range []string{
|
||||
"chat send",
|
||||
"chat history",
|
||||
"im send",
|
||||
"im history",
|
||||
} {
|
||||
command, remaining, err := root.Find(strings.Fields(path))
|
||||
if err != nil {
|
||||
t.Fatalf("find %s: %v", path, err)
|
||||
}
|
||||
if len(remaining) != 0 || !command.Hidden || !command.Runnable() {
|
||||
t.Fatalf("%s compatibility contract: remaining=%v hidden=%v runnable=%v", path, remaining, command.Hidden, command.Runnable())
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
hint string
|
||||
}{
|
||||
{args: []string{"chat", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{args: []string{"im", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
} {
|
||||
command := NewRootCommand()
|
||||
command.SilenceErrors = true
|
||||
command.SilenceUsage = true
|
||||
command.SetArgs(tc.args)
|
||||
err := command.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
|
||||
t.Fatalf("dws %s error = %v, want migration hint %q", strings.Join(tc.args, " "), err, tc.hint)
|
||||
}
|
||||
}
|
||||
|
||||
listDirect := mustFindCommand(t, root, "chat", "message", "list-direct")
|
||||
for _, flag := range []string{"user", "open-dingtalk-id", "time", "forward", "limit"} {
|
||||
if listDirect.Flags().Lookup(flag) == nil {
|
||||
@@ -386,20 +419,14 @@ func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheRefreshCompatibilityStub(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"cache", "refresh", "--format", "json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("cache refresh compatibility stub: %v\n%s", err, out.String())
|
||||
func TestCacheCommandDeprecatedCompatStub(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
cmd, _, err := root.Find([]string{"cache", "refresh"})
|
||||
if err != nil || cmd == nil || cmd == root {
|
||||
t.Fatalf("dws cache refresh compatibility stub missing: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{`"status":"deprecated"`, `"command":"dws cache refresh"`, "服务发现已下线"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("cache refresh output missing %q:\n%s", want, got)
|
||||
}
|
||||
if cmd.Hidden || cmd.Deprecated == "" {
|
||||
t.Fatalf("cache refresh must be visible Deprecated: hidden=%v deprecated=%q", cmd.Hidden, cmd.Deprecated)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+41
-14
@@ -413,6 +413,33 @@ func multiProfileErrorPayload(err error) map[string]any {
|
||||
if typed.Operation != "" {
|
||||
payload["operation"] = typed.Operation
|
||||
}
|
||||
if typed.Origin != "" {
|
||||
payload["origin"] = typed.Origin
|
||||
}
|
||||
if typed.FailureStage != "" {
|
||||
payload["stage"] = typed.FailureStage
|
||||
}
|
||||
if typed.ExecutionStarted != nil {
|
||||
payload["execution_started"] = *typed.ExecutionStarted
|
||||
}
|
||||
if typed.RetryableSet {
|
||||
payload["retryable"] = typed.Retryable
|
||||
}
|
||||
if typed.Hint != "" {
|
||||
payload["hint"] = typed.Hint
|
||||
}
|
||||
if len(typed.Actions) > 0 {
|
||||
payload["actions"] = append([]string(nil), typed.Actions...)
|
||||
}
|
||||
if len(typed.Details) > 0 {
|
||||
payload["details"] = typed.Details
|
||||
}
|
||||
if typed.ServerDiag.TraceID != "" {
|
||||
payload["trace_id"] = typed.ServerDiag.TraceID
|
||||
}
|
||||
if typed.ServerDiag.ServerErrorCode != "" {
|
||||
payload["server_error_code"] = typed.ServerDiag.ServerErrorCode
|
||||
}
|
||||
if code := typed.ExitCode(); code != 0 {
|
||||
payload["exitCode"] = code
|
||||
}
|
||||
@@ -678,7 +705,6 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
|
||||
if callResult.IsError {
|
||||
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
|
||||
logBusinessError(r.transport.FileLogger, "mcp_tool_error", invocation, callResult.Content, diag)
|
||||
|
||||
// ClassifyToolResult hook: let the overlay intercept known error
|
||||
// patterns (PAT permission, gateway-auth) before generic handling.
|
||||
@@ -695,14 +721,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
}
|
||||
}
|
||||
|
||||
mcpErr := apperrors.NewAPI(
|
||||
mcpErr := newServerFailureAPIError(
|
||||
extractMCPErrorMessage(callResult),
|
||||
apperrors.WithOperation("tools/call"),
|
||||
apperrors.WithReason("mcp_tool_error"),
|
||||
apperrors.WithServerKey(invocation.CanonicalProduct),
|
||||
apperrors.WithHint("MCP tool returned a business error; check tool parameters and refer to skill documentation."),
|
||||
apperrors.WithServerDiag(diag),
|
||||
"mcp_tool_error",
|
||||
"MCP tool returned a business error; check tool parameters and refer to skill documentation.",
|
||||
invocation.CanonicalProduct,
|
||||
diag,
|
||||
)
|
||||
logBusinessError(r.transport.FileLogger, serverFailureReason(mcpErr, "mcp_tool_error"), invocation, callResult.Content, diag)
|
||||
// PAT scope error in business response: offer human-readable output and retry
|
||||
if isPatScopeError(mcpErr) {
|
||||
scopeErr := extractPatScopeError(mcpErr)
|
||||
@@ -720,14 +746,15 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
|
||||
if bizErr := detectBusinessError(callResult.Content); bizErr != "" {
|
||||
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
|
||||
logBusinessError(r.transport.FileLogger, "business_error", invocation, callResult.Content, diag)
|
||||
return executor.Result{}, apperrors.NewAPI(bizErr,
|
||||
apperrors.WithOperation("tools/call"),
|
||||
apperrors.WithReason("business_error"),
|
||||
apperrors.WithServerKey(invocation.CanonicalProduct),
|
||||
apperrors.WithHint("The API returned a business-level error. Check required parameters and values."),
|
||||
apperrors.WithServerDiag(diag),
|
||||
classifiedErr := newServerFailureAPIError(
|
||||
bizErr,
|
||||
"business_error",
|
||||
"The API returned a business-level error. Check required parameters and values.",
|
||||
invocation.CanonicalProduct,
|
||||
diag,
|
||||
)
|
||||
logBusinessError(r.transport.FileLogger, serverFailureReason(classifiedErr, "business_error"), invocation, callResult.Content, diag)
|
||||
return executor.Result{}, classifiedErr
|
||||
}
|
||||
|
||||
invocation.Implemented = true
|
||||
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 266
|
||||
publicShortcutCount = 294
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including hidden leaves such as minutes.shortcut_minutes_search.
|
||||
schemaPublishedShortcutCount = 216
|
||||
schemaPublishedShortcutCount = 295
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 215
|
||||
publiclyDeliveredShortcutCount = 294
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
@@ -114,7 +114,7 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
|
||||
product := executeShortcutSchemaQuery(t, "chat")
|
||||
productPayload, _ := product["product"].(map[string]any)
|
||||
if got, want := int(product["count"].(float64)), 129; got != want {
|
||||
if got, want := int(product["count"].(float64)), 180; got != want {
|
||||
t.Fatalf("schema chat count = %d, want %d", got, want)
|
||||
}
|
||||
summaries := schemaContractObjectSlice(productPayload["tools"])
|
||||
@@ -124,8 +124,8 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
shortcutCount++
|
||||
}
|
||||
}
|
||||
if shortcutCount != 47 {
|
||||
t.Fatalf("schema chat shortcut summaries = %d, want 47", shortcutCount)
|
||||
if shortcutCount != 98 {
|
||||
t.Fatalf("schema chat shortcut summaries = %d, want 98", shortcutCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,17 +194,9 @@ func assertDeliveryShortcutSafetyAndInterface(
|
||||
canonical string,
|
||||
) {
|
||||
t.Helper()
|
||||
risk := declared.Risk
|
||||
if risk == "" {
|
||||
risk = shortcut.RiskRead
|
||||
}
|
||||
wantEffect, wantRisk, wantConfirmation, wantIdempotency := "read", "low", "not_required", "idempotent"
|
||||
switch risk {
|
||||
case shortcut.RiskWrite:
|
||||
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "write", "medium", "user_required", "unknown"
|
||||
case shortcut.RiskHighWrite:
|
||||
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "destructive", "high", "user_required", "unknown"
|
||||
}
|
||||
safety := shortcut.EffectiveSafety(declared)
|
||||
wantEffect, wantRisk := safety.Effect, safety.Risk
|
||||
wantConfirmation, wantIdempotency := safety.Confirmation, safety.Idempotency
|
||||
for field, want := range map[string]string{
|
||||
"effect": wantEffect,
|
||||
"risk": wantRisk,
|
||||
@@ -234,6 +226,15 @@ func assertDeliveryShortcutParameters(
|
||||
for _, flag := range declared.Flags {
|
||||
if !flag.Hidden {
|
||||
publicFlags = append(publicFlags, flag)
|
||||
if flag.AliasesVisible {
|
||||
for _, alias := range flag.Aliases {
|
||||
aliasFlag := flag
|
||||
aliasFlag.Name = alias
|
||||
aliasFlag.Default = ""
|
||||
aliasFlag.Aliases = nil
|
||||
publicFlags = append(publicFlags, aliasFlag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if got, want := len(parameters), len(publicFlags); got != want {
|
||||
@@ -299,6 +300,13 @@ func shortcutSchemaRequired(declared shortcut.Shortcut, flagName string) bool {
|
||||
if flag.Name == flagName && flag.Required {
|
||||
return true
|
||||
}
|
||||
if flag.Required && flag.AliasesVisible {
|
||||
for _, alias := range flag.Aliases {
|
||||
if alias == flagName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
public := make(map[string]bool, len(declared.Flags))
|
||||
for _, flag := range declared.Flags {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// 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 (
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
type serverFailureClass struct {
|
||||
message string
|
||||
reason string
|
||||
origin string
|
||||
stage string
|
||||
hint string
|
||||
actions []string
|
||||
}
|
||||
|
||||
func classifyServerFailure(message string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
|
||||
code := strings.ToUpper(strings.TrimSpace(diag.ServerErrorCode))
|
||||
detail := strings.ToLower(strings.TrimSpace(diag.TechnicalDetail))
|
||||
text := strings.ToLower(strings.TrimSpace(message))
|
||||
|
||||
if code == "NETWORK_ERROR" ||
|
||||
strings.Contains(detail, "statuscode.unavailable") ||
|
||||
strings.Contains(detail, "connection refused") {
|
||||
classified := serverFailureClass{
|
||||
message: "MCP 后端依赖暂时不可用",
|
||||
reason: "backend_dependency_unavailable",
|
||||
origin: "mcp_gateway",
|
||||
stage: "backend_dependency",
|
||||
hint: "请求参数无需修改;请使用相同参数稍后重试。持续失败时请提供 Trace ID 排查 MCP 服务。",
|
||||
actions: []string{
|
||||
"使用相同参数重试一次",
|
||||
"持续失败时保留 Trace ID 并排查 MCP 后端依赖",
|
||||
},
|
||||
}
|
||||
if strings.Contains(detail, "querytoolmeta") {
|
||||
classified.message = "MCP 后端元数据服务暂时不可用"
|
||||
classified.stage = "tool_metadata_lookup"
|
||||
}
|
||||
return classified, true
|
||||
}
|
||||
|
||||
if code == "PARAM_ERROR" ||
|
||||
strings.Contains(text, "opencid or cid is required") ||
|
||||
strings.Contains(text, "openconversationid") && strings.Contains(text, "required") {
|
||||
return serverFailureClass{
|
||||
message: message,
|
||||
reason: "invalid_request",
|
||||
origin: "dingtalk_api",
|
||||
stage: "tool_validation",
|
||||
hint: "请求未通过后端参数校验;请核对当前 leaf Help/Schema 和稳定 ID 类型后重试。",
|
||||
}, true
|
||||
}
|
||||
|
||||
return serverFailureClass{}, false
|
||||
}
|
||||
|
||||
func newServerFailureAPIError(
|
||||
message string,
|
||||
fallbackReason string,
|
||||
fallbackHint string,
|
||||
serverKey string,
|
||||
diag apperrors.ServerDiagnostics,
|
||||
) error {
|
||||
opts := []apperrors.Option{
|
||||
apperrors.WithOperation("tools/call"),
|
||||
apperrors.WithReason(fallbackReason),
|
||||
apperrors.WithServerKey(serverKey),
|
||||
apperrors.WithHint(fallbackHint),
|
||||
apperrors.WithServerDiag(diag),
|
||||
}
|
||||
if classified, ok := classifyServerFailure(message, diag); ok {
|
||||
message = classified.message
|
||||
opts = append(opts,
|
||||
apperrors.WithReason(classified.reason),
|
||||
apperrors.WithOrigin(classified.origin),
|
||||
apperrors.WithFailureStage(classified.stage),
|
||||
apperrors.WithHint(classified.hint),
|
||||
apperrors.WithActions(classified.actions...),
|
||||
)
|
||||
}
|
||||
return apperrors.NewAPI(message, opts...)
|
||||
}
|
||||
|
||||
func serverFailureReason(err error, fallback string) string {
|
||||
typed, ok := err.(*apperrors.Error)
|
||||
if ok && strings.TrimSpace(typed.Reason) != "" {
|
||||
return typed.Reason
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// 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"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageServerFailureClassifierBackendMetadataUnavailable(t *testing.T) {
|
||||
retryable := true
|
||||
err := newServerFailureAPIError(
|
||||
"business error: success=false",
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
apperrors.ServerDiagnostics{
|
||||
TraceID: "trace-local",
|
||||
ServerErrorCode: "NETWORK_ERROR",
|
||||
TechnicalDetail: "调用 McpService.queryToolMeta 失败: status = StatusCode.UNAVAILABLE; connect: Connection refused (111)",
|
||||
ServerRetryable: &retryable,
|
||||
},
|
||||
)
|
||||
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("error = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Reason != "backend_dependency_unavailable" || typed.Origin != "mcp_gateway" || typed.FailureStage != "tool_metadata_lookup" {
|
||||
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
|
||||
}
|
||||
if typed.ExecutionStarted != nil {
|
||||
t.Fatalf("execution_started = %v, want unknown until the backend publishes it", typed.ExecutionStarted)
|
||||
}
|
||||
if !typed.RetryableSet || !typed.Retryable {
|
||||
t.Fatalf("retryability = (%v, %v), want explicit true", typed.RetryableSet, typed.Retryable)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(typed.Hint), "parameter") || strings.Contains(typed.Hint, "认证") {
|
||||
t.Fatalf("misleading hint = %q", typed.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *testing.T) {
|
||||
err := newServerFailureAPIError(
|
||||
"openCid or cid is required",
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"chat",
|
||||
apperrors.ServerDiagnostics{ServerErrorCode: "1001"},
|
||||
)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("error = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Reason != "invalid_request" || typed.FailureStage != "tool_validation" {
|
||||
t.Fatalf("classification = reason %q stage %q", typed.Reason, typed.FailureStage)
|
||||
}
|
||||
if typed.ExecutionStarted != nil {
|
||||
t.Fatalf("execution_started = %v, want unknown until the backend publishes it", typed.ExecutionStarted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing.T) {
|
||||
err := newServerFailureAPIError(
|
||||
"business error: success=false",
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
apperrors.ServerDiagnostics{},
|
||||
)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("error = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Reason != "business_error" || typed.Origin != "" || typed.FailureStage != "" || typed.ExecutionStarted != nil {
|
||||
t.Fatalf("unexpected fallback classification: %#v", typed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *testing.T) {
|
||||
err := newServerFailureAPIError(
|
||||
"business error: success=false",
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
apperrors.ServerDiagnostics{ServerErrorCode: "NETWORK_ERROR"},
|
||||
)
|
||||
if got := serverFailureReason(err, "business_error"); got != "backend_dependency_unavailable" {
|
||||
t.Fatalf("reason = %q", got)
|
||||
}
|
||||
if got := serverFailureReason(errors.New("plain"), "fallback"); got != "fallback" {
|
||||
t.Fatalf("fallback reason = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesFailureSemantics(t *testing.T) {
|
||||
retryable := true
|
||||
err := newServerFailureAPIError(
|
||||
"business error: success=false",
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
apperrors.ServerDiagnostics{
|
||||
TraceID: "trace-multi",
|
||||
ServerErrorCode: "NETWORK_ERROR",
|
||||
TechnicalDetail: "McpService.queryToolMeta: StatusCode.UNAVAILABLE",
|
||||
ServerRetryable: &retryable,
|
||||
},
|
||||
)
|
||||
payload := multiProfileErrorPayload(err)
|
||||
for key, want := range map[string]any{
|
||||
"reason": "backend_dependency_unavailable",
|
||||
"origin": "mcp_gateway",
|
||||
"stage": "tool_metadata_lookup",
|
||||
"retryable": true,
|
||||
"trace_id": "trace-multi",
|
||||
"server_error_code": "NETWORK_ERROR",
|
||||
} {
|
||||
if got := payload[key]; got != want {
|
||||
t.Errorf("payload[%q] = %#v, want %#v", key, got, want)
|
||||
}
|
||||
}
|
||||
if _, ok := payload["execution_started"]; ok {
|
||||
t.Fatalf("payload must not invent execution_started: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesResolutionDetails(t *testing.T) {
|
||||
err := apperrors.NewValidation(
|
||||
"群目标不唯一",
|
||||
apperrors.WithReason("resolution_ambiguous"),
|
||||
apperrors.WithOrigin("client"),
|
||||
apperrors.WithFailureStage("target_resolution"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithHint("请选择候选"),
|
||||
apperrors.WithActions("使用稳定 ID 重试"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"type": "resolution",
|
||||
"candidates": []string{"cid-1", "cid-2"},
|
||||
}),
|
||||
)
|
||||
payload := multiProfileErrorPayload(err)
|
||||
details, ok := payload["details"].(map[string]any)
|
||||
if !ok || details["type"] != "resolution" {
|
||||
t.Fatalf("details = %#v", payload["details"])
|
||||
}
|
||||
if payload["execution_started"] != false || payload["origin"] != "client" || payload["stage"] != "target_resolution" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if actions, ok := payload["actions"].([]string); !ok || len(actions) != 1 {
|
||||
t.Fatalf("actions = %#v", payload["actions"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExecuteInvocationClassifiesObservedMCPMetadataFailure(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": request.ID,
|
||||
"result": map[string]any{
|
||||
"structuredContent": map[string]any{
|
||||
"success": false,
|
||||
"code": "NETWORK_ERROR",
|
||||
"trace_id": "trace-replay",
|
||||
"technical_detail": "调用 McpService.queryToolMeta 失败: status = StatusCode.UNAVAILABLE; connect: Connection refused (111)",
|
||||
"retryable": true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := transport.NewClient(server.Client())
|
||||
client.TrustedDomains = []string{strings.TrimPrefix(server.URL, "http://")}
|
||||
runner := &runtimeRunner{
|
||||
transport: client,
|
||||
globalFlags: &GlobalFlags{Token: "local-test-token"},
|
||||
}
|
||||
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
|
||||
CanonicalProduct: "im",
|
||||
Tool: "list_conversations",
|
||||
Params: map[string]any{"pageSize": 100},
|
||||
})
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("executeInvocation() error = %T %v, want typed API error", err, err)
|
||||
}
|
||||
if typed.Reason != "backend_dependency_unavailable" || typed.Origin != "mcp_gateway" || typed.FailureStage != "tool_metadata_lookup" {
|
||||
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
|
||||
}
|
||||
if typed.ServerDiag.TraceID != "trace-replay" || !typed.RetryableSet || !typed.Retryable {
|
||||
t.Fatalf("diagnostics = %#v retryable=(%v,%v)", typed.ServerDiag, typed.RetryableSet, typed.Retryable)
|
||||
}
|
||||
if typed.ExecutionStarted != nil {
|
||||
t.Fatalf("execution_started must remain unknown: %v", typed.ExecutionStarted)
|
||||
}
|
||||
}
|
||||
@@ -75,10 +75,8 @@ func newSkillSetupCommand() *cobra.Command {
|
||||
Long: `安装 dws 自身 skill 文档到 AI Agent 目录(如 ~/.claude/skills/、~/.cursor/skills/ 等)。
|
||||
|
||||
支持两种模式:
|
||||
mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
|
||||
multi 🧪 EXPERIMENTAL 多 skill(试验版 / Preview)—— 按产品拆 N 个独立 skill
|
||||
尚未达到 stable 标准,接口、命名与跨 skill 引用可能变动;
|
||||
生产前请评估,问题请提 issue 反馈
|
||||
mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
|
||||
multi 多 skill—— 按产品拆 N 个独立 skill
|
||||
|
||||
multi 模式支持按产品挑选:
|
||||
-s/--skill 只装指定子 skill(可重复,短名 aitable 或全名 dingtalk-aitable 均可)
|
||||
@@ -150,7 +148,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
if filterErr != nil {
|
||||
return filterErr
|
||||
}
|
||||
// dws-shared carries the global rules every product skill declares as a
|
||||
// dingtalk-shared carries the global rules every product skill declares as a
|
||||
// PREREQUISITE; it must ship even when --skill / --exclude narrows the set.
|
||||
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
|
||||
}
|
||||
@@ -177,8 +175,6 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
fmt.Fprintln(out, "已取消。")
|
||||
return nil
|
||||
}
|
||||
} else if mode == skillSetupModeMulti {
|
||||
fmt.Fprintln(errOut, "🧪 multi 模式当前为 EXPERIMENTAL(试验版 / Preview)—— 接口与布局可能变动,稳定版请用 --mode mono")
|
||||
}
|
||||
|
||||
var installed, skipped int
|
||||
@@ -205,8 +201,8 @@ const multiSkillPrefix = "dingtalk-"
|
||||
// multiSharedSkill is the shared, non-product skill that every per-product
|
||||
// skill declares as a PREREQUISITE. It must always be installed in multi mode
|
||||
// regardless of --skill / --exclude, otherwise the product skills reference a
|
||||
// dws-shared that was never installed.
|
||||
const multiSharedSkill = "dws-shared"
|
||||
// dingtalk-shared that was never installed.
|
||||
const multiSharedSkill = "dingtalk-shared"
|
||||
|
||||
// ensureMandatorySharedSkill guarantees the shared dependency skill is included
|
||||
// whenever it exists in the source, even if --skill / --exclude narrowed it out.
|
||||
@@ -365,10 +361,10 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("选择 dws skill 安装模式").
|
||||
Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分(🧪 EXPERIMENTAL / 试验版,未达 stable,接口可能变动)").
|
||||
Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分的独立 skill").
|
||||
Options(
|
||||
huh.NewOption("mono — 单 skill(稳定 / 推荐)", skillSetupModeMono),
|
||||
huh.NewOption("multi — 多 skill(🧪 EXPERIMENTAL · 试验版)", skillSetupModeMulti),
|
||||
huh.NewOption("multi — 多 skill(按产品拆分)", skillSetupModeMulti),
|
||||
).
|
||||
Value(&choice),
|
||||
),
|
||||
@@ -526,15 +522,6 @@ func detectExistingAgentHomes(home, mode string) []string {
|
||||
}
|
||||
|
||||
func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSkillNames []string) (bool, error) {
|
||||
if mode == skillSetupModeMulti {
|
||||
fmt.Fprintln(out, "\n🧪 ─────────────────────────────────────────────────────────────")
|
||||
fmt.Fprintln(out, " multi 模式当前为 EXPERIMENTAL(试验版 / Preview)")
|
||||
fmt.Fprintf(out, " · 当前选择的 %d 个独立 skill 均跑过 verifier,可用但未达 stable\n", len(multiSkillNames))
|
||||
fmt.Fprintln(out, " · 跨 skill 引用、bundle 命名、目录布局后续可能调整")
|
||||
fmt.Fprintln(out, " · 不建议在生产 / 共享环境直接落地;问题请提 issue 反馈")
|
||||
fmt.Fprintln(out, " 稳定版请用 --mode mono")
|
||||
fmt.Fprintln(out, "🧪 ─────────────────────────────────────────────────────────────")
|
||||
}
|
||||
fmt.Fprintf(out, "\n📦 将安装 skill:\n mode: %s\n source: %s\n", mode, src)
|
||||
if mode == skillSetupModeMulti {
|
||||
fmt.Fprintf(out, " 将装 %d 个独立 skill(按子目录平铺到 <agent-home>/<skill-name>/):\n", len(multiSkillNames))
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestMaterializeEmbeddedSkillSourceMono(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestMaterializeEmbeddedSkillSourceMulti verifies that the peer multi bundle
|
||||
// contains both the shared routing skill and the PAT product skill. Structured
|
||||
// contains both the shared routing skill and misc (including folded PAT docs). Structured
|
||||
// Schema hints are build inputs and must not become a third installable mode.
|
||||
func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
|
||||
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
|
||||
@@ -71,9 +71,9 @@ func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
|
||||
t.Fatalf("extracted dir %s is not a valid multi skill source root", dir)
|
||||
}
|
||||
for _, rel := range []string{
|
||||
filepath.Join("dws-shared", "SKILL.md"),
|
||||
filepath.Join("dingtalk-pat", "SKILL.md"),
|
||||
filepath.Join("dingtalk-pat", "references", "pat.md"),
|
||||
filepath.Join("dingtalk-shared", "SKILL.md"),
|
||||
filepath.Join("dingtalk-misc", "SKILL.md"),
|
||||
filepath.Join("dingtalk-misc", "references", "pat.md"),
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(dir, rel)); err != nil {
|
||||
t.Errorf("expected embedded multi skill to contain %s: %v", rel, err)
|
||||
|
||||
@@ -73,7 +73,7 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
t.Fatal("empty multi source should fail")
|
||||
}
|
||||
skillSetupListMulti = func(string) ([]string, error) { return []string{"dws-shared", "dingtalk-doc"}, nil }
|
||||
skillSetupListMulti = func(string) ([]string, error) { return []string{"dingtalk-shared", "dingtalk-doc"}, nil }
|
||||
skillSetupFilterMulti = func([]string, []string, []string) ([]string, error) { return nil, fail }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
|
||||
@@ -23,11 +23,11 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/profilectx"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
@@ -117,23 +117,14 @@ type Profile struct {
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
runtimeProfileMu sync.RWMutex
|
||||
runtimeProfile string
|
||||
)
|
||||
|
||||
// SetRuntimeProfile sets a process-local one-shot profile override.
|
||||
func SetRuntimeProfile(profile string) {
|
||||
runtimeProfileMu.Lock()
|
||||
defer runtimeProfileMu.Unlock()
|
||||
runtimeProfile = strings.TrimSpace(profile)
|
||||
profilectx.Set(profile)
|
||||
}
|
||||
|
||||
// RuntimeProfile returns the process-local one-shot profile override.
|
||||
func RuntimeProfile() string {
|
||||
runtimeProfileMu.RLock()
|
||||
defer runtimeProfileMu.RUnlock()
|
||||
return runtimeProfile
|
||||
return profilectx.Get()
|
||||
}
|
||||
|
||||
// ProfilesPath returns the profile metadata path for a config dir.
|
||||
|
||||
@@ -71,7 +71,7 @@ func NewSchemaCommand() *cobra.Command {
|
||||
Short: "渐进查看命令 Schema (产品 / 分组 / 工具参数)",
|
||||
Long: `查看当前可运行命令的 Schema 元数据。
|
||||
|
||||
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。--all 输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)。--compact 去除 provenance / debug 字段,仅保留 Agent 选参所需信息(适合 Agent 上下文)。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
|
||||
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。普通 Agent 查询应使用 --compact:它按稳定字段白名单输出选参、约束和安全语义。省略 --compact 的 full leaf 保留参数映射、接口绑定和 provenance,仅用于定向审计;--all 输出全部工具的完整 leaf Schema,用于审计/CI。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -113,7 +113,7 @@ func NewSchemaCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().Bool("all", false, "输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)")
|
||||
cmd.Flags().Bool("compact", false, "去除 provenance/debug 字段,仅保留 Agent 选参所需信息")
|
||||
cmd.Flags().Bool("compact", false, "按稳定字段白名单输出 Agent 选参、约束和安全语义")
|
||||
cmd.Flags().String("cli-path", "", "按 CLI 命令路径查询")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -24,39 +24,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalMCPMetadataLookup(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "reply"}
|
||||
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
|
||||
cmd.Flags().String("text", "", "text")
|
||||
runtimeannotate.AnnotateRuntimeFlag(cmd, "text", "text", "string", false)
|
||||
contractfinal.RegisterRuntimeContractFinal(cmd, contract.ContractFinalPayload{
|
||||
Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "chat", Name: "reply_personal_message", CanonicalPath: "chat.reply_personal_message",
|
||||
CLIPath: "chat reply", PrimaryCLIPath: "chat reply",
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeMCP,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "send_personal_message"},
|
||||
},
|
||||
})
|
||||
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "chat", ToolName: "reply_personal_message", Command: cmd,
|
||||
CLIPath: "chat reply", PrimaryCLIPath: "chat reply",
|
||||
}
|
||||
metadata := runtimeSchemaMetadataSources{
|
||||
MCP: embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{
|
||||
"chat.send_personal_message": {Parameters: map[string]embeddedMCPParamMeta{
|
||||
"text": {Type: "string"},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
if _, err := runtimeToolSpecFromContractFinal(entry, mustFinal(t, cmd), metadata); err != nil {
|
||||
t.Fatalf("runtimeToolSpecFromContractFinal with MCP metadata = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "create", Short: "s", Long: "l"}
|
||||
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
|
||||
@@ -318,7 +285,7 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalSafetyAnnotationFa
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalParameterResolutionError(t *testing.T) {
|
||||
oldParameters := resolveRuntimeParameters
|
||||
t.Cleanup(func() { resolveRuntimeParameters = oldParameters })
|
||||
resolveRuntimeParameters = func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
resolveRuntimeParameters = func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
return nil, errors.New("parameters failed")
|
||||
}
|
||||
entry := runtimeSchemaEntry{
|
||||
|
||||
@@ -158,6 +158,12 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat +chat-messages",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat +chat-mute",
|
||||
Aliases: map[string]string{
|
||||
@@ -336,6 +342,12 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"at-user-ids": "at-users",
|
||||
},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat +search-msg",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat +send-to-group",
|
||||
Aliases: map[string]string{
|
||||
|
||||
@@ -57,6 +57,8 @@
|
||||
"mail message search": {"scoped_aliases": {"subject": "query"}, "scope_strict": true, "note": "never globalize: mail template create has a real and different --subject"},
|
||||
"calendar event list": {"scoped_aliases": {"date": "start"}, "note": "reviewed against ParseISOTimeToMillis and final list_calendar_events payload; --date is normalized centrally while the command's existing hidden compatibility flags remain native fallbacks"},
|
||||
"chat +bot-find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search shortcut, --name and --query denote the same search keyword; --name must not become a global search alias."},
|
||||
"chat +chat-messages": {"scoped_aliases": {"chat": "group"}, "scope_strict": true, "note": "Evaluation compatibility: --chat carries one stable openConversationId and normalizes to the existing --group identifier route."},
|
||||
"chat +search-msg": {"scoped_aliases": {"chat": "group"}, "scope_strict": true, "note": "Evaluation compatibility: scalar --chat carries one stable openConversationId and normalizes to the existing scalar --group filter."},
|
||||
"chat bot find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search command, --name and --query denote the same search keyword; --name must not become a global search alias."},
|
||||
"chat +bot-search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."},
|
||||
"chat bot search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."},
|
||||
@@ -255,7 +257,9 @@
|
||||
{"command": "chat +unread-chats", "emitted": "limit", "expect": "count", "via": "override:scoped"},
|
||||
{"command": "chat +unread-chats", "emitted": "size", "expect": "count", "via": "override:scoped"},
|
||||
{"command": "chat category rename", "emitted": "name", "expect": "title", "via": "override:scoped"},
|
||||
{"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"}
|
||||
{"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"},
|
||||
{"command": "chat +chat-messages", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"},
|
||||
{"command": "chat +search-msg", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+91
-184
@@ -89,9 +89,9 @@ func RegisterRuntimeSchemaConstraints(canonicalPath string, constraints RuntimeS
|
||||
}
|
||||
|
||||
// emptyPinnedMCPMetadata returns the retired pin shape with no tools.
|
||||
// schema_mcp_metadata.json is deleted; production assembly does not embed or
|
||||
// load a pinned MCP snapshot. Test fixtures may still inject non-empty maps
|
||||
// through schemaRegistryForTestWithMetadata.
|
||||
// schema_mcp_metadata.json is deleted; Schema parameter assembly never loads
|
||||
// or ranks MCP pin candidates. Optional Interface-registry validators may
|
||||
// still pass this empty shape when they only need ContractFinal self-checks.
|
||||
func emptyPinnedMCPMetadata() embeddedMCPMetadata {
|
||||
return embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}
|
||||
}
|
||||
@@ -217,62 +217,6 @@ func collectRuntimeSchemaEntriesFromBound(bound BoundCommandRegistry) ([]runtime
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func pinnedMCPMetadataForEntryFrom(entry runtimeSchemaEntry, agentMetadata agentMetadata, mcpMetadata embeddedMCPMetadata) (embeddedMCPToolMetadata, bool) {
|
||||
// Optional test/diagnostic lookup only. Production mcpMetadata is empty;
|
||||
// Contract/ParamDecl own interface facts. When a non-empty fixture is
|
||||
// injected, ContractFinal Interface.Ref remaps CLI canonical names onto
|
||||
// fixture keys (e.g. reply_personal_message → chat.send_personal_message).
|
||||
if len(mcpMetadata.Tools) == 0 {
|
||||
return embeddedMCPToolMetadata{}, false
|
||||
}
|
||||
if entry.Command != nil {
|
||||
if final, ok := RuntimeContractFinal(entry.Command); ok && final.Interface != nil && final.Interface.Ref != nil {
|
||||
if metadata, found := mcpMetadataForInterfaceRef(mcpMetadata, final.Interface.Ref.ProductID, final.Interface.Ref.RPCName); found {
|
||||
return metadata, true
|
||||
}
|
||||
}
|
||||
}
|
||||
paths := []string{
|
||||
entry.PrimaryCLIPath,
|
||||
entry.CLIPath,
|
||||
entry.ProductID + "." + entry.ToolName,
|
||||
}
|
||||
paths = append(paths, entry.Aliases...)
|
||||
if toolMetadata, ok := lookupAgentToolMetadataFrom(agentMetadata, paths...); ok && toolMetadata.InterfaceRef != nil {
|
||||
if metadata, found := mcpMetadataForInterfaceRef(mcpMetadata, toolMetadata.InterfaceRef.ProductID, toolMetadata.InterfaceRef.RPCName); found {
|
||||
return metadata, true
|
||||
}
|
||||
}
|
||||
for _, key := range []string{
|
||||
entry.SourceProductID + "." + entry.ToolName,
|
||||
entry.ProductID + "." + entry.ToolName,
|
||||
} {
|
||||
key = strings.Trim(key, ".")
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if meta, ok := mcpMetadata.Tools[key]; ok {
|
||||
return meta, true
|
||||
}
|
||||
}
|
||||
return embeddedMCPToolMetadata{}, false
|
||||
}
|
||||
|
||||
func mcpMetadataForInterfaceRef(mcpMetadata embeddedMCPMetadata, productID, rpcName string) (embeddedMCPToolMetadata, bool) {
|
||||
productID = strings.TrimSpace(productID)
|
||||
rpcName = strings.TrimSpace(rpcName)
|
||||
key := strings.Trim(productID+"."+rpcName, ".")
|
||||
if key == "" {
|
||||
return embeddedMCPToolMetadata{}, false
|
||||
}
|
||||
metadata, exists := mcpMetadata.Tools[key]
|
||||
if !exists {
|
||||
return embeddedMCPToolMetadata{}, false
|
||||
}
|
||||
metadata.InterfaceRef = &embeddedMCPInterfaceRef{ProductID: productID, RPCName: rpcName}
|
||||
return metadata, true
|
||||
}
|
||||
|
||||
func runtimeSchemaAnnotations(cmd *cobra.Command) (productID, toolName, source string) {
|
||||
if cmd == nil || cmd.Annotations == nil {
|
||||
return "", "", ""
|
||||
@@ -322,7 +266,6 @@ const (
|
||||
runtimeSchemaRankDefault = 0
|
||||
runtimeSchemaRankDerived = 50
|
||||
runtimeSchemaRankInference = 100
|
||||
runtimeSchemaRankMCP = 400
|
||||
runtimeSchemaRankCobraHelp = 450
|
||||
runtimeSchemaRankCobraDefault = 600
|
||||
runtimeSchemaRankCobraContract = 610
|
||||
@@ -332,7 +275,7 @@ const (
|
||||
runtimeSchemaRankVersionedBinding = 650
|
||||
// ParamDecl.Property (dws.schema.property) outranks residual versioned
|
||||
// binding candidates (active bindings JSON is empty after Phase 2).
|
||||
// Mapping exclusions stay highest so an explicit "no MCP property" review
|
||||
// Mapping exclusions stay highest so an explicit "no RPC property" review
|
||||
// cannot be overridden by a leaf ParamDecl that still carries a Property.
|
||||
runtimeSchemaRankParamDeclProperty = 655
|
||||
runtimeSchemaRankMappingExclusion = 660
|
||||
@@ -340,7 +283,6 @@ const (
|
||||
runtimeSchemaPrecedenceDefault = "default"
|
||||
runtimeSchemaPrecedenceDerived = "derived_resolution"
|
||||
runtimeSchemaPrecedenceInference = "inference"
|
||||
runtimeSchemaPrecedenceMCP = "mcp_metadata"
|
||||
runtimeSchemaPrecedenceCobraHelp = "cobra_help"
|
||||
runtimeSchemaPrecedenceCobra = "cobra_contract"
|
||||
runtimeSchemaPrecedenceNativeAnnotation = "native_annotation"
|
||||
@@ -533,8 +475,6 @@ func runtimeSchemaSourcePriority(source string) (int, string) {
|
||||
return runtimeSchemaRankCobraDefault, runtimeSchemaPrecedenceCobra
|
||||
}
|
||||
return runtimeSchemaRankCobraContract, runtimeSchemaPrecedenceCobra
|
||||
case "mcp_metadata", "pinned_mcp_metadata":
|
||||
return runtimeSchemaRankMCP, runtimeSchemaPrecedenceMCP
|
||||
case "cobra_help":
|
||||
return runtimeSchemaRankCobraHelp, runtimeSchemaPrecedenceCobraHelp
|
||||
case "flag_name_inference", "usage_required_inference", "usage_format_inference":
|
||||
@@ -575,9 +515,9 @@ func runtimeSchemaParameterMappingKey(canonicalPath, flagName string) string {
|
||||
|
||||
// runtimeSchemaParameterMappingCandidates resolves the two reviewed,
|
||||
// versioned property-mapping inputs. An exclusion is an explicit statement
|
||||
// that the CLI parameter is not a direct MCP property: it therefore supplies
|
||||
// a present empty candidate (rather than allowing name inference to survive)
|
||||
// and keeps the review reason in provenance.
|
||||
// that the CLI parameter is not a direct RPC/interface property: it therefore
|
||||
// supplies a present empty candidate (rather than allowing name inference to
|
||||
// survive) and keeps the review reason in provenance.
|
||||
func runtimeSchemaParameterMappingCandidates(snapshot schemaParameterBindingSnapshot, canonicalPath, flagName string) (runtimeSchemaFieldCandidate, runtimeSchemaFieldCandidate, error) {
|
||||
binding := strings.TrimSpace(snapshot.Bindings[strings.TrimSpace(canonicalPath)][strings.TrimSpace(flagName)])
|
||||
bindingCandidate := runtimeSchemaStringCandidate(binding, "versioned_parameter_binding")
|
||||
@@ -605,34 +545,24 @@ func runtimeSchemaParameterMappingCandidates(snapshot schemaParameterBindingSnap
|
||||
type runtimeParameterFieldContext struct {
|
||||
flag *pflag.Flag
|
||||
metadata RuntimeSchemaParameterMetadata
|
||||
pinnedParam embeddedMCPParamMeta
|
||||
hasPinned bool
|
||||
paramType string
|
||||
constraints RuntimeSchemaConstraints
|
||||
property string
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) interfaceTypeCandidates() []runtimeSchemaFieldCandidate {
|
||||
candidates := []runtimeSchemaFieldCandidate{
|
||||
return []runtimeSchemaFieldCandidate{
|
||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagTypeAnnotation), "native_annotation"),
|
||||
}
|
||||
if c.hasPinned {
|
||||
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.Type, "mcp_metadata"))
|
||||
}
|
||||
return append(candidates,
|
||||
runtimeSchemaStringCandidateAtRank(c.paramType, "cobra_flag_type", runtimeSchemaRankInference, "fallback"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) descriptionCandidates() []runtimeSchemaFieldCandidate {
|
||||
candidates := []runtimeSchemaFieldCandidate{
|
||||
return []runtimeSchemaFieldCandidate{
|
||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagDescriptionAnnotation), "native_annotation"),
|
||||
runtimeSchemaStringCandidate(c.flag.Usage, "cobra_usage"),
|
||||
runtimeSchemaCandidate("", true, "default"),
|
||||
}
|
||||
if c.hasPinned {
|
||||
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.Description, "mcp_metadata"))
|
||||
}
|
||||
return append(candidates, runtimeSchemaCandidate("", true, "default"))
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldCandidate {
|
||||
@@ -649,7 +579,7 @@ func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldC
|
||||
break
|
||||
}
|
||||
}
|
||||
candidates := []runtimeSchemaFieldCandidate{
|
||||
return []runtimeSchemaFieldCandidate{
|
||||
constraintRequired,
|
||||
runtimeSchemaCandidate(true, typedRequired, "typed_parameter_metadata"),
|
||||
runtimeSchemaAnnotatedBoolCandidate(c.flag, runtimeSchemaFlagMetadataRequiredAnnotation, "typed_parameter_metadata"),
|
||||
@@ -657,50 +587,36 @@ func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldC
|
||||
runtimeSchemaCandidate(true, runtimeFlagCobraHardRequired(c.flag), "cobra_hard_required"),
|
||||
runtimeSchemaCandidate(false, cobraDefaultOptional, "cobra_nonzero_default"),
|
||||
runtimeSchemaCandidate(usageRequired, usageRequired, "usage_required_inference"),
|
||||
runtimeSchemaCandidate(false, true, "default"),
|
||||
}
|
||||
if c.hasPinned && c.pinnedParam.Required != nil {
|
||||
candidates = append(candidates, runtimeSchemaCandidate(*c.pinnedParam.Required, true, "mcp_metadata"))
|
||||
}
|
||||
return append(candidates, runtimeSchemaCandidate(false, true, "default"))
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) requiredWhenCandidates() []runtimeSchemaFieldCandidate {
|
||||
candidates := []runtimeSchemaFieldCandidate{
|
||||
return []runtimeSchemaFieldCandidate{
|
||||
runtimeSchemaStringCandidate(c.metadata.RequiredWhen[c.flag.Name], "typed_parameter_metadata"),
|
||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagMetadataRequiredWhenAnnotation), "typed_parameter_metadata"),
|
||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagRequiredWhenAnnotation), "native_annotation"),
|
||||
runtimeSchemaCandidate("", true, "default"),
|
||||
}
|
||||
if c.hasPinned {
|
||||
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.RequiredWhen, "mcp_metadata"))
|
||||
}
|
||||
return append(candidates, runtimeSchemaCandidate("", true, "default"))
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) formatCandidates() []runtimeSchemaFieldCandidate {
|
||||
candidates := []runtimeSchemaFieldCandidate{
|
||||
return []runtimeSchemaFieldCandidate{
|
||||
runtimeSchemaStringCandidate(c.metadata.Formats[c.flag.Name], "typed_parameter_metadata"),
|
||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagMetadataFormatAnnotation), "typed_parameter_metadata"),
|
||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, "x-cli-format"), "native_annotation"),
|
||||
}
|
||||
if c.hasPinned {
|
||||
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.Format, "mcp_metadata"))
|
||||
}
|
||||
return append(candidates,
|
||||
runtimeSchemaStringCandidate(inferredRuntimeFlagFormat(c.flag), "usage_format_inference"),
|
||||
runtimeSchemaCandidate("", true, "default"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) enumCandidates() []runtimeSchemaFieldCandidate {
|
||||
candidates := []runtimeSchemaFieldCandidate{
|
||||
return []runtimeSchemaFieldCandidate{
|
||||
runtimeSchemaEnumCandidate(c.metadata.Enums[c.flag.Name], "typed_parameter_metadata"),
|
||||
runtimeSchemaEnumCandidate(runtimeFlagEnumAnnotation(c.flag, runtimeSchemaFlagMetadataEnumAnnotation), "typed_parameter_metadata"),
|
||||
runtimeSchemaEnumCandidate(runtimeFlagEnum(c.flag), "native_annotation"),
|
||||
runtimeSchemaCandidate([]string{}, true, "default"),
|
||||
}
|
||||
if c.hasPinned {
|
||||
candidates = append(candidates, runtimeSchemaEnumCandidate(c.pinnedParam.Enum, "mcp_metadata"))
|
||||
}
|
||||
return append(candidates, runtimeSchemaCandidate([]string{}, true, "default"))
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) exampleCandidates() []runtimeSchemaFieldCandidate {
|
||||
@@ -717,7 +633,8 @@ func (c runtimeParameterFieldContext) exampleCandidates() []runtimeSchemaFieldCa
|
||||
// source may intentionally raise or lower type/mapping/description semantics.
|
||||
// required is different: Cobra MarkFlagRequired is a hard floor that no
|
||||
// lower-priority source may demote (see resolveRequiredProjection).
|
||||
func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinnedParams map[string]embeddedMCPParamMeta, constraints RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
// MCP pin / mcp_metadata is not a candidate source.
|
||||
func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, constraints RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
if cmd == nil {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -758,18 +675,10 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
|
||||
return
|
||||
}
|
||||
property, _ := propertyWinner.Value.(string)
|
||||
// pinnedParams remains for test fixtures that inject MCP-shaped maps;
|
||||
// production assembly always passes an empty map (pin retired).
|
||||
pinnedParam, hasPinnedParam := embeddedMCPParamMeta{}, false
|
||||
if len(pinnedParams) > 0 && strings.TrimSpace(property) != "" {
|
||||
pinnedParam, hasPinnedParam = lookupPinnedMCPParam(pinnedParams, property, flag.Name)
|
||||
}
|
||||
paramType := runtimeFlagCLIType(flag)
|
||||
fieldCtx := runtimeParameterFieldContext{
|
||||
flag: flag,
|
||||
metadata: metadata,
|
||||
pinnedParam: pinnedParam,
|
||||
hasPinned: hasPinnedParam,
|
||||
paramType: paramType,
|
||||
constraints: constraints,
|
||||
property: property,
|
||||
@@ -794,10 +703,6 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
|
||||
return
|
||||
}
|
||||
description, _ := descriptionWinner.Value.(string)
|
||||
interfaceDescription := ""
|
||||
if hasPinnedParam {
|
||||
interfaceDescription = strings.TrimSpace(pinnedParam.Description)
|
||||
}
|
||||
|
||||
// Required uses field-level safe merge: higher sources may raise required, but
|
||||
// Cobra MarkFlagRequired cannot be projected away as optional.
|
||||
@@ -838,9 +743,6 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
|
||||
runtimeSchemaCandidate(true, true, "cobra_hard_required"),
|
||||
)
|
||||
}
|
||||
if interfaceDescription != "" && interfaceDescription != description {
|
||||
parameter.InterfaceDescription = interfaceDescription
|
||||
}
|
||||
if interfaceType != "" && interfaceType != paramType {
|
||||
parameter.InterfaceType = interfaceType
|
||||
fieldProvenance["interface_type"] = runtimeSchemaFieldProvenance(interfaceTypeWinner)
|
||||
@@ -855,12 +757,6 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
|
||||
if def := runtimeFlagDefault(flag); def != "" {
|
||||
parameter.Default = runtimeSchemaJSONString(def)
|
||||
}
|
||||
if hasPinnedParam {
|
||||
interfaceDefault := strings.TrimSpace(pinnedParam.Default)
|
||||
if interfaceDefault != "" && interfaceDefault != runtimeFlagDefault(flag) {
|
||||
parameter.InterfaceDefault = runtimeSchemaJSONString(interfaceDefault)
|
||||
}
|
||||
}
|
||||
formatWinner, ok := resolveField("format", fieldCtx.formatCandidates())
|
||||
if !ok {
|
||||
return
|
||||
@@ -991,19 +887,6 @@ func runtimeSchemaConstraintsEmpty(constraints RuntimeSchemaConstraints) bool {
|
||||
return runtimeannotate.ConstraintsEmpty(constraints)
|
||||
}
|
||||
|
||||
func lookupPinnedMCPParam(params map[string]embeddedMCPParamMeta, property, flagName string) (embeddedMCPParamMeta, bool) {
|
||||
if len(params) == 0 {
|
||||
return embeddedMCPParamMeta{}, false
|
||||
}
|
||||
if meta, ok := params[property]; ok {
|
||||
return meta, true
|
||||
}
|
||||
if meta, ok := params[flagName]; ok {
|
||||
return meta, true
|
||||
}
|
||||
return embeddedMCPParamMeta{}, false
|
||||
}
|
||||
|
||||
func isGenericPayloadFlag(flag *pflag.Flag) bool {
|
||||
if flag == nil {
|
||||
return false
|
||||
@@ -1142,6 +1025,12 @@ func inferredRuntimeFlagFormat(flag *pflag.Flag) string {
|
||||
}
|
||||
usage := strings.ToLower(strings.TrimSpace(flag.Usage))
|
||||
if strings.Contains(usage, "iso-8601") || strings.Contains(usage, "rfc3339") {
|
||||
// JSON Schema's date-time format means one RFC3339 value. Do not publish
|
||||
// that narrower wire contract when the CLI also accepts local timestamps
|
||||
// or date-only values alongside RFC3339.
|
||||
if strings.Contains(usage, "yyyy-mm-dd") {
|
||||
return ""
|
||||
}
|
||||
return "date-time"
|
||||
}
|
||||
if strings.Contains(usage, "a1") {
|
||||
@@ -1156,69 +1045,87 @@ func strconvQuote(value string) string {
|
||||
|
||||
// ─── --compact mode ──────────────────────────────────────────────────────────
|
||||
|
||||
// schemaCompactStripKeys are top-level tool/product keys removed in --compact mode.
|
||||
var schemaCompactStripKeys = map[string]bool{
|
||||
// provenance / debug
|
||||
"agent_metadata_source": true,
|
||||
"agent_source_refs": true,
|
||||
"agent_summary_source": true,
|
||||
"effect_source": true,
|
||||
"metadata_source": true,
|
||||
"source": true,
|
||||
"agent_metadata": true,
|
||||
"field_provenance": true,
|
||||
"reviewed": true,
|
||||
// redundant with canonical_path / cli_path
|
||||
"name": true,
|
||||
"path": true,
|
||||
"cli_name": true,
|
||||
"primary_cli_path": true,
|
||||
"is_alias": true,
|
||||
"has_parameters": true,
|
||||
"parameter_count": true,
|
||||
"product_id": true,
|
||||
"display": true,
|
||||
"title": true,
|
||||
"group": true,
|
||||
"source_product_id": true,
|
||||
"aliases": true,
|
||||
"catalog_hash": true,
|
||||
"surface_hash": true,
|
||||
"workflow_refs": true,
|
||||
"prerequisites": true,
|
||||
"tips": true,
|
||||
"interface_ref": true,
|
||||
// schemaCompactPayloadKeys is the reviewed Agent-view allowlist. Keep this a
|
||||
// positive list: a new full/audit field must not silently expand routine Agent
|
||||
// context just because it was added to ToolSpec.ToPayload.
|
||||
var schemaCompactPayloadKeys = map[string]bool{
|
||||
// Navigation envelopes.
|
||||
"kind": true, "level": true, "count": true, "tool_count": true,
|
||||
"products": true, "product": true, "tools": true,
|
||||
"id": true, "schema_path": true, "runtime": true,
|
||||
// Leaf identity and execution semantics.
|
||||
"canonical_path": true, "cli_path": true,
|
||||
"agent_summary": true, "description": true,
|
||||
"effect": true, "risk": true, "confirmation": true, "idempotency": true,
|
||||
"interface_mode": true, "availability": true, "interface_reason": true,
|
||||
"parameters": true, "constraints": true, "positionals": true, "dry_run": true,
|
||||
"examples": true, "use_when": true, "avoid_when": true,
|
||||
}
|
||||
|
||||
// schemaCompactParamStripKeys are per-parameter keys removed in --compact mode.
|
||||
var schemaCompactParamStripKeys = map[string]bool{
|
||||
"interface_description": true,
|
||||
"interface_type": true,
|
||||
"property": true,
|
||||
"field_provenance": true,
|
||||
// schemaCompactParamKeys is the reviewed parameter allowlist for Agent command
|
||||
// construction. RPC mapping and provenance fields intentionally remain in the
|
||||
// full/audit view.
|
||||
var schemaCompactParamKeys = map[string]bool{
|
||||
"type": true, "description": true, "required": true,
|
||||
"cli_required": true, "required_when": true,
|
||||
"default": true, "interface_default": true, "example": true,
|
||||
"format": true, "enum": true,
|
||||
}
|
||||
|
||||
// stripSchemaPayloadCompact walks a schema payload map and removes provenance,
|
||||
// debug and redundant keys so that only agent-essential fields remain.
|
||||
// It operates recursively on nested maps, slices, and parameter objects.
|
||||
// stripSchemaPayloadCompact projects a full Schema payload onto the reviewed
|
||||
// Agent-view allowlist. Structural product/tool children are projected
|
||||
// recursively; constraint, positional and dry-run values are already typed
|
||||
// contract data and are retained verbatim.
|
||||
func stripSchemaPayloadCompact(payload map[string]any) map[string]any {
|
||||
if payload == nil {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]any, len(payload))
|
||||
for k, v := range payload {
|
||||
if schemaCompactStripKeys[k] {
|
||||
if !schemaCompactPayloadKeys[k] {
|
||||
continue
|
||||
}
|
||||
if k == "parameters" {
|
||||
switch k {
|
||||
case "parameters":
|
||||
result[k] = stripSchemaParametersCompact(v)
|
||||
continue
|
||||
case "product":
|
||||
if product, ok := v.(map[string]any); ok {
|
||||
result[k] = stripSchemaPayloadCompact(product)
|
||||
} else {
|
||||
result[k] = v
|
||||
}
|
||||
case "products", "tools":
|
||||
result[k] = stripSchemaPayloadCollectionCompact(v)
|
||||
default:
|
||||
result[k] = v
|
||||
}
|
||||
result[k] = stripSchemaValueCompact(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func stripSchemaPayloadCollectionCompact(value any) any {
|
||||
switch values := value.(type) {
|
||||
case []map[string]any:
|
||||
result := make([]map[string]any, len(values))
|
||||
for i, item := range values {
|
||||
result[i] = stripSchemaPayloadCompact(item)
|
||||
}
|
||||
return result
|
||||
case []any:
|
||||
result := make([]any, len(values))
|
||||
for i, item := range values {
|
||||
if payload, ok := item.(map[string]any); ok {
|
||||
result[i] = stripSchemaPayloadCompact(payload)
|
||||
} else {
|
||||
result[i] = item
|
||||
}
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func stripSchemaParametersCompact(value any) any {
|
||||
parameters, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
@@ -1272,7 +1179,7 @@ func stripSchemaValueCompact(v any) any {
|
||||
func stripSchemaParamCompact(param map[string]any) map[string]any {
|
||||
result := make(map[string]any, len(param))
|
||||
for k, v := range param {
|
||||
if schemaCompactParamStripKeys[k] {
|
||||
if !schemaCompactParamKeys[k] {
|
||||
continue
|
||||
}
|
||||
result[k] = v
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -63,39 +62,6 @@ func TestCrossPlatformCoverageCollectRuntimeSchemaEntriesErrorsAndOrdering(t *te
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeSchemaMetadataLookupEdges(t *testing.T) {
|
||||
if _, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{}, agentMetadata{}, embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}); ok {
|
||||
t.Fatal("empty lookup unexpectedly matched")
|
||||
}
|
||||
|
||||
leaf := &cobra.Command{Use: "reply"}
|
||||
contractfinal.RegisterRuntimeContractFinal(leaf, contract.ContractFinalPayload{
|
||||
Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "sample", Name: "run", CanonicalPath: "sample.run",
|
||||
CLIPath: "sample run", PrimaryCLIPath: "sample run",
|
||||
},
|
||||
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeMCP,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "send_personal_message"},
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { ClearRuntimeContractFinalForTest(leaf) })
|
||||
mcp := embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{
|
||||
"chat.send_personal_message": {
|
||||
Parameters: map[string]embeddedMCPParamMeta{
|
||||
"clawType": {Type: "string"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
got, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{Command: leaf, ProductID: "chat", ToolName: "reply_personal_message"}, agentMetadata{}, mcp)
|
||||
if !ok || got.Parameters["clawType"].Type != "string" {
|
||||
t.Fatalf("ContractFinal Interface.Ref MCP remap = %#v ok=%v", got, ok)
|
||||
}
|
||||
if got.InterfaceRef == nil || got.InterfaceRef.RPCName != "send_personal_message" {
|
||||
t.Fatalf("InterfaceRef = %#v", got.InterfaceRef)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
value any
|
||||
want int
|
||||
@@ -155,13 +121,13 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
|
||||
cmd.Flags().String("value", "", "value")
|
||||
flag := cmd.Flags().Lookup("value")
|
||||
|
||||
if specs, err := runtimeCommandParameterSpecs(nil, "sample.run", nil, RuntimeSchemaConstraints{}); err != nil || specs != nil {
|
||||
if specs, err := runtimeCommandParameterSpecs(nil, "sample.run", RuntimeSchemaConstraints{}); err != nil || specs != nil {
|
||||
t.Fatalf("nil command specs = %#v, err = %v", specs, err)
|
||||
}
|
||||
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||
return schemaParameterBindingSnapshot{}, errors.New("load failed")
|
||||
})
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "load failed") {
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "load failed") {
|
||||
t.Fatalf("binding load error = %v", err)
|
||||
}
|
||||
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||
@@ -179,7 +145,7 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
|
||||
MappingExclusions: map[string]string{"sample.run --value": " "},
|
||||
}, nil
|
||||
})
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "mapping exclusion") {
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "mapping exclusion") {
|
||||
t.Fatalf("mapping exclusion error = %v", err)
|
||||
}
|
||||
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||
@@ -194,22 +160,22 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
|
||||
}
|
||||
return resolveRuntimeSchemaCandidate(field, candidates...)
|
||||
})
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), target) {
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), target) {
|
||||
t.Fatalf("%s resolution error = %v", target, err)
|
||||
}
|
||||
}
|
||||
resolveRuntimeSchemaField = realResolver
|
||||
|
||||
if specs, err := runtimeCommandParameterSpecs(&cobra.Command{Use: "empty"}, "sample.empty", nil, RuntimeSchemaConstraints{}); err != nil || specs != nil {
|
||||
if specs, err := runtimeCommandParameterSpecs(&cobra.Command{Use: "empty"}, "sample.empty", RuntimeSchemaConstraints{}); err != nil || specs != nil {
|
||||
t.Fatalf("empty specs = %#v, err = %v", specs, err)
|
||||
}
|
||||
if payload, err := runtimeCommandParameters(nil, "", nil, RuntimeSchemaConstraints{}); err != nil || payload != nil {
|
||||
if payload, err := runtimeCommandParameters(nil, "", RuntimeSchemaConstraints{}); err != nil || payload != nil {
|
||||
t.Fatalf("empty payload = %#v, err = %v", payload, err)
|
||||
}
|
||||
testseam.Swap(t, &runtimeCommandParameterSpecsForPayload, func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
testseam.Swap(t, &runtimeCommandParameterSpecsForPayload, func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
return []ParameterSpec{{Name: "bad", Example: json.RawMessage("{")}}, nil
|
||||
})
|
||||
if _, err := runtimeCommandParameters(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "serialize Schema parameter") {
|
||||
if _, err := runtimeCommandParameters(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "serialize Schema parameter") {
|
||||
t.Fatalf("payload serialization error = %v", err)
|
||||
}
|
||||
|
||||
@@ -218,32 +184,21 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
|
||||
t.Fatalf("required annotation = %v/%v", required, present)
|
||||
}
|
||||
|
||||
// Fixture MCP-shaped maps still participate when explicitly injected.
|
||||
// Binding snapshot still supplies reviewed property mappings without MCP pin.
|
||||
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||
return schemaParameterBindingSnapshot{
|
||||
Bindings: map[string]map[string]string{"sample.run": {"value": "clawType"}},
|
||||
}, nil
|
||||
})
|
||||
requiredTrue := true
|
||||
specs, err := runtimeCommandParameterSpecs(cmd, "sample.run", map[string]embeddedMCPParamMeta{
|
||||
"clawType": {
|
||||
Type: "string",
|
||||
Description: "fixture description",
|
||||
Required: &requiredTrue,
|
||||
Default: "fixture-default",
|
||||
},
|
||||
}, RuntimeSchemaConstraints{})
|
||||
specs, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{})
|
||||
if err != nil {
|
||||
t.Fatalf("fixture pinned parameter specs error = %v", err)
|
||||
t.Fatalf("parameter specs error = %v", err)
|
||||
}
|
||||
if len(specs) != 1 || specs[0].Property != "clawType" || specs[0].InterfaceDescription != "fixture description" {
|
||||
t.Fatalf("fixture pinned parameter specs = %#v", specs)
|
||||
if len(specs) != 1 || specs[0].Property != "clawType" {
|
||||
t.Fatalf("parameter specs = %#v", specs)
|
||||
}
|
||||
if len(specs[0].InterfaceDefault) == 0 {
|
||||
t.Fatalf("fixture interface_default missing: %#v", specs[0])
|
||||
}
|
||||
if prov := specs[0].FieldProvenance["required"]; prov.Source == "" {
|
||||
t.Fatalf("fixture required provenance missing: %#v", specs[0].FieldProvenance)
|
||||
if prov := specs[0].FieldProvenance["property"]; prov.Source == "" {
|
||||
t.Fatalf("property provenance missing: %#v", specs[0].FieldProvenance)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,9 +228,6 @@ func TestCrossPlatformCoverageRuntimeSchemaPureHelperEdges(t *testing.T) {
|
||||
if !reflect.DeepEqual(groups, [][]string{{"one"}}) {
|
||||
t.Fatalf("normalized groups = %#v", groups)
|
||||
}
|
||||
if meta, ok := lookupPinnedMCPParam(map[string]embeddedMCPParamMeta{"flag": {Type: "string"}}, "property", "flag"); !ok || meta.Type != "string" {
|
||||
t.Fatalf("flag fallback metadata = %#v/%v", meta, ok)
|
||||
}
|
||||
if isGenericPayloadFlag(nil) {
|
||||
t.Fatal("nil flag cannot be a generic payload")
|
||||
}
|
||||
@@ -322,4 +274,75 @@ func TestCrossPlatformCoverageSchemaCompactProjectionEdges(t *testing.T) {
|
||||
if _, exists := value["property"]; exists {
|
||||
t.Fatalf("compact parameter value = %#v", value)
|
||||
}
|
||||
// Non-parameter nested maps fall through to payload compacting.
|
||||
nested := stripSchemaValueCompact(map[string]any{"description": "keep", "provenance": "drop"}).(map[string]any)
|
||||
if nested["description"] != "keep" {
|
||||
t.Fatalf("nested non-param map = %#v", nested)
|
||||
}
|
||||
if _, exists := nested["provenance"]; exists {
|
||||
t.Fatalf("nested non-param provenance should drop: %#v", nested)
|
||||
}
|
||||
// Type-only maps still count as parameter objects.
|
||||
typedOnly := stripSchemaValueCompact(map[string]any{"type": "string", "property": "remote"}).(map[string]any)
|
||||
if _, exists := typedOnly["property"]; exists {
|
||||
t.Fatalf("type-only param value = %#v", typedOnly)
|
||||
}
|
||||
mapSlice := stripSchemaValueCompact([]map[string]any{{"description": "leaf", "provenance": "drop"}}).([]map[string]any)
|
||||
if len(mapSlice) != 1 || mapSlice[0]["description"] != "leaf" {
|
||||
t.Fatalf("value compact []map = %#v", mapSlice)
|
||||
}
|
||||
if _, exists := mapSlice[0]["provenance"]; exists {
|
||||
t.Fatalf("value compact []map provenance should drop: %#v", mapSlice)
|
||||
}
|
||||
anySlice := stripSchemaValueCompact([]any{map[string]any{"description": "leaf", "provenance": "drop"}, "raw"}).([]any)
|
||||
if len(anySlice) != 2 || anySlice[1] != "raw" {
|
||||
t.Fatalf("value compact []any = %#v", anySlice)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"description": "calendar",
|
||||
"provenance": map[string]any{"source": "drop"},
|
||||
"parameters": parameters,
|
||||
"product": map[string]any{"description": "calendar", "provenance": "drop"},
|
||||
"products": []map[string]any{
|
||||
{"description": "calendar", "provenance": "drop"},
|
||||
},
|
||||
"tools": []any{
|
||||
map[string]any{"description": "leaf", "provenance": "drop"},
|
||||
"skip-me",
|
||||
},
|
||||
"constraints": map[string]any{"require_one_of": []any{}},
|
||||
}
|
||||
stripped := stripSchemaPayloadCompact(payload)
|
||||
if stripped["description"] != "calendar" {
|
||||
t.Fatalf("compact description = %#v", stripped["description"])
|
||||
}
|
||||
if _, exists := stripped["provenance"]; exists {
|
||||
t.Fatalf("compact should drop provenance: %#v", stripped)
|
||||
}
|
||||
if product, ok := stripped["product"].(map[string]any); !ok || product["description"] != "calendar" {
|
||||
t.Fatalf("compact product = %#v", stripped["product"])
|
||||
}
|
||||
if _, exists := stripped["product"].(map[string]any)["provenance"]; exists {
|
||||
t.Fatalf("nested product provenance should drop: %#v", stripped["product"])
|
||||
}
|
||||
if products, ok := stripped["products"].([]map[string]any); !ok || len(products) != 1 || products[0]["description"] != "calendar" {
|
||||
t.Fatalf("compact products = %#v", stripped["products"])
|
||||
}
|
||||
if tools, ok := stripped["tools"].([]any); !ok || len(tools) != 2 {
|
||||
t.Fatalf("compact tools = %#v", stripped["tools"])
|
||||
}
|
||||
if tool, ok := stripped["tools"].([]any)[0].(map[string]any); !ok || tool["description"] != "leaf" {
|
||||
t.Fatalf("compact tools[0] = %#v", stripped["tools"].([]any)[0])
|
||||
}
|
||||
if stripped["tools"].([]any)[1] != "skip-me" {
|
||||
t.Fatalf("compact tools[1] = %#v", stripped["tools"].([]any)[1])
|
||||
}
|
||||
// Non-map product values are retained verbatim.
|
||||
if got := stripSchemaPayloadCompact(map[string]any{"product": "raw"}); got["product"] != "raw" {
|
||||
t.Fatalf("non-map product = %#v", got["product"])
|
||||
}
|
||||
if got := stripSchemaPayloadCollectionCompact("raw"); got != "raw" {
|
||||
t.Fatalf("non-collection compact = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,38 +30,29 @@ import (
|
||||
// remains a precise reviewed exception for such a capability whose runtime
|
||||
// preconditions cannot be exercised safely and deterministically in the
|
||||
// isolated test process.
|
||||
type AgentExampleMode string
|
||||
type AgentExampleMode = contract.ExampleDispositionMode
|
||||
|
||||
const (
|
||||
AgentExampleModeContract AgentExampleMode = "contract"
|
||||
AgentExampleModeDryRun AgentExampleMode = "dry_run"
|
||||
AgentExampleModeContractOnly AgentExampleMode = "contract_only"
|
||||
AgentExampleModeContract = contract.ExampleDispositionModeContract
|
||||
AgentExampleModeDryRun = contract.ExampleDispositionModeDryRun
|
||||
AgentExampleModeContractOnly = contract.ExampleDispositionModeContractOnly
|
||||
)
|
||||
|
||||
// AgentExampleReasonCode is a closed taxonomy for reviewed contract-only
|
||||
// exceptions to an explicit dry-run capability.
|
||||
type AgentExampleReasonCode string
|
||||
type AgentExampleReasonCode = contract.ExampleDispositionReasonCode
|
||||
|
||||
const (
|
||||
AgentExampleReasonLocalState AgentExampleReasonCode = "local_state"
|
||||
AgentExampleReasonStatefulPreflight AgentExampleReasonCode = "stateful_preflight"
|
||||
AgentExampleReasonLocalState = contract.ExampleDispositionReasonLocalState
|
||||
AgentExampleReasonStatefulPreflight = contract.ExampleDispositionReasonStatefulPreflight
|
||||
)
|
||||
|
||||
// AgentExampleDisposition narrows one exact example with an explicit
|
||||
// typed dry-run capability to contract-only. Index is a pointer so a missing
|
||||
// field cannot silently select example zero.
|
||||
//
|
||||
// Dispositions are authored as an in-test / future ContractFinal extension
|
||||
// surface; production ContractFinal Selection currently does not declare them,
|
||||
// so the delivery plan treats every example as default-typed (contract or
|
||||
// dry_run from ToolSpec.DryRun).
|
||||
type AgentExampleDisposition struct {
|
||||
Index *int `json:"index"`
|
||||
Mode AgentExampleMode `json:"mode"`
|
||||
ReasonCode AgentExampleReasonCode `json:"reason_code"`
|
||||
Reason string `json:"reason"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
}
|
||||
// Dispositions are authored on the owning ContractFinal Selection.
|
||||
type AgentExampleDisposition = contract.ExampleDisposition
|
||||
|
||||
// AgentExampleExecution is one resolved example and its effective test mode.
|
||||
type AgentExampleExecution struct {
|
||||
|
||||
@@ -189,25 +189,6 @@ func cloneFieldCandidates(source []contract.FieldCandidateProvenance) []contract
|
||||
return out
|
||||
}
|
||||
|
||||
func lookupAgentToolMetadataFrom(source agentMetadata, paths ...string) (agentToolMetadata, bool) {
|
||||
seen := map[string]bool{}
|
||||
for _, path := range paths {
|
||||
for _, candidate := range []string{
|
||||
strings.TrimSpace(path),
|
||||
strings.Join(splitSchemaPathTokens(path), " "),
|
||||
} {
|
||||
if candidate == "" || seen[candidate] {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = true
|
||||
if metadata, ok := source.Tools[candidate]; ok {
|
||||
return metadata, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return agentToolMetadata{}, false
|
||||
}
|
||||
|
||||
// agentMetadataSummaryFromProducts publishes Catalog-level Agent coverage from
|
||||
// the assembled Schema surface (ContractFinal / ProductDecl). This keeps
|
||||
// runtime delivery and CI dumps hash-aligned without requiring build-time
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -94,9 +93,8 @@ func TestRuntimeSchemaIncludesAgentMetadata(t *testing.T) {
|
||||
// longer participates in assembly.
|
||||
root := buildRuntimeSchemaTestRoot()
|
||||
declareRuntimeSchemaTestRootDoc(t, root, nil)
|
||||
mcpFixture := embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}
|
||||
|
||||
leaf, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"doc.create_document"}, emptyAgentMetadata(), mcpFixture)
|
||||
leaf, err := runtimeSchemaPayloadForTest(root, []string{"doc.create_document"})
|
||||
if err != nil {
|
||||
t.Fatalf("runtimeSchemaPayloadForTest(leaf): %v", err)
|
||||
}
|
||||
@@ -110,7 +108,7 @@ func TestRuntimeSchemaIncludesAgentMetadata(t *testing.T) {
|
||||
t.Fatalf("leaf examples = %#v", leaf["examples"])
|
||||
}
|
||||
|
||||
catalog, err := runtimeSchemaPayloadForTestWithMetadata(root, nil, emptyAgentMetadata(), mcpFixture)
|
||||
catalog, err := runtimeSchemaPayloadForTest(root, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("runtimeSchemaPayloadForTest(catalog): %v", err)
|
||||
}
|
||||
@@ -136,7 +134,7 @@ func TestRuntimeSchemaIncludesAgentMetadata(t *testing.T) {
|
||||
t.Fatalf("product summary must not include examples: %#v", tools[0])
|
||||
}
|
||||
|
||||
registry, err := schemaRegistryForTestWithMetadata(root, emptyAgentMetadata(), mcpFixture)
|
||||
registry, err := schemaRegistryForTest(root)
|
||||
if err != nil {
|
||||
t.Fatalf("schemaRegistryForTest(): %v", err)
|
||||
}
|
||||
@@ -162,7 +160,7 @@ func TestRuntimeSchemaAllPayloadContainsFullLeafParameters(t *testing.T) {
|
||||
// exercises the production assembly path.
|
||||
root := buildRuntimeSchemaTestRoot()
|
||||
declareRuntimeSchemaTestRootDoc(t, root, nil)
|
||||
registry, err := schemaRegistryForTestWithMetadata(root, emptyAgentMetadata(), embeddedMCPMetadata{})
|
||||
registry, err := schemaRegistryForTest(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -203,9 +201,8 @@ func schemaTestInt(value any) int {
|
||||
}
|
||||
|
||||
func TestRuntimeSchemaUsesVersionedInterfaceRef(t *testing.T) {
|
||||
// interface_ref declares on the leaf ContractFinal; the injected MCP
|
||||
// fixture participates through the gated fixture lookup (remapped via the
|
||||
// declared Interface.Ref).
|
||||
// interface_ref declares on the leaf ContractFinal; MCP pin is not a
|
||||
// parameter candidate source.
|
||||
root := buildRuntimeSchemaTestRoot()
|
||||
declareRuntimeSchemaTestRootDoc(t, root, func(payload *contract.ContractFinalPayload) {
|
||||
payload.Interface = &contract.InterfaceSpec{
|
||||
@@ -215,17 +212,8 @@ func TestRuntimeSchemaUsesVersionedInterfaceRef(t *testing.T) {
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "documents", RPCName: "create_doc_v2"},
|
||||
}
|
||||
})
|
||||
mcpFixture := embeddedMCPMetadata{
|
||||
Tools: map[string]embeddedMCPToolMetadata{
|
||||
"documents.create_doc_v2": {
|
||||
Parameters: map[string]embeddedMCPParamMeta{
|
||||
"title": {Description: "MCP document title"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
payload, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"doc.create_document"}, emptyAgentMetadata(), mcpFixture)
|
||||
payload, err := runtimeSchemaPayloadForTest(root, []string{"doc.create_document"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -233,112 +221,11 @@ func TestRuntimeSchemaUsesVersionedInterfaceRef(t *testing.T) {
|
||||
if ref["product_id"] != "documents" || ref["rpc_name"] != "create_doc_v2" {
|
||||
t.Fatalf("interface_ref = %#v", payload["interface_ref"])
|
||||
}
|
||||
parameters, _ := payload["parameters"].(map[string]any)
|
||||
title, _ := parameters["title"].(map[string]any)
|
||||
if title["interface_description"] != "MCP document title" {
|
||||
t.Fatalf("title metadata = %#v", title)
|
||||
if payload["interface_mode"] != contract.InterfaceModeMCP {
|
||||
t.Fatalf("interface_mode = %#v", payload["interface_mode"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPRequiredParticipatesInSourcePrecedence(t *testing.T) {
|
||||
required := true
|
||||
mcpFixture := embeddedMCPMetadata{
|
||||
Tools: map[string]embeddedMCPToolMetadata{
|
||||
"sample.list_items": {
|
||||
Parameters: map[string]embeddedMCPParamMeta{
|
||||
"limit": {Required: &required},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
list := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
|
||||
list.Flags().Int("limit", 0, "optional page size")
|
||||
AttachRuntimeSchema(list, "sample", "list_items", "test")
|
||||
sample := &cobra.Command{Use: "sample"}
|
||||
sample.AddCommand(list)
|
||||
root.AddCommand(sample)
|
||||
declareSampleListItemsLeaf(t, list)
|
||||
|
||||
payload, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"sample.list_items"}, emptyAgentMetadata(), mcpFixture)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parameters, _ := payload["parameters"].(map[string]any)
|
||||
limit, _ := parameters["limit"].(map[string]any)
|
||||
if limit["required"] != true {
|
||||
t.Fatalf("MCP required candidate did not win over the default: %#v", limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPDefaultDoesNotOverrideCLIDefault(t *testing.T) {
|
||||
mcpFixture := embeddedMCPMetadata{
|
||||
Tools: map[string]embeddedMCPToolMetadata{
|
||||
"sample.list_items": {
|
||||
Parameters: map[string]embeddedMCPParamMeta{
|
||||
"limit": {Default: "50"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
list := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
|
||||
list.Flags().Int("limit", 10, "optional page size")
|
||||
AttachRuntimeSchema(list, "sample", "list_items", "test")
|
||||
sample := &cobra.Command{Use: "sample"}
|
||||
sample.AddCommand(list)
|
||||
root.AddCommand(sample)
|
||||
declareSampleListItemsLeaf(t, list)
|
||||
|
||||
payload, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"sample.list_items"}, emptyAgentMetadata(), mcpFixture)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parameters, _ := payload["parameters"].(map[string]any)
|
||||
limit, _ := parameters["limit"].(map[string]any)
|
||||
if limit["default"] != "10" || limit["interface_default"] != "50" {
|
||||
t.Fatalf("CLI and interface defaults were not separated: %#v", limit)
|
||||
}
|
||||
}
|
||||
|
||||
// declareSampleListItemsLeaf registers the ContractFinal / ProductDecl
|
||||
// declarations for the synthetic sample.list_items leaf so MCP fixture tests
|
||||
// assemble through the production path.
|
||||
func declareSampleListItemsLeaf(t *testing.T, list *cobra.Command) {
|
||||
t.Helper()
|
||||
contractfinal.RegisterRuntimeContractFinal(list, contract.ContractFinalPayload{
|
||||
Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "sample", Name: "list_items", CanonicalPath: "sample.list_items",
|
||||
CLIPath: "sample list", PrimaryCLIPath: "sample list",
|
||||
},
|
||||
Title: "List items",
|
||||
Description: "List sample items",
|
||||
Safety: &contract.SafetySpec{
|
||||
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "local", Availability: "available", Reason: "test local leaf",
|
||||
},
|
||||
Selection: &contract.SelectionSpec{
|
||||
AgentSummary: "List sample items",
|
||||
UseWhen: []string{"list sample items"},
|
||||
AvoidWhen: []string{"not listing"},
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(list) })
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "sample",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "Sample product",
|
||||
UseWhen: []string{"sample routing"},
|
||||
AvoidWhen: []string{"not sample"},
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { contract.ClearProductDeclForTest("sample") })
|
||||
}
|
||||
|
||||
func findSchemaProduct(products []map[string]any, id string) map[string]any {
|
||||
for _, product := range products {
|
||||
if product["id"] == id {
|
||||
|
||||
@@ -178,6 +178,7 @@ func contractFinalToolSelection(command *cobra.Command) AgentToolSelection {
|
||||
out.UseWhen = selection.UseWhen
|
||||
out.AvoidWhen = selection.AvoidWhen
|
||||
out.Examples = selection.Examples
|
||||
out.ExampleDispositions = selection.ExampleDispositions
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -508,6 +508,10 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leaf["future_audit_field"] = "must not leak into Agent view"
|
||||
for _, raw := range schemaMap(leaf["parameters"]) {
|
||||
raw["future_mapping_field"] = "must not leak into Agent view"
|
||||
}
|
||||
stripped := stripSchemaPayloadCompact(leaf)
|
||||
|
||||
// Must keep agent-essential fields.
|
||||
@@ -518,7 +522,7 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
|
||||
}
|
||||
|
||||
// Must strip provenance / redundant fields.
|
||||
for _, key := range []string{"agent_metadata_source", "agent_source_refs", "agent_summary_source", "effect_source", "metadata_source", "primary_cli_path", "parameter_count", "has_parameters", "interface_ref", "source", "title", "display"} {
|
||||
for _, key := range []string{"agent_metadata_source", "agent_source_refs", "agent_summary_source", "effect_source", "metadata_source", "primary_cli_path", "parameter_count", "has_parameters", "interface_ref", "source", "title", "display", "future_audit_field"} {
|
||||
if _, ok := stripped[key]; ok {
|
||||
t.Fatalf("compact leaf still contains stripped key %q", key)
|
||||
}
|
||||
@@ -528,7 +532,7 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
|
||||
if params, ok := stripped["parameters"].(map[string]any); ok {
|
||||
for name, p := range params {
|
||||
if pm, ok := p.(map[string]any); ok {
|
||||
for _, stripped := range []string{"interface_description", "interface_type", "property"} {
|
||||
for _, stripped := range []string{"interface_description", "interface_type", "property", "future_mapping_field"} {
|
||||
if _, present := pm[stripped]; present {
|
||||
t.Fatalf("compact param %q still contains %q", name, stripped)
|
||||
}
|
||||
@@ -1304,7 +1308,7 @@ func TestDeliveryCatalogContactParamDeclsMatchMergeBaseContract(t *testing.T) {
|
||||
}
|
||||
if want.interfaceType != "" {
|
||||
prov := schemaMap(param["field_provenance"])["interface_type"]
|
||||
if src, _ := prov["source"].(string); src != "native_annotation" && src != "mcp_metadata" {
|
||||
if src, _ := prov["source"].(string); src != "native_annotation" {
|
||||
t.Fatalf("%s --%s interface_type source = %#v", tc.path, flagName, prov)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ package cli
|
||||
var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
|
||||
{
|
||||
ID: "cli-management",
|
||||
Reason: "Local CLI lifecycle, authentication, configuration, recovery, and plugin-management commands are user-operated controls rather than stable Agent tools.",
|
||||
Reason: "Local CLI lifecycle, authentication, configuration, and plugin-management commands are user-operated controls rather than stable Agent tools.",
|
||||
Reviewed: true,
|
||||
Commands: []string{
|
||||
"api",
|
||||
@@ -53,9 +53,6 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
|
||||
"profile list",
|
||||
"profile switch",
|
||||
"profile use",
|
||||
"recovery execute",
|
||||
"recovery finalize",
|
||||
"recovery plan",
|
||||
"schema",
|
||||
"skill get",
|
||||
"skill install",
|
||||
@@ -171,62 +168,4 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
|
||||
"todo task remove-attachment",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "chat-shortcuts-pending-schema-curation",
|
||||
Reason: "These reviewed public Chat shortcuts remain executable and discoverable through the Shortcut catalog while their typed Schema selection and metadata records are curated; exact paths keep reverse completeness strict without hiding future shortcuts.",
|
||||
Reviewed: true,
|
||||
Commands: []string{
|
||||
"chat +category-add-conversation",
|
||||
"chat +category-list-conversations",
|
||||
"chat +category-remove-conversation",
|
||||
"chat +chat-add-bot",
|
||||
"chat +chat-audit-join",
|
||||
"chat +chat-create",
|
||||
"chat +chat-list",
|
||||
"chat +chat-get-by-id",
|
||||
"chat +chat-members-get",
|
||||
"chat +chat-members-list",
|
||||
"chat +chat-mute-member",
|
||||
"chat +chat-quit",
|
||||
"chat +chat-remove-bot",
|
||||
"chat +chat-role-remove",
|
||||
"chat +chat-role-remove-user",
|
||||
"chat +chat-transfer-owner",
|
||||
"chat +chat-update",
|
||||
"chat +chat-update-icon",
|
||||
"chat +chat-update-settings",
|
||||
"chat +conversation-clear-messages",
|
||||
"chat +conversation-clear-red-point",
|
||||
"chat +conversation-hide",
|
||||
"chat +conversation-mark-read",
|
||||
"chat +conversation-mark-unread",
|
||||
"chat +conversation-mute",
|
||||
"chat +conversation-set-top",
|
||||
"chat +feed-group-query-item",
|
||||
"chat +flag-cancel",
|
||||
"chat +flag-create",
|
||||
"chat +flag-list",
|
||||
"chat +messages-add-emoji",
|
||||
"chat +messages-add-text-emotion",
|
||||
"chat +messages-batch-recall-by-bot",
|
||||
"chat +messages-batch-send-by-bot",
|
||||
"chat +messages-combine-forward",
|
||||
"chat +messages-create-text-emotion",
|
||||
"chat +messages-forward",
|
||||
"chat +messages-forward-topic",
|
||||
"chat +messages-list",
|
||||
"chat +messages-recall",
|
||||
"chat +messages-recall-by-bot",
|
||||
"chat +messages-remove-emoji",
|
||||
"chat +messages-remove-text-emotion",
|
||||
"chat +messages-reply",
|
||||
"chat +messages-resource-download",
|
||||
"chat +messages-resource-url",
|
||||
"chat +messages-send-by-bot",
|
||||
"chat +messages-set-pin",
|
||||
"chat +messages-set-top",
|
||||
"chat +messages-unset-pin",
|
||||
"chat +messages-unset-top",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -123,16 +123,11 @@ func TestCrossPlatformCoverageAgentExampleRemainingBranches(t *testing.T) {
|
||||
t.Run("disposition narrows dry_run capability", func(t *testing.T) {
|
||||
bound, registry := crossPlatformAgentExampleFixture(t, func(_ *cobra.Command, payload *contract.ContractFinalPayload) {
|
||||
payload.DryRun = &contract.DryRunSpec{PreviewKind: "plan"}
|
||||
})
|
||||
t.Cleanup(restoreSelection)
|
||||
agentExampleSelectionFn = func(cmd *cobra.Command) AgentToolSelection {
|
||||
selection := contractFinalToolSelection(cmd)
|
||||
selection.ExampleDispositions = []AgentExampleDisposition{{
|
||||
Index: idx(0), Mode: AgentExampleModeContractOnly, Reviewed: true,
|
||||
Reason: "cannot dry-run safely", ReasonCode: AgentExampleReasonStatefulPreflight,
|
||||
payload.Selection.ExampleDispositions = []contract.ExampleDisposition{{
|
||||
Index: idx(0), Mode: contract.ExampleDispositionModeContractOnly, Reviewed: true,
|
||||
Reason: "cannot dry-run safely", ReasonCode: contract.ExampleDispositionReasonStatefulPreflight,
|
||||
}}
|
||||
return selection
|
||||
}
|
||||
})
|
||||
plan, err := BuildAgentExampleExecutionPlan(bound, registry)
|
||||
if err != nil {
|
||||
t.Fatalf("plan error = %v", err)
|
||||
@@ -146,16 +141,12 @@ func TestCrossPlatformCoverageAgentExampleRemainingBranches(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("disposition without dry_run capability fails", func(t *testing.T) {
|
||||
bound, registry := crossPlatformAgentExampleFixture(t, nil)
|
||||
t.Cleanup(restoreSelection)
|
||||
agentExampleSelectionFn = func(cmd *cobra.Command) AgentToolSelection {
|
||||
selection := contractFinalToolSelection(cmd)
|
||||
selection.ExampleDispositions = []AgentExampleDisposition{{
|
||||
Index: idx(0), Mode: AgentExampleModeContractOnly, Reviewed: true,
|
||||
Reason: "no dry run", ReasonCode: AgentExampleReasonLocalState,
|
||||
bound, registry := crossPlatformAgentExampleFixture(t, func(_ *cobra.Command, payload *contract.ContractFinalPayload) {
|
||||
payload.Selection.ExampleDispositions = []contract.ExampleDisposition{{
|
||||
Index: idx(0), Mode: contract.ExampleDispositionModeContractOnly, Reviewed: true,
|
||||
Reason: "no dry run", ReasonCode: contract.ExampleDispositionReasonLocalState,
|
||||
}}
|
||||
return selection
|
||||
}
|
||||
})
|
||||
_, err := BuildAgentExampleExecutionPlan(bound, registry)
|
||||
if err == nil || !strings.Contains(err.Error(), "narrows no explicit dry_run") {
|
||||
t.Fatalf("error = %v", err)
|
||||
|
||||
@@ -137,8 +137,8 @@ func schemaToolSpecFromPayload(payload map[string]any) (ToolSpec, error) {
|
||||
|
||||
// runtimeCommandParameters is the compatibility wire adapter used only by
|
||||
// tests; resolution happens in runtimeCommandParameterSpecs.
|
||||
func runtimeCommandParameters(cmd *cobra.Command, canonicalPath string, pinnedParams map[string]embeddedMCPParamMeta, constraints RuntimeSchemaConstraints) (map[string]any, error) {
|
||||
specs, err := runtimeCommandParameterSpecsForPayload(cmd, canonicalPath, pinnedParams, constraints)
|
||||
func runtimeCommandParameters(cmd *cobra.Command, canonicalPath string, constraints RuntimeSchemaConstraints) (map[string]any, error) {
|
||||
specs, err := runtimeCommandParameterSpecsForPayload(cmd, canonicalPath, constraints)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -969,16 +969,16 @@ func TestOverallCoverageGapDeliveryCompletenessAndDryRun(t *testing.T) {
|
||||
func TestOverallCoverageGapRuntimeParamsAndAgentMetadata(t *testing.T) {
|
||||
prevSpecs := runtimeCommandParameterSpecsForPayload
|
||||
t.Cleanup(func() { runtimeCommandParameterSpecsForPayload = prevSpecs })
|
||||
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
return nil, fmt.Errorf("specs boom")
|
||||
}
|
||||
if _, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil {
|
||||
if _, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", RuntimeSchemaConstraints{}); err == nil {
|
||||
t.Fatal("parameter specs error must surface")
|
||||
}
|
||||
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
return []ParameterSpec{{Name: "ok", Type: "string"}}, nil
|
||||
}
|
||||
payload, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", nil, RuntimeSchemaConstraints{})
|
||||
payload, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", RuntimeSchemaConstraints{})
|
||||
if err != nil || payload["ok"] == nil {
|
||||
t.Fatalf("parameter payload = %#v err=%v", payload, err)
|
||||
}
|
||||
@@ -1070,24 +1070,6 @@ func TestOverallCoverageGapRuntimeParamsAndAgentMetadata(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOverallRegressionRecovery(t *testing.T) {
|
||||
if _, ok := lookupPinnedMCPParam(nil, "property", "flag"); ok {
|
||||
t.Fatal("nil pinned params must miss")
|
||||
}
|
||||
if _, ok := lookupPinnedMCPParam(map[string]embeddedMCPParamMeta{}, "property", "flag"); ok {
|
||||
t.Fatal("empty pinned params must miss")
|
||||
}
|
||||
if _, ok := lookupPinnedMCPParam(map[string]embeddedMCPParamMeta{"other": {Type: "string"}}, "property", "flag"); ok {
|
||||
t.Fatal("unmatched pinned params must miss")
|
||||
}
|
||||
if _, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{}, agentMetadata{}, embeddedMCPMetadata{}); ok {
|
||||
t.Fatal("empty MCP metadata must not match")
|
||||
}
|
||||
if _, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{}, agentMetadata{}, embeddedMCPMetadata{
|
||||
Tools: map[string]embeddedMCPToolMetadata{"other.key": {}},
|
||||
}); ok {
|
||||
t.Fatal("missing MCP metadata keys must not match")
|
||||
}
|
||||
|
||||
left := runtimeSchemaStringCandidateAtPriority("same", true, "z-source", 5, "p")
|
||||
right := runtimeSchemaStringCandidateAtPriority("same", true, "a-source", 5, "p")
|
||||
winner, err := resolveRuntimeSchemaCandidate("source-order", left, right)
|
||||
|
||||
@@ -410,7 +410,7 @@ func TestRuntimeCommandParameterSpecsPreserveReviewedEmptyPropertyProvenance(t *
|
||||
cmd := &cobra.Command{Use: "query"}
|
||||
cmd.Flags().Bool("all", false, "fetch every page")
|
||||
|
||||
parameters, err := runtimeCommandParameterSpecs(cmd, "aitable.query_records", nil, RuntimeSchemaConstraints{})
|
||||
parameters, err := runtimeCommandParameterSpecs(cmd, "aitable.query_records", RuntimeSchemaConstraints{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -448,6 +448,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"drive.download_file --output": "local output path",
|
||||
"drive.download_file --parallel": "local multipart download control; never sent to download_file",
|
||||
"drive.download_file --part-size": "local multipart download control; never sent to download_file",
|
||||
"drive.download_file --version": "Polymorphic dispatch: --version switches the MCP tool call from download_file to download_file_version; not a download_file interface property",
|
||||
"drive.download_file_version --no-resume": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI-local multipart download control and does not publish a direct interface property.",
|
||||
"drive.download_file_version --node": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.download_file_version --output": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --output is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -655,7 +656,6 @@ var reviewedSchemaParameterBindingRemovals = map[string]schemaParameterBindingRe
|
||||
"contact.get_dept_info_by_dept_id --id": {Reason: "The public flag was renamed from --id to the unambiguous --dept spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
|
||||
"contact.get_dept_members_by_deptId --ids": {Reason: "The public flag was renamed from --ids to the unambiguous --depts spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
|
||||
"contact.get_sub_depts_by_dept_id --id": {Reason: "The public flag was renamed from --id to the unambiguous --dept spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
|
||||
"drive.download_file --version": {Reason: "Polymorphic dispatch: --version switches the MCP tool call from download_file to download_file_version; the version property belongs to download_file_version metadata, not download_file.", Reviewed: true},
|
||||
"minutes.query_user_tag_list --limit": {Reason: "The current helper and pinned interface have no pagination input.", Reviewed: true},
|
||||
"oa.list_pending_approvals --size": {Reason: "The public pagination flag was normalized from --size to --limit; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
|
||||
"oa.list_user_visible_process --size": {Reason: "The public pagination flag was normalized from --size to --limit; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
|
||||
|
||||
@@ -70,18 +70,6 @@ func schemaRegistryForTest(root *cobra.Command) (SchemaRegistry, error) {
|
||||
return AssembleSchemaRegistryFromBound(bound)
|
||||
}
|
||||
|
||||
func schemaRegistryForTestWithMetadata(root *cobra.Command, agent agentMetadata, mcp embeddedMCPMetadata) (SchemaRegistry, error) {
|
||||
bound, err := boundTestCommandRegistry(root)
|
||||
if err != nil {
|
||||
return SchemaRegistry{}, err
|
||||
}
|
||||
// Production-shaped assembly: leaves must carry ContractFinal and products
|
||||
// a ProductDecl (see declareRuntimeSchemaTestRootDoc). Injected MCP/agent
|
||||
// fixtures participate only through the gated fixture lookup in
|
||||
// runtimeToolSpecFromContractFinal; production passes an empty pin.
|
||||
return assembleSchemaRegistryFromBound(bound, runtimeSchemaMetadataSources{Agent: agent, MCP: mcp})
|
||||
}
|
||||
|
||||
// declareRuntimeSchemaTestRootDoc registers the ContractFinal / ProductDecl
|
||||
// declarations for the synthetic doc.create_document tree built by
|
||||
// buildRuntimeSchemaTestRoot, so production-shaped assembly can resolve it.
|
||||
@@ -156,18 +144,6 @@ func runtimeSchemaPayloadForTest(root *cobra.Command, args []string) (map[string
|
||||
return schemaPayloadFromLoadedCatalog(loaded, args)
|
||||
}
|
||||
|
||||
func runtimeSchemaPayloadForTestWithMetadata(root *cobra.Command, args []string, agent agentMetadata, mcp embeddedMCPMetadata) (map[string]any, error) {
|
||||
registry, err := schemaRegistryForTestWithMetadata(root, agent, mcp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loaded, err := loadedSchemaCatalogForTestRegistry(registry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return schemaPayloadFromLoadedCatalog(loaded, args)
|
||||
}
|
||||
|
||||
func runtimeSchemaAllPayloadForTest(root *cobra.Command) (map[string]any, error) {
|
||||
registry, err := schemaRegistryForTest(root)
|
||||
if err != nil {
|
||||
|
||||
@@ -15,8 +15,10 @@ import (
|
||||
)
|
||||
|
||||
type runtimeSchemaMetadataSources struct {
|
||||
// Agent remains only for historical test seams that still construct this
|
||||
// struct; production assembly does not overlay Agent or MCP pin onto
|
||||
// parameters or tool text.
|
||||
Agent agentMetadata
|
||||
MCP embeddedMCPMetadata
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -62,10 +64,9 @@ func (resolved ResolvedSchemaBuild) CommandCount() int {
|
||||
}
|
||||
|
||||
func pinnedRuntimeSchemaMetadataSources() runtimeSchemaMetadataSources {
|
||||
return runtimeSchemaMetadataSources{
|
||||
Agent: runtimeAgentMetadata(),
|
||||
MCP: emptyPinnedMCPMetadata(),
|
||||
}
|
||||
// Production pin and Agent inject are both retired; assembly is Contract /
|
||||
// ParamDecl / Cobra only.
|
||||
return runtimeSchemaMetadataSources{}
|
||||
}
|
||||
|
||||
// ResolveSchemaBuild is the only assembly path from executable Cobra commands
|
||||
@@ -123,7 +124,7 @@ func AssembleSchemaRegistryFromBound(bound BoundCommandRegistry) (SchemaRegistry
|
||||
|
||||
// assembleSchemaRegistryFromBound resolves every entry through the
|
||||
// ContractFinal / ProductDecl production path. Missing declarations fail
|
||||
// closed; retired skill/MCP/agent-inject overlays are never reopened.
|
||||
// closed; retired skill/MCP-pin/agent-inject overlays are never reopened.
|
||||
func assembleSchemaRegistryFromBound(bound BoundCommandRegistry, metadata runtimeSchemaMetadataSources) (SchemaRegistry, error) {
|
||||
entries, err := assembleCollectEntries(bound)
|
||||
if err != nil {
|
||||
@@ -197,18 +198,12 @@ func assembleProductSelection(entry runtimeSchemaEntry) (contract.SelectionSpec,
|
||||
|
||||
// runtimeToolSpecFromContractFinal pass-throughs Contract-authored Schema fields.
|
||||
// Declared values are the final data source; hints/registry text does not merge.
|
||||
// Production MCP pin is empty, so assembly skips MCP-metadata lookups entirely;
|
||||
// interface_type / interface_* facts come from ParamDecl / native annotations.
|
||||
// Tests may still inject a non-empty MCP fixture map, which participates through
|
||||
// pinnedMCPMetadataForEntryFrom.
|
||||
// MCP pin is retired: interface_type / interface_* facts come from ParamDecl /
|
||||
// native annotations only.
|
||||
func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.ContractFinalPayload, metadata runtimeSchemaMetadataSources) (ToolSpec, error) {
|
||||
_ = metadata // reserved for historical assemble seams; no overlay sources remain
|
||||
canonicalPath := entry.ProductID + "." + entry.ToolName
|
||||
constraints := runtimeCommandConstraints(entry.Command)
|
||||
var pinnedParams map[string]embeddedMCPParamMeta
|
||||
if len(metadata.MCP.Tools) > 0 {
|
||||
pinnedMeta, _ := pinnedMCPMetadataForEntryFrom(entry, metadata.Agent, metadata.MCP)
|
||||
pinnedParams = pinnedMeta.Parameters
|
||||
}
|
||||
// Apply parameter declarations from the contract.ContractFinalPayload before the
|
||||
// resolver reads them. The decls were put there by AttachContract at
|
||||
// DeclareLeafMetadata time; now that all flags exist on the fully-built
|
||||
@@ -216,7 +211,7 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
|
||||
if err := ApplyParamDecls(entry.Command, final.Parameters); err != nil {
|
||||
return ToolSpec{}, fmt.Errorf("apply Contract Schema ParamDecls for %s: %w", canonicalPath, err)
|
||||
}
|
||||
parameters, err := resolveRuntimeParameters(entry.Command, canonicalPath, pinnedParams, constraints)
|
||||
parameters, err := resolveRuntimeParameters(entry.Command, canonicalPath, constraints)
|
||||
if err != nil {
|
||||
return ToolSpec{}, fmt.Errorf("resolve Contract Schema parameters for %s: %w", canonicalPath, err)
|
||||
}
|
||||
@@ -332,6 +327,10 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
|
||||
reviewed := true
|
||||
selection.Reviewed = &reviewed
|
||||
}
|
||||
// Example dispositions control only the policy gate's execution eligibility.
|
||||
// They remain on ContractFinal for BuildAgentExampleExecutionPlan and are not
|
||||
// part of the public ToolSpec / Schema wire contract.
|
||||
selection.ExampleDispositions = nil
|
||||
|
||||
provenance := contractFinalProvenance(identity, title, description, titleProv, descriptionProv, safety, interfaceSpec, selection, final.DryRun)
|
||||
|
||||
|
||||
@@ -278,23 +278,10 @@ func TestCrossPlatformCoverageRenderSafetyAnnotationSuccess(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMCPMetadataInterfaceRefEdges(t *testing.T) {
|
||||
if _, ok := mcpMetadataForInterfaceRef(embeddedMCPMetadata{}, " ", " "); ok {
|
||||
t.Fatal("blank interface ref must miss")
|
||||
}
|
||||
if _, ok := mcpMetadataForInterfaceRef(embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}, "chat", "missing"); ok {
|
||||
t.Fatal("missing MCP tool must miss")
|
||||
}
|
||||
agent := agentMetadata{Tools: map[string]agentToolMetadata{
|
||||
"chat reply": {InterfaceRef: &embeddedMCPInterfaceRef{ProductID: "chat", RPCName: "send_personal_message"}},
|
||||
}}
|
||||
mcp := embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{
|
||||
"chat.send_personal_message": {Parameters: map[string]embeddedMCPParamMeta{"clawType": {Type: "string"}}},
|
||||
}}
|
||||
got, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{
|
||||
PrimaryCLIPath: "chat reply", ProductID: "chat", ToolName: "reply_personal_message",
|
||||
}, agent, mcp)
|
||||
if !ok || got.Parameters["clawType"].Type != "string" {
|
||||
t.Fatalf("agent InterfaceRef remap = %#v ok=%v", got, ok)
|
||||
// MCP pin lookup helpers are retired; keep this named coverage slot as a
|
||||
// no-op marker so CrossPlatformCoverage* selection stays stable.
|
||||
if got := emptyPinnedMCPMetadata(); got.Tools == nil || len(got.Tools) != 0 {
|
||||
t.Fatalf("empty pinned metadata = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -200,6 +200,10 @@ type SelectionSpec struct {
|
||||
Tips []string
|
||||
WorkflowRefs []string
|
||||
Examples []string
|
||||
// ExampleDispositions narrows an exact example with a reviewed local or
|
||||
// stateful precondition from dry-run execution to contract validation.
|
||||
// It does not change the command's declared DryRun capability.
|
||||
ExampleDispositions []ExampleDisposition
|
||||
// Reviewed is a legacy-path (hints/registry) marker only. The Contract
|
||||
// declaration path must not set it: declared selection is final by
|
||||
// construction, and assembly rejects a declared payload carrying it.
|
||||
@@ -219,10 +223,54 @@ func (s SelectionSpec) Normalized() SelectionSpec {
|
||||
out.Tips = stableUniqueStrings(s.Tips)
|
||||
out.WorkflowRefs = stableUniqueStrings(s.WorkflowRefs)
|
||||
out.Examples = stableUniqueStrings(s.Examples)
|
||||
out.ExampleDispositions = cloneExampleDispositions(s.ExampleDispositions)
|
||||
out.SourceRefs = sortedUniqueStrings(s.SourceRefs)
|
||||
return out
|
||||
}
|
||||
|
||||
// ExampleDispositionMode controls how an already contract-validated example
|
||||
// is exercised by the Agent example gate.
|
||||
type ExampleDispositionMode string
|
||||
|
||||
const (
|
||||
ExampleDispositionModeContract ExampleDispositionMode = "contract"
|
||||
ExampleDispositionModeDryRun ExampleDispositionMode = "dry_run"
|
||||
ExampleDispositionModeContractOnly ExampleDispositionMode = "contract_only"
|
||||
)
|
||||
|
||||
// ExampleDispositionReasonCode is the closed taxonomy for reviewed
|
||||
// contract-only exceptions to an explicit dry-run capability.
|
||||
type ExampleDispositionReasonCode string
|
||||
|
||||
const (
|
||||
ExampleDispositionReasonLocalState ExampleDispositionReasonCode = "local_state"
|
||||
ExampleDispositionReasonStatefulPreflight ExampleDispositionReasonCode = "stateful_preflight"
|
||||
)
|
||||
|
||||
// ExampleDisposition narrows one exact example to contract-only validation.
|
||||
// Index is a pointer so a missing index cannot silently select example zero.
|
||||
type ExampleDisposition struct {
|
||||
Index *int `json:"index"`
|
||||
Mode ExampleDispositionMode `json:"mode"`
|
||||
ReasonCode ExampleDispositionReasonCode `json:"reason_code"`
|
||||
Reason string `json:"reason"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
}
|
||||
|
||||
func cloneExampleDispositions(in []ExampleDisposition) []ExampleDisposition {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := append([]ExampleDisposition(nil), in...)
|
||||
for i := range out {
|
||||
if out[i].Index != nil {
|
||||
index := *out[i].Index
|
||||
out[i].Index = &index
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ParamDecl is one parameter-level Schema fact declared on a command. It is
|
||||
// stored at DeclareLeafMetadata time and applied as annotations at assembly
|
||||
// time, when all flags are guaranteed to exist on the fully-built command tree.
|
||||
|
||||
@@ -75,9 +75,14 @@ func TestCrossPlatformCoverageInterfaceSpecAgentExecutableAndValidate(t *testing
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSelectionSpecNormalizedAndProvenanceHelpers(t *testing.T) {
|
||||
exampleIndex := 0
|
||||
normalized := (SelectionSpec{
|
||||
UseWhen: []string{" one ", "one", ""},
|
||||
AvoidWhen: []string{"avoid"},
|
||||
UseWhen: []string{" one ", "one", ""},
|
||||
AvoidWhen: []string{"avoid"},
|
||||
ExampleDispositions: []ExampleDisposition{{
|
||||
Index: &exampleIndex, Mode: ExampleDispositionModeContractOnly,
|
||||
ReasonCode: ExampleDispositionReasonLocalState, Reason: "local file", Reviewed: true,
|
||||
}},
|
||||
SourceRefs: []string{"b", "a", "b"},
|
||||
}).Normalized()
|
||||
if len(normalized.UseWhen) != 1 || normalized.UseWhen[0] != "one" {
|
||||
@@ -86,6 +91,16 @@ func TestCrossPlatformCoverageSelectionSpecNormalizedAndProvenanceHelpers(t *tes
|
||||
if normalized.SourceRefs[0] != "a" || normalized.SourceRefs[1] != "b" {
|
||||
t.Fatalf("SourceRefs = %#v", normalized.SourceRefs)
|
||||
}
|
||||
if len(normalized.ExampleDispositions) != 1 || normalized.ExampleDispositions[0].Index == nil || *normalized.ExampleDispositions[0].Index != 0 {
|
||||
t.Fatalf("ExampleDispositions = %#v", normalized.ExampleDispositions)
|
||||
}
|
||||
exampleIndex = 1
|
||||
if *normalized.ExampleDispositions[0].Index != 0 {
|
||||
t.Fatal("ExampleDispositions index was not cloned")
|
||||
}
|
||||
if got := cloneExampleDispositions(nil); got != nil {
|
||||
t.Fatalf("cloneExampleDispositions(nil) = %#v", got)
|
||||
}
|
||||
if got := stableUniqueStrings(nil); got != nil {
|
||||
t.Fatalf("stableUniqueStrings(nil) = %#v", got)
|
||||
}
|
||||
|
||||
+15
-10
@@ -103,11 +103,12 @@ const ValidationShortcut FlagValidationMode = "shortcut"
|
||||
// fields intentionally mirror the former helpers.LeafFlag one-for-one so that
|
||||
// helpers can alias to it without touching any call site.
|
||||
type FlagSpec struct {
|
||||
Name string // flag name (kebab-case)
|
||||
Usage string // registration usage text
|
||||
Kind FlagKind // value type, defaults to KindString
|
||||
Default string // registration default for every Kind; also the fallback-chain tail when aliases/env are empty
|
||||
Hidden bool // hide the real flag from help/Schema while keeping it invocable
|
||||
Name string // flag name (kebab-case)
|
||||
Shorthand string // optional one-character Cobra shorthand
|
||||
Usage string // registration usage text
|
||||
Kind FlagKind // value type, defaults to KindString
|
||||
Default string // registration default for every Kind; also the fallback-chain tail when aliases/env are empty
|
||||
Hidden bool // hide the real flag from help/Schema while keeping it invocable
|
||||
|
||||
// Required, when true, validates a non-empty effective value in RunE. Plain
|
||||
// Required flags aggregate into a cmdutil.ValidateRequiredFlags-compatible
|
||||
@@ -551,7 +552,7 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
|
||||
"flag %q: MarkRequired cannot be combined with Aliases: cobra MarkFlagRequired only recognizes the main name, so a value passed via an alias would be rejected",
|
||||
flag.Name))
|
||||
}
|
||||
RegisterFlag(cmd, flag.Kind, flag.Name, flag.Default, flag.Usage)
|
||||
registerFlagP(cmd, flag.Kind, flag.Name, flag.Shorthand, flag.Default, flag.Usage)
|
||||
// Aliases are registered with the main flag's Kind, otherwise an integer
|
||||
// alias's value would never be readable (silently dropped).
|
||||
for _, alias := range flag.Aliases {
|
||||
@@ -572,6 +573,10 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
|
||||
// Malformed KindInt / KindBool Default values panic at registration (fail-closed)
|
||||
// instead of silently degrading to 0 / false.
|
||||
func RegisterFlag(cmd *cobra.Command, kind FlagKind, name, def, usage string) {
|
||||
registerFlagP(cmd, kind, name, "", def, usage)
|
||||
}
|
||||
|
||||
func registerFlagP(cmd *cobra.Command, kind FlagKind, name, shorthand, def, usage string) {
|
||||
switch kind {
|
||||
case KindInt:
|
||||
defInt := 0
|
||||
@@ -582,7 +587,7 @@ func RegisterFlag(cmd *cobra.Command, kind FlagKind, name, def, usage string) {
|
||||
}
|
||||
defInt = v
|
||||
}
|
||||
cmd.Flags().Int(name, defInt, usage)
|
||||
cmd.Flags().IntP(name, shorthand, defInt, usage)
|
||||
case KindBool:
|
||||
defBool := false
|
||||
if def != "" {
|
||||
@@ -595,15 +600,15 @@ func RegisterFlag(cmd *cobra.Command, kind FlagKind, name, def, usage string) {
|
||||
panic(fmt.Sprintf("flag %q: invalid KindBool Default %q (want \"true\" or \"false\")", name, def))
|
||||
}
|
||||
}
|
||||
cmd.Flags().Bool(name, defBool, usage)
|
||||
cmd.Flags().BoolP(name, shorthand, defBool, usage)
|
||||
case KindStringSlice:
|
||||
var defaults []string
|
||||
if value := strings.TrimSpace(def); value != "" {
|
||||
defaults = strings.Split(value, ",")
|
||||
}
|
||||
cmd.Flags().StringSlice(name, defaults, usage)
|
||||
cmd.Flags().StringSliceP(name, shorthand, defaults, usage)
|
||||
default:
|
||||
cmd.Flags().String(name, def, usage)
|
||||
cmd.Flags().StringP(name, shorthand, def, usage)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,10 +67,10 @@ func testDestructiveSafety() contract.SafetySpec {
|
||||
func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
|
||||
cmd := newTestCommand()
|
||||
RegisterFlags(cmd, []FlagSpec{
|
||||
{Name: "s", Usage: "S", Default: "d"},
|
||||
{Name: "i", Usage: "I", Kind: KindInt, Aliases: []string{"i-alias"}},
|
||||
{Name: "b", Usage: "B", Kind: KindBool},
|
||||
{Name: "sl", Usage: "SL", Kind: KindStringSlice, Default: "a,b", Aliases: []string{"sl-alias"}},
|
||||
{Name: "s", Shorthand: "s", Usage: "S", Default: "d"},
|
||||
{Name: "i", Shorthand: "i", Usage: "I", Kind: KindInt, Aliases: []string{"i-alias"}},
|
||||
{Name: "b", Shorthand: "b", Usage: "B", Kind: KindBool},
|
||||
{Name: "sl", Shorthand: "l", Usage: "SL", Kind: KindStringSlice, Default: "a,b", Aliases: []string{"sl-alias"}},
|
||||
{Name: "req", Usage: "R", MarkRequired: true},
|
||||
{Name: "hidden", Usage: "H", Hidden: true},
|
||||
})
|
||||
@@ -78,6 +78,11 @@ func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
|
||||
if f := cmd.Flags().Lookup("s"); f == nil || f.DefValue != "d" || f.Usage != "S" {
|
||||
t.Fatalf("string flag = %#v", f)
|
||||
}
|
||||
for shorthand, name := range map[string]string{"s": "s", "i": "i", "b": "b", "l": "sl"} {
|
||||
if flag := cmd.Flags().ShorthandLookup(shorthand); flag == nil || flag.Name != name {
|
||||
t.Fatalf("shorthand -%s = %#v, want --%s", shorthand, flag, name)
|
||||
}
|
||||
}
|
||||
for name, wantType := range map[string]string{"i": "int", "b": "bool", "sl": "stringSlice"} {
|
||||
f := cmd.Flags().Lookup(name)
|
||||
if f == nil || f.Value.Type() != wantType {
|
||||
|
||||
@@ -46,6 +46,9 @@ type Error struct {
|
||||
Message string
|
||||
Operation string
|
||||
ServerKey string
|
||||
Origin string
|
||||
FailureStage string
|
||||
ExecutionStarted *bool
|
||||
Retryable bool
|
||||
RetryableSet bool
|
||||
RetryAfterSeconds *int64
|
||||
@@ -55,6 +58,7 @@ type Error struct {
|
||||
Actions []string
|
||||
AvailableFlags []string
|
||||
Snapshot string
|
||||
Details map[string]any
|
||||
RPCCode int `json:"rpc_code,omitempty"`
|
||||
RPCData json.RawMessage `json:"rpc_data,omitempty"`
|
||||
ServerDiag ServerDiagnostics `json:"-"`
|
||||
@@ -107,6 +111,32 @@ func WithServerKey(serverKey string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithOrigin records the component that produced the failure, such as the
|
||||
// client, MCP gateway, or DingTalk API. It is independent from Category,
|
||||
// which remains the stable exit-code contract.
|
||||
func WithOrigin(origin string) Option {
|
||||
return func(err *Error) {
|
||||
err.Origin = strings.TrimSpace(origin)
|
||||
}
|
||||
}
|
||||
|
||||
// WithFailureStage records the execution stage at which the failure occurred.
|
||||
func WithFailureStage(stage string) Option {
|
||||
return func(err *Error) {
|
||||
err.FailureStage = strings.TrimSpace(stage)
|
||||
}
|
||||
}
|
||||
|
||||
// WithExecutionStarted records whether the downstream business operation was
|
||||
// known to have started. Unknown state must be represented by omitting this
|
||||
// option, which is important for safe retry decisions on write operations.
|
||||
func WithExecutionStarted(started bool) Option {
|
||||
return func(err *Error) {
|
||||
value := started
|
||||
err.ExecutionStarted = &value
|
||||
}
|
||||
}
|
||||
|
||||
// WithRetryable marks whether the error can be retried safely.
|
||||
func WithRetryable(retryable bool) Option {
|
||||
return func(err *Error) {
|
||||
@@ -186,6 +216,21 @@ func WithSnapshot(path string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithDetails records an additive machine-readable payload for errors whose
|
||||
// recovery needs typed context, such as ambiguous target-resolution
|
||||
// candidates. Callers must keep credentials and other secrets out of details.
|
||||
func WithDetails(details map[string]any) Option {
|
||||
return func(err *Error) {
|
||||
if len(details) == 0 {
|
||||
return
|
||||
}
|
||||
err.Details = make(map[string]any, len(details))
|
||||
for key, value := range details {
|
||||
err.Details[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRPCCode records the original JSON-RPC error code.
|
||||
func WithRPCCode(code int) Option {
|
||||
return func(err *Error) {
|
||||
@@ -295,6 +340,15 @@ func PrintJSON(w io.Writer, err error) error {
|
||||
if typed.ServerKey != "" {
|
||||
errorPayload["server_key"] = typed.ServerKey
|
||||
}
|
||||
if typed.Origin != "" {
|
||||
errorPayload["origin"] = typed.Origin
|
||||
}
|
||||
if typed.FailureStage != "" {
|
||||
errorPayload["stage"] = typed.FailureStage
|
||||
}
|
||||
if typed.ExecutionStarted != nil {
|
||||
errorPayload["execution_started"] = *typed.ExecutionStarted
|
||||
}
|
||||
if typed.RetryableSet {
|
||||
errorPayload["retryable"] = typed.Retryable
|
||||
}
|
||||
@@ -316,6 +370,9 @@ func PrintJSON(w io.Writer, err error) error {
|
||||
if typed.Snapshot != "" {
|
||||
errorPayload["snapshot_path"] = typed.Snapshot
|
||||
}
|
||||
if len(typed.Details) > 0 {
|
||||
errorPayload["details"] = typed.Details
|
||||
}
|
||||
if typed.RPCCode != 0 {
|
||||
errorPayload["rpc_code"] = typed.RPCCode
|
||||
}
|
||||
@@ -447,6 +504,15 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
|
||||
if typed.ServerKey != "" {
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Server: %s", typed.ServerKey)))
|
||||
}
|
||||
if typed.Origin != "" {
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Origin: %s", typed.Origin)))
|
||||
}
|
||||
if typed.FailureStage != "" {
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Stage: %s", typed.FailureStage)))
|
||||
}
|
||||
if typed.ExecutionStarted != nil {
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Execution Started: %t", *typed.ExecutionStarted)))
|
||||
}
|
||||
if typed.Snapshot != "" {
|
||||
lines = append(lines, tui.Dim(fmt.Sprintf("Snapshot: %s", typed.Snapshot)))
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestExitCodeByCategory(t *testing.T) {
|
||||
func TestCrossPlatformCoverageExitCodeByCategory(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
@@ -42,17 +42,24 @@ func TestExitCodeByCategory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJSON(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewValidation(
|
||||
"bad flag",
|
||||
WithReason("missing_required_flag"),
|
||||
WithOrigin("client"),
|
||||
WithFailureStage("request_validation"),
|
||||
WithExecutionStarted(false),
|
||||
WithHint("Pass the required flag and retry."),
|
||||
WithRetryable(true),
|
||||
WithActions("dws schema doc.create_document", "retry command"),
|
||||
WithSnapshot("/tmp/dws-recovery/snapshot.json"),
|
||||
WithDetails(map[string]any{
|
||||
"type": "resolution",
|
||||
"query": "项目群",
|
||||
}),
|
||||
)); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
@@ -67,6 +74,11 @@ func TestPrintJSON(t *testing.T) {
|
||||
if !strings.Contains(got, "\"reason\": \"missing_required_flag\"") {
|
||||
t.Fatalf("expected reason in output, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "\"origin\": \"client\"") ||
|
||||
!strings.Contains(got, "\"stage\": \"request_validation\"") ||
|
||||
!strings.Contains(got, "\"execution_started\": false") {
|
||||
t.Fatalf("expected failure provenance in output, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "\"retryable\": true") {
|
||||
t.Fatalf("expected retryable in output, got %q", got)
|
||||
}
|
||||
@@ -76,6 +88,9 @@ func TestPrintJSON(t *testing.T) {
|
||||
if !strings.Contains(got, "\"snapshot_path\": \"/tmp/dws-recovery/snapshot.json\"") {
|
||||
t.Fatalf("expected snapshot path in output, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "\"type\": \"resolution\"") || !strings.Contains(got, "\"query\": \"项目群\"") {
|
||||
t.Fatalf("expected structured details in output, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRetryabilityTriStateAndRetryTiming(t *testing.T) {
|
||||
@@ -171,7 +186,7 @@ func TestCrossPlatformCoverageRetryTimingOptionsIgnoreInvalidValues(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJSON_AvailableFlags(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintJSON_AvailableFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
@@ -192,7 +207,7 @@ func TestPrintJSON_AvailableFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintHuman(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintHuman(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
@@ -201,6 +216,9 @@ func TestPrintHuman(t *testing.T) {
|
||||
WithReason("missing_required_flag"),
|
||||
WithOperation("calendar.list"),
|
||||
WithServerKey("calendar"),
|
||||
WithOrigin("client"),
|
||||
WithFailureStage("request_validation"),
|
||||
WithExecutionStarted(false),
|
||||
WithHint("Pass the required flag and retry."),
|
||||
WithRetryable(true),
|
||||
WithActions("retry command"),
|
||||
@@ -228,9 +246,19 @@ func TestPrintHuman(t *testing.T) {
|
||||
if !strings.Contains(got, "Retryable: true") {
|
||||
t.Fatalf("expected retryable marker in output, got %q", got)
|
||||
}
|
||||
for _, want := range []string{"Origin: client", "Stage: request_validation", "Execution Started: false"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("expected %q in verbose output, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
withoutDetails := NewValidation("empty", WithDetails(nil)).(*Error)
|
||||
if withoutDetails.Details != nil {
|
||||
t.Fatalf("empty details were retained: %#v", withoutDetails.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintHuman_NormalMode(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintHuman_NormalMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
@@ -254,7 +282,7 @@ func TestPrintHuman_NormalMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJSONIncludesServerDiag(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintJSONIncludesServerDiag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
@@ -289,7 +317,7 @@ func TestPrintJSONIncludesServerDiag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintHumanIncludesServerGuidance(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintHumanIncludesServerGuidance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
@@ -313,7 +341,7 @@ func TestPrintHumanIncludesServerGuidance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJSONIncludesRPCCodeAndData(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintJSONIncludesRPCCodeAndData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
@@ -335,7 +363,7 @@ func TestPrintJSONIncludesRPCCodeAndData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintHumanIncludesRPCCode_Debug(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintHumanIncludesRPCCode_Debug(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
@@ -356,7 +384,7 @@ func TestPrintHumanIncludesRPCCode_Debug(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintHumanHidesRPCCode_Normal(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePrintHumanHidesRPCCode_Normal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
@@ -3,6 +3,7 @@ package helpers
|
||||
import (
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
@@ -78,6 +79,8 @@ func addAisearchPersonFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().String(alias, "", "")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
cmd.Flags().String("type", "", "兼容选择器;person/search 路径仅接受 person/user/people")
|
||||
_ = cmd.Flags().MarkHidden("type")
|
||||
}
|
||||
|
||||
func addAisearchKeywordCompatibilityFlag(cmd *cobra.Command) {
|
||||
@@ -90,6 +93,9 @@ func addAisearchKeywordCompatibilityFlag(cmd *cobra.Command) {
|
||||
// runAisearchPerson 是 aisearch person 的实际执行体,被 personCmd 和 root
|
||||
// 的智能 RunE(裸调兜底)共享调用。
|
||||
func runAisearchPerson(cmd *cobra.Command, _ []string) error {
|
||||
if selector := strings.ToLower(strings.TrimSpace(flagValue(cmd, "type"))); selector != "" && selector != "person" && selector != "user" && selector != "people" {
|
||||
return apperrors.NewValidation("aisearch person/search 的 --type 仅接受 person、user 或 people")
|
||||
}
|
||||
keyword := resolveAisearchKeyword(cmd)
|
||||
if keyword == "" {
|
||||
// 复用原有报错文案("keyword is required")
|
||||
|
||||
@@ -28,3 +28,13 @@ func TestCrossPlatformCoverageAISearchRemainingFallbackBranches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAisearchPersonAcceptsRedundantTypeSelector(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executeFilterCoverage(t, newAisearchCommand(), "search", "--query", "张三", "--type", "person"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := executeFilterCoverage(t, newAisearchCommand(), "search", "--query", "张三", "--type", "document"); err == nil {
|
||||
t.Fatal("invalid person type selector unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ func newCalendarCommand() *cobra.Command {
|
||||
Long: `管理钉钉日历:日程、参会人、会议室、闲忙、附件、日历本、访问权限。调用前必须先使用 --help 查看参数结构。
|
||||
|
||||
命令结构:
|
||||
dws calendar event [list|get|create|update|delete|suggest|respond] 日程管理
|
||||
dws calendar event [list|get|create|update|delete|suggest|respond|instances] 日程管理
|
||||
dws calendar attendee [list|add|delete] 参会人管理
|
||||
dws calendar room [search|add|delete|list-groups] 会议室管理
|
||||
dws calendar busy search 闲忙查询 (可查人、查会议室)
|
||||
@@ -2082,7 +2082,184 @@ func newCalendarCommand() *cobra.Command {
|
||||
eventSuggestCmd.Flags().String("members", "", "")
|
||||
_ = eventSuggestCmd.Flags().MarkHidden("members")
|
||||
eventSuggestCmd.Flags().String("duration", "", "日程持续时间 (分钟,默认30)")
|
||||
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd)
|
||||
|
||||
eventInstancesCmd := &cobra.Command{
|
||||
Use: "instances",
|
||||
Short: "查询循环日程的实例列表",
|
||||
Long: `查询指定重复性日程(SeriesMaster)在指定时间范围内的所有实例。
|
||||
**注意**:此接口只能查询重复性日程的实例;若传入的是普通非循环日程,将查不到任何实例信息。
|
||||
必须传入 --id 指定重复性日程的 eventId(即 SeriesMaster 的 eventId,可通过 event list 获取)。
|
||||
不传 --start/--end 时,默认查询今天(00:00:00 ~ 23:59:59)的实例。`,
|
||||
Example: ` dws calendar event instances --id EVENT_ID
|
||||
dws calendar event instances --id EVENT_ID --start "2026-03-10T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00"
|
||||
dws calendar event instances --id EVENT_ID --limit 50
|
||||
dws calendar event instances --id EVENT_ID --cursor "<nextCursor>"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
eventID, err := mustFlagOrFallback(cmd, "id", "event", "event-id", "eventId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{"eventId": eventID}
|
||||
var startTime, endTime int64
|
||||
var now time.Time
|
||||
if v := flagOrFallback(cmd, "start", "time-min", "min-time", "start-time", "startTime", "start_time", "start-date", "startDate"); v != "" {
|
||||
startTime, err = parseISOTimeToMillis("start", v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["startTime"] = startTime
|
||||
} else {
|
||||
now = time.Now()
|
||||
startTime = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).UnixMilli()
|
||||
toolArgs["startTime"] = startTime
|
||||
}
|
||||
if v := flagOrFallback(cmd, "end", "time-max", "max-time", "end-time", "endTime", "end_time", "end-date", "endDate"); v != "" {
|
||||
endTime, err = parseISOTimeToMillis("end", v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["endTime"] = endTime
|
||||
} else {
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
endTime = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).UnixMilli()
|
||||
toolArgs["endTime"] = endTime
|
||||
}
|
||||
if err := validateTimeRange(startTime, endTime); err != nil {
|
||||
return err
|
||||
}
|
||||
if v := flagOrFallback(cmd, "calendar-id", "calendarId", "calendar"); v != "" {
|
||||
toolArgs["calendarId"] = v
|
||||
}
|
||||
if v := flagOrFallback(cmd, "cursor", "next-cursor", "nextCursor", "page-token", "pageToken", "next-token"); v != "" {
|
||||
toolArgs["cursor"] = v
|
||||
}
|
||||
if lim, _ := cmd.Flags().GetInt("limit"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
} else if lim, _ := cmd.Flags().GetInt("max-results"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
} else if lim, _ := cmd.Flags().GetInt("maxResults"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
} else if lim, _ := cmd.Flags().GetInt("page-size"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
} else if lim, _ := cmd.Flags().GetInt("size"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
} else if lim, _ := cmd.Flags().GetInt("count"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
}
|
||||
return callSortedCalendarEvents(cmd, "list_event_instances", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(eventInstancesCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "calendar",
|
||||
Name: "list_event_instances",
|
||||
CanonicalPath: "calendar.list_event_instances",
|
||||
CLIPath: "calendar event instances",
|
||||
PrimaryCLIPath: "calendar event instances",
|
||||
},
|
||||
Description: "查询循环日程的实例列表",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "calendar", RPCName: "list_event_instances"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询循环日程在时间范围内展开的实例",
|
||||
UseWhen: []string{"已知循环日程 eventId(SeriesMaster),需要列出某时间窗内的实例时"},
|
||||
AvoidWhen: []string{
|
||||
"普通非循环日程请用 dws calendar event get / list",
|
||||
"未知 eventId 时先 dws calendar event list",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws calendar event instances --id <EVENT_ID>",
|
||||
"dws calendar event instances --id <EVENT_ID> --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-31T23:59:59+08:00\"",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "id", Property: "eventId", Required: boolPtr(true)},
|
||||
{Name: "start", Property: "startTime"},
|
||||
{Name: "end", Property: "endTime"},
|
||||
{Name: "calendar-id", Property: "calendarId"},
|
||||
{Name: "cursor", Property: "cursor"},
|
||||
{Name: "limit", Property: "limit", InterfaceType: "integer"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// InstancesEvent flags (aligned with event list aliases)
|
||||
eventInstancesCmd.Flags().String("id", "", "日程 ID (必填)")
|
||||
eventInstancesCmd.Flags().String("event", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("event")
|
||||
eventInstancesCmd.Flags().String("event-id", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("event-id")
|
||||
eventInstancesCmd.Flags().String("eventId", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("eventId")
|
||||
eventInstancesCmd.Flags().String("start", "", "开始时间 ISO-8601 (例如 2026-03-10T00:00:00+08:00)")
|
||||
eventInstancesCmd.Flags().String("time-min", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("time-min")
|
||||
eventInstancesCmd.Flags().String("min-time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("min-time")
|
||||
eventInstancesCmd.Flags().String("start-time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("start-time")
|
||||
eventInstancesCmd.Flags().String("startTime", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("startTime")
|
||||
eventInstancesCmd.Flags().String("start_time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("start_time")
|
||||
eventInstancesCmd.Flags().String("start-date", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("start-date")
|
||||
eventInstancesCmd.Flags().String("startDate", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("startDate")
|
||||
eventInstancesCmd.Flags().String("end", "", "结束时间 ISO-8601 (例如 2026-03-31T23:59:59+08:00)")
|
||||
eventInstancesCmd.Flags().String("time-max", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("time-max")
|
||||
eventInstancesCmd.Flags().String("max-time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("max-time")
|
||||
eventInstancesCmd.Flags().String("end-time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("end-time")
|
||||
eventInstancesCmd.Flags().String("endTime", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("endTime")
|
||||
eventInstancesCmd.Flags().String("end_time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("end_time")
|
||||
eventInstancesCmd.Flags().String("end-date", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("end-date")
|
||||
eventInstancesCmd.Flags().String("endDate", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("endDate")
|
||||
eventInstancesCmd.Flags().String("calendar-id", "", "日历 ID (可选,默认 primary 主日历;指定其他日历本时填写,可通过 book list 获取)")
|
||||
eventInstancesCmd.Flags().String("calendarId", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("calendarId")
|
||||
eventInstancesCmd.Flags().String("calendar", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("calendar")
|
||||
eventInstancesCmd.Flags().String("cursor", "", "分页游标 (首次查询无需传入,仅翻页时传入上一次返回的 nextCursor)")
|
||||
eventInstancesCmd.Flags().String("next-cursor", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("next-cursor")
|
||||
eventInstancesCmd.Flags().String("nextCursor", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("nextCursor")
|
||||
eventInstancesCmd.Flags().String("page-token", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("page-token")
|
||||
eventInstancesCmd.Flags().String("pageToken", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("pageToken")
|
||||
eventInstancesCmd.Flags().String("next-token", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("next-token")
|
||||
eventInstancesCmd.Flags().Int("limit", 0, "每页返回条数 (默认 100,最大 100)")
|
||||
eventInstancesCmd.Flags().Int("max-results", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("max-results")
|
||||
eventInstancesCmd.Flags().Int("maxResults", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("maxResults")
|
||||
eventInstancesCmd.Flags().Int("page-size", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("page-size")
|
||||
eventInstancesCmd.Flags().Int("size", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("size")
|
||||
eventInstancesCmd.Flags().Int("count", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("count")
|
||||
|
||||
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd, eventInstancesCmd)
|
||||
|
||||
// participant
|
||||
participantCmd.PersistentFlags().String("event", "", "日程 ID (必填)")
|
||||
|
||||
+213
-74
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"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/shortcut/targetresolver"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -53,6 +54,43 @@ func resolveMessageForward(cmd *cobra.Command, defaultForward bool) (bool, error
|
||||
}
|
||||
}
|
||||
|
||||
func chatCompatibilityHintSubCmd(use, hint string) *cobra.Command {
|
||||
command := hintSubCmd(use, hint)
|
||||
// Legacy callers may still pass the old command's flags. Let the migration
|
||||
// command consume them so Cobra reaches RunE and returns the replacement path.
|
||||
command.DisableFlagParsing = true
|
||||
return command
|
||||
}
|
||||
|
||||
type nativeChatTargetReader struct{}
|
||||
|
||||
func (nativeChatTargetReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
|
||||
text, err := CallMCPReadToolTextOnServer(product, tool, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &data); err != nil {
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func resolveNativeChatTarget(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if targetresolver.LooksLikeOpenConversationID(raw) {
|
||||
return raw, nil
|
||||
}
|
||||
resolved, err := targetresolver.ResolveChat(nativeChatTargetReader{}, raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resolved.Selected.OpenConversationID, nil
|
||||
}
|
||||
|
||||
const maxConversationCategoryTitleRunes = 15
|
||||
|
||||
func validatedConversationCategoryTitle(raw string) (string, error) {
|
||||
@@ -78,6 +116,61 @@ func chatIntFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string
|
||||
return v
|
||||
}
|
||||
|
||||
func runChatGroupSearch(cmd *cobra.Command, args []string) error {
|
||||
keyword := flagOrFallback(cmd, "query", "keyword", "name", "group")
|
||||
if len(args) == 1 {
|
||||
if keyword != "" {
|
||||
return apperrors.NewValidation("群搜索位置参数与 --query/--keyword 不能同时指定")
|
||||
}
|
||||
keyword = strings.TrimSpace(args[0])
|
||||
}
|
||||
if keyword == "" {
|
||||
return apperrors.NewValidation("flag --query is required\n hint: dws chat search --query \"test\"")
|
||||
}
|
||||
limit := chatIntFlagOrFallback(cmd, "limit", "size")
|
||||
cursor, _ := cmd.Flags().GetString("cursor")
|
||||
toolArgs := map[string]any{
|
||||
"keyword": keyword,
|
||||
"limit": limit,
|
||||
"cursor": cursor,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("exclude-muted"); v {
|
||||
toolArgs["excludeMuted"] = true
|
||||
}
|
||||
return callMCPToolOnServer("im", "search_groups", toolArgs)
|
||||
}
|
||||
|
||||
func newChatGroupSearchCommand(hidden bool) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "search [query]",
|
||||
Short: "根据关键词搜索群聊",
|
||||
Hidden: hidden,
|
||||
Long: `根据关键词搜索群聊列表。分页参数 --limit(默认 20)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。
|
||||
|
||||
注意:
|
||||
1. query 不要拆分得太细,应使用群名称中连续的核心词作为关键词(如群名"项目冲刺群"应搜"项目冲刺"而非拆成"项目"+"冲刺"分别搜索)。
|
||||
2. 当搜索结果返回多个群聊时,应列出候选群让用户确认目标群聊,不要自行假定并直接进行后续操作。`,
|
||||
Example: ` dws chat search --query "项目冲刺"
|
||||
dws chat search "项目冲刺"
|
||||
dws chat search --query "项目冲刺" --limit 20 --cursor 0`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: runChatGroupSearch,
|
||||
}
|
||||
cmd.Flags().String("query", "", "搜索关键词 (必填)")
|
||||
cmd.Flags().String("keyword", "", "--query 的别名")
|
||||
_ = cmd.Flags().MarkHidden("keyword")
|
||||
cmd.Flags().String("name", "", "--query 的兼容别名")
|
||||
_ = cmd.Flags().MarkHidden("name")
|
||||
cmd.Flags().String("group", "", "--query 的兼容别名")
|
||||
_ = cmd.Flags().MarkHidden("group")
|
||||
cmd.Flags().Int("limit", 20, "每页返回数量(默认 20)")
|
||||
cmd.Flags().Int("size", 0, "--limit 的旧版别名")
|
||||
_ = cmd.Flags().MarkHidden("size")
|
||||
cmd.Flags().String("cursor", "0", "分页游标(默认 \"0\",翻页传 nextCursor)")
|
||||
cmd.Flags().Bool("exclude-muted", false, "是否排除已设置免打扰的群聊(默认 false)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runChatSearchCommon(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateRequiredFlags(cmd, "nicks"); err != nil {
|
||||
return err
|
||||
@@ -311,6 +404,50 @@ func NormalizeMessageMentions(text string, ids []string, atAll, wrapAngle bool)
|
||||
return text
|
||||
}
|
||||
|
||||
// applyCurrentUserGroupMentions keeps the body placeholders and
|
||||
// send_personal_message mention arguments aligned for send and reply.
|
||||
func applyCurrentUserGroupMentions(params map[string]any, text, rawOpenIDs string, atAll bool) string {
|
||||
var atOpenIDs []string
|
||||
if rawOpenIDs != "" {
|
||||
atOpenIDs = strings.Split(rawOpenIDs, ",")
|
||||
}
|
||||
if atAll && !strings.Contains(text, "<@all>") {
|
||||
text = "<@all> " + text
|
||||
}
|
||||
text = normalizeAtPlaceholders(text, atOpenIDs, true)
|
||||
if atAll {
|
||||
params["atAll"] = true
|
||||
}
|
||||
if len(atOpenIDs) > 0 {
|
||||
params["atOpenDingTalkIds"] = atOpenIDs
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func addMissingCurrentUserMentionPlaceholders(text, rawOpenIDs string) string {
|
||||
if rawOpenIDs == "" {
|
||||
return text
|
||||
}
|
||||
missing := make([]string, 0)
|
||||
probeText := text
|
||||
for _, id := range parseCSVValues(rawOpenIDs) {
|
||||
placeholder := "<@" + id + ">"
|
||||
if strings.Contains(probeText, placeholder) {
|
||||
continue
|
||||
}
|
||||
missing = append(missing, placeholder)
|
||||
probeText += placeholder
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return text
|
||||
}
|
||||
prefix := strings.Join(missing, " ")
|
||||
if strings.HasPrefix(text, "<@all> ") {
|
||||
return "<@all> " + prefix + " " + strings.TrimPrefix(text, "<@all> ")
|
||||
}
|
||||
return prefix + " " + text
|
||||
}
|
||||
|
||||
func containsMessageMention(text, placeholder string) bool {
|
||||
if strings.HasPrefix(placeholder, "<") {
|
||||
return strings.Contains(text, placeholder)
|
||||
@@ -1277,34 +1414,23 @@ func newChatCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
|
||||
chatSearchCmd := &cobra.Command{
|
||||
Use: "search",
|
||||
Short: "根据关键词搜索群聊",
|
||||
Long: `根据关键词搜索群聊列表。分页参数 --limit(默认 20)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。
|
||||
|
||||
注意:
|
||||
1. query 不要拆分得太细,应使用群名称中连续的核心词作为关键词(如群名"项目冲刺群"应搜"项目冲刺"而非拆成"项目"+"冲刺"分别搜索)。
|
||||
2. 当搜索结果返回多个群聊时,应列出候选群让用户确认目标群聊,不要自行假定并直接进行后续操作。`,
|
||||
Example: ` dws chat search --query "项目冲刺"
|
||||
dws chat search --query "项目冲刺" --limit 20 --cursor 0`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
keyword := flagOrFallback(cmd, "query", "keyword")
|
||||
if keyword == "" {
|
||||
return fmt.Errorf("flag --query is required\n hint: dws chat search --query \"test\"")
|
||||
}
|
||||
limit := chatIntFlagOrFallback(cmd, "limit", "size")
|
||||
cursor, _ := cmd.Flags().GetString("cursor")
|
||||
toolArgs := map[string]any{
|
||||
"keyword": keyword,
|
||||
"limit": limit,
|
||||
"cursor": cursor,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("exclude-muted"); v {
|
||||
toolArgs["excludeMuted"] = true
|
||||
}
|
||||
return callMCPToolOnServer("im", "search_groups", toolArgs)
|
||||
chatSearchCmd := newChatGroupSearchCommand(false)
|
||||
chatGroupSearchCompatibilityCmd := newChatGroupSearchCommand(true)
|
||||
cli.AttachRuntimeSchema(
|
||||
chatGroupSearchCompatibilityCmd,
|
||||
"chat",
|
||||
"search_groups",
|
||||
"reviewed-compatibility:chat-group-search",
|
||||
)
|
||||
cli.AnnotateRuntimeCompatibilityEquivalence(
|
||||
chatSearchCmd,
|
||||
chatGroupSearchCompatibilityCmd,
|
||||
cli.RuntimeCompatibilityEquivalence{
|
||||
ID: "chat-group-search-compatibility-v1",
|
||||
Reason: "Both leaves share the same constructor, flags, positional normalization, read-only search_groups transport, and result contract.",
|
||||
Reviewed: true,
|
||||
},
|
||||
}
|
||||
)
|
||||
DeclareLeafMetadata(chatSearchCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
@@ -1955,29 +2081,15 @@ func newChatCommand() *cobra.Command {
|
||||
if groupID != "" {
|
||||
atAll, _ := cmd.Flags().GetBool("at-all")
|
||||
atOpenIdsStr, _ := cmd.Flags().GetString("at-open-dingtalk-ids")
|
||||
var atOpenIds []string
|
||||
if atOpenIdsStr != "" {
|
||||
atOpenIds = strings.Split(atOpenIdsStr, ",")
|
||||
}
|
||||
if atAll && !strings.Contains(text, "<@all>") {
|
||||
text = "<@all> " + text
|
||||
}
|
||||
// 用户身份发消息要求 @ 占位符为 <@openDingTalkId>;模型若写成裸 @id 自动补全,已有 <@id> 不变
|
||||
text = normalizeAtPlaceholders(text, atOpenIds, true)
|
||||
// 群聊统一走 openDingTalkId @ 人接口。
|
||||
contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text})
|
||||
newParams := map[string]any{
|
||||
"openConversationId": groupID,
|
||||
"msgType": "markdown",
|
||||
"content": string(contentJSON),
|
||||
"clawType": clawType,
|
||||
}
|
||||
if atAll {
|
||||
newParams["atAll"] = true
|
||||
}
|
||||
if len(atOpenIds) > 0 {
|
||||
newParams["atOpenDingTalkIds"] = atOpenIds
|
||||
}
|
||||
text = applyCurrentUserGroupMentions(newParams, text, atOpenIdsStr, atAll)
|
||||
contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text})
|
||||
newParams["content"] = string(contentJSON)
|
||||
if msgUuid != "" {
|
||||
newParams["uuid"] = msgUuid
|
||||
}
|
||||
@@ -3353,15 +3465,6 @@ func newChatCommand() *cobra.Command {
|
||||
chatGroupCreateCmd.Flags().String("type", "INTERNAL", "群类型: INTERNAL(内部群,默认)/EXTERNAL(外部群)/NORMAL(普通群)")
|
||||
chatGroupCreateCmd.Flags().Bool("thread", false, "开启话题模式,将创建话题圈")
|
||||
|
||||
chatSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
|
||||
chatSearchCmd.Flags().String("keyword", "", "--query 的别名")
|
||||
_ = chatSearchCmd.Flags().MarkHidden("keyword")
|
||||
chatSearchCmd.Flags().Int("limit", 20, "每页返回数量(默认 20)")
|
||||
chatSearchCmd.Flags().Int("size", 0, "--limit 的旧版别名")
|
||||
_ = chatSearchCmd.Flags().MarkHidden("size")
|
||||
chatSearchCmd.Flags().String("cursor", "0", "分页游标(默认 \"0\",翻页传 nextCursor)")
|
||||
chatSearchCmd.Flags().Bool("exclude-muted", false, "是否排除已设置免打扰的群聊(默认 false)")
|
||||
|
||||
chatGroupMembersCmd.Flags().String("id", "", "群 ID / openconversation_id (必填)")
|
||||
_ = chatGroupMembersCmd.MarkFlagRequired("id")
|
||||
chatGroupMembersCmd.Flags().String("cursor", "", "分页游标,首次从 0 开始")
|
||||
@@ -3387,7 +3490,7 @@ func newChatCommand() *cobra.Command {
|
||||
_ = chatGroupMemberRemoveCmd.MarkFlagRequired("users")
|
||||
|
||||
chatGroupCmd.AddCommand(chatGroupCreateCmd, chatGroupMembersCmd, chatGroupRenameCmd)
|
||||
chatGroupCmd.AddCommand(hintSubCmd("search", "use: dws chat search --query <关键词>"))
|
||||
chatGroupCmd.AddCommand(chatGroupSearchCompatibilityCmd)
|
||||
chatGroupMembersCmd.AddCommand(chatGroupMemberAddCmd, chatGroupMemberRemoveCmd, chatGroupMembersAddBotCmd)
|
||||
|
||||
// message 子命令 flags
|
||||
@@ -3940,6 +4043,7 @@ func newChatCommand() *cobra.Command {
|
||||
chatCategoryDeleteCmd := &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "删除用户自定义会话分组",
|
||||
Long: "删除用户自定义会话分组。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。",
|
||||
Example: ` dws chat category delete --category-id <分组ID>
|
||||
# 分组ID 可通过 dws chat category list 获取`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -3947,6 +4051,14 @@ func newChatCommand() *cobra.Command {
|
||||
if categoryId == 0 {
|
||||
return fmt.Errorf("flag --category-id is required")
|
||||
}
|
||||
if !commandBoolFlag(cmd, "yes") {
|
||||
return apperrors.NewValidation(
|
||||
"删除会话分组不可逆;获得用户确认后加 --yes 执行",
|
||||
apperrors.WithReason("confirmation_required"),
|
||||
apperrors.WithHint("先确认目标分组及影响范围;用户明确同意后以相同参数追加 --yes"),
|
||||
apperrors.WithActions("确认目标会话分组", "获得用户确认后使用 --yes 执行"),
|
||||
)
|
||||
}
|
||||
return callMCPToolOnServer("im", "delete_conv_category", map[string]any{
|
||||
"categoryId": categoryId,
|
||||
})
|
||||
@@ -5294,13 +5406,14 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
chatMessageReplyCmd := &cobra.Command{
|
||||
Use: "reply",
|
||||
Short: "引用回复消息(支持单聊/群聊)",
|
||||
Long: `以当前用户身份引用某条消息并回复。需要指定会话 ID、被引用消息 ID、原消息发送者 openDingTalkId,以及回复内容。
|
||||
Long: `以当前用户身份引用某条消息并回复。需要指定会话 ID、被引用消息 ID、原消息发送者 openDingTalkId,以及回复内容。群聊回复可通过 --at-open-dingtalk-ids @指定成员,或通过 --at-all @所有人;正文中的裸 @openDingTalkId 会自动规范化为 <@openDingTalkId>,缺少对应成员或 <@all> 占位符时会自动补齐。
|
||||
|
||||
如何获取 openConversationId(如果上层已有则直接使用,不必再查):
|
||||
- 群聊:dws chat search --query "群名"
|
||||
- 单聊:dws chat conversation-info --open-dingtalk-id <openDingTalkId>
|
||||
(人员信息可通过 dws contact user search --keyword "姓名" --format json 获取)`,
|
||||
Example: ` dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"`,
|
||||
Example: ` dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"
|
||||
dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "请看一下" --at-open-dingtalk-ids <mentionedOpenDingTalkId>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "conversation-id", "ref-msg-id", "ref-sender", "text"); err != nil {
|
||||
return err
|
||||
@@ -5313,13 +5426,6 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
}
|
||||
refSender = resolved
|
||||
}
|
||||
replyContent := map[string]string{
|
||||
"referenceOpenMessageId": mustGetFlag(cmd, "ref-msg-id"),
|
||||
"srcMsgSendOpenDingTalkId": refSender,
|
||||
"replyMsgType": "text",
|
||||
"content": mustGetFlag(cmd, "text"),
|
||||
}
|
||||
contentJSON, _ := marshalJSONRaw(replyContent)
|
||||
clawType := ""
|
||||
aiTag, _ := cmd.Flags().GetBool("ai-tag")
|
||||
if aiTag {
|
||||
@@ -5328,9 +5434,25 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
toolArgs := map[string]any{
|
||||
"openConversationId": mustGetFlag(cmd, "conversation-id"),
|
||||
"msgType": "reply",
|
||||
"content": string(contentJSON),
|
||||
"clawType": clawType,
|
||||
}
|
||||
atAll, _ := cmd.Flags().GetBool("at-all")
|
||||
atOpenIDs := mustGetFlag(cmd, "at-open-dingtalk-ids")
|
||||
replyText := applyCurrentUserGroupMentions(
|
||||
toolArgs,
|
||||
mustGetFlag(cmd, "text"),
|
||||
atOpenIDs,
|
||||
atAll,
|
||||
)
|
||||
replyText = addMissingCurrentUserMentionPlaceholders(replyText, atOpenIDs)
|
||||
replyContent := map[string]string{
|
||||
"referenceOpenMessageId": mustGetFlag(cmd, "ref-msg-id"),
|
||||
"srcMsgSendOpenDingTalkId": refSender,
|
||||
"replyMsgType": "text",
|
||||
"content": replyText,
|
||||
}
|
||||
contentJSON, _ := marshalJSONRaw(replyContent)
|
||||
toolArgs["content"] = string(contentJSON)
|
||||
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
|
||||
toolArgs["uuid"] = v
|
||||
}
|
||||
@@ -5364,6 +5486,8 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "ai-tag", Property: "clawType", InterfaceType: "string"},
|
||||
{Name: "at-all", Property: "atAll", Required: boolPtr(false), InterfaceType: "boolean"},
|
||||
{Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds", Required: boolPtr(false), InterfaceType: "array"},
|
||||
{Name: "conversation-id", Property: "openConversationId"},
|
||||
},
|
||||
},
|
||||
@@ -5378,6 +5502,8 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
_ = chatMessageReplyCmd.MarkFlagRequired("text")
|
||||
chatMessageReplyCmd.Flags().String("uuid", "", "幂等键(可选)")
|
||||
chatMessageReplyCmd.Flags().Bool("ai-tag", true, "消息是否带 AI 发送角标(默认 true)")
|
||||
chatMessageReplyCmd.Flags().Bool("at-all", false, "@所有人(仅群聊时生效;正文缺少 <@all> 时自动补齐)")
|
||||
chatMessageReplyCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingTalkId 列表,逗号分隔(仅群聊时生效;正文缺少对应 <@id> 时自动补齐,裸 @id 自动规范化)")
|
||||
cli.AttachRuntimeSchema(chatMessageReplyCmd, "chat", "reply_personal_message", "hardcoded:chat")
|
||||
|
||||
// ── message forward: 转发单条消息 ────────────────────────
|
||||
@@ -6261,8 +6387,12 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
if err := validateRequiredFlags(cmd, "group"); err != nil {
|
||||
return err
|
||||
}
|
||||
groupID, err := resolveNativeChatTarget(mustGetFlag(cmd, "group"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("bot", "list_group_bots", map[string]any{
|
||||
"openConversationId": mustGetFlag(cmd, "group"),
|
||||
"openConversationId": groupID,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -6296,7 +6426,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
},
|
||||
},
|
||||
})
|
||||
chatGroupBotsCmd.Flags().String("group", "", "群聊 openConversationId (必填)")
|
||||
chatGroupBotsCmd.Flags().String("group", "", "群聊 openConversationId 或需唯一解析的群名 (必填)")
|
||||
_ = chatGroupBotsCmd.MarkFlagRequired("group")
|
||||
|
||||
chatGroupMembersRemoveBotCmd := &cobra.Command{
|
||||
@@ -6432,7 +6562,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
Use: "dismiss",
|
||||
Short: "解散群聊",
|
||||
Long: `解散指定群聊。该操作不可逆,需要群主权限;必须先获得用户确认,再追加 --yes 执行。`,
|
||||
Example: ` dws chat group dismiss --group <openConversationId> --yes
|
||||
Example: ` dws chat group dismiss --group <openConversationId>
|
||||
# 查询群 ID: dws chat search --query "群名"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "group"); err != nil {
|
||||
@@ -7276,7 +7406,7 @@ status 可选值:
|
||||
chatClearMessagesCmd := &cobra.Command{
|
||||
Use: "clear-messages",
|
||||
Short: "清空当前用户指定会话的聊天记录",
|
||||
Long: `清空当前用户在指定会话中的聊天记录。仅清空当前用户视角的消息,不影响其他成员。
|
||||
Long: `清空当前用户在指定会话中的聊天记录。仅清空当前用户视角的消息,不影响其他成员。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。
|
||||
|
||||
如何获取 openConversationId(如果上层已有则直接使用,不必再查):
|
||||
- 群聊:dws chat search --query "群名"
|
||||
@@ -7288,6 +7418,14 @@ status 可选值:
|
||||
if convID == "" {
|
||||
return fmt.Errorf("flag --conversation-id is required\n hint: dws chat clear-messages --conversation-id <openConversationId>")
|
||||
}
|
||||
if !commandBoolFlag(cmd, "yes") {
|
||||
return apperrors.NewValidation(
|
||||
"清空会话聊天记录不可逆;获得用户确认后加 --yes 执行",
|
||||
apperrors.WithReason("confirmation_required"),
|
||||
apperrors.WithHint("先确认目标会话及影响范围;用户明确同意后以相同参数追加 --yes"),
|
||||
apperrors.WithActions("确认目标会话", "获得用户确认后使用 --yes 执行"),
|
||||
)
|
||||
}
|
||||
return callMCPToolOnServer("im", "clear_conversation_messages", map[string]any{
|
||||
"openConversationId": convID,
|
||||
})
|
||||
@@ -7763,8 +7901,8 @@ status 可选值:
|
||||
本命令升级已有普通群;新建外部群请使用 chat group create --type EXTERNAL。
|
||||
|
||||
该操作不可逆,仅群主可执行。正式执行必须通过 --yes 显式确认,可先使用 --dry-run 预览。`,
|
||||
Example: ` dws chat group upgrade-to-external --group <openConversationId> --yes
|
||||
dws chat group upgrade-to-external --group <openConversationId> --extension '{"source":"dws"}' --yes
|
||||
Example: ` dws chat group upgrade-to-external --group <openConversationId> --dry-run
|
||||
dws chat group upgrade-to-external --group <openConversationId> --extension '{"source":"dws"}' --dry-run
|
||||
# 查询群 ID: dws chat search --query "群名"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "group"); err != nil {
|
||||
@@ -8080,10 +8218,11 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
|
||||
|
||||
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd)
|
||||
|
||||
// hint: dws chat send → dws chat message send
|
||||
root.AddCommand(hintSubCmd("send", "use: dws chat message send"))
|
||||
// hint: dws chat history → dws chat message list
|
||||
root.AddCommand(hintSubCmd("history", "use: dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"))
|
||||
// Keep the v1.0.56 command surface recognizable while directing callers to
|
||||
// the supported nested commands. The chat root's "im" alias makes these
|
||||
// compatibility hints available through both chat and im.
|
||||
root.AddCommand(chatCompatibilityHintSubCmd("send", "use: dws chat message send"))
|
||||
root.AddCommand(chatCompatibilityHintSubCmd("history", "use: dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"))
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -43,6 +43,75 @@ func runChatCoverageDirect(t *testing.T, path []string, flags map[string]string)
|
||||
return command.RunE(command, nil)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEvaluationRegressionChatSearchSpellingsAndNaturalBotTarget(t *testing.T) {
|
||||
if got, err := resolveNativeChatTarget(" cid123456789 "); err != nil || got != "cid123456789" {
|
||||
t.Fatalf("stable native chat target = %q, %v", got, err)
|
||||
}
|
||||
t.Run("group search path accepts query", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
if err := runChatCoverageCommand(t, caller, "group", "search", "--query", "项目群"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("calls = %d", caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("group search accepts positional", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
if err := runChatCoverageCommand(t, caller, "group", "search", "项目群"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("calls = %d", caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("native bots resolves group name", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":[{"openConversationId":"cid-project","title":"项目群"}],"hasMore":false}`},
|
||||
{text: `{"result":{"bots":[]}}`},
|
||||
}}
|
||||
if err := runChatCoverageCommand(t, caller, "group", "bots", "--group", "项目群"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 2 {
|
||||
t.Fatalf("calls = %d", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *testing.T) {
|
||||
root := newChatCommand()
|
||||
if len(root.Aliases) != 1 || root.Aliases[0] != "im" {
|
||||
t.Fatalf("chat aliases = %v, want [im]", root.Aliases)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
args []string
|
||||
hint string
|
||||
}{
|
||||
{path: "send", args: []string{"send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
} {
|
||||
command, remaining, err := root.Find([]string{tc.path})
|
||||
if err != nil {
|
||||
t.Fatalf("find chat %s: %v", tc.path, err)
|
||||
}
|
||||
if len(remaining) != 0 || command.Name() != tc.path {
|
||||
t.Fatalf("find chat %s = command %q, remaining %v", tc.path, command.Name(), remaining)
|
||||
}
|
||||
if !command.Hidden || !command.Runnable() {
|
||||
t.Fatalf("chat %s compatibility contract: hidden=%v runnable=%v", tc.path, command.Hidden, command.Runnable())
|
||||
}
|
||||
root.SetArgs(tc.args)
|
||||
err = root.ExecuteContext(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
|
||||
t.Fatalf("chat %s with legacy flags error = %v, want migration hint %q", tc.path, err, tc.hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupUpdateIconAcceptsUploadedMediaIDPrefixes(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
@@ -112,6 +181,7 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T)
|
||||
{"message", "search", "--query=q", "--start=2026-01-02T00:00:00Z", "--end=2026-01-01T00:00:00Z"},
|
||||
{"message", "search", "--query=q", "--start=2026-01-01T00:00:00Z", "--end=2026-01-02T00:00:00Z", "--group=cid"},
|
||||
{"message", "recall", "--conversation-id=cid", "--msg-id=mid"},
|
||||
{"category", "delete", "--category-id=1"},
|
||||
{"category", "rename", "--category-id=1", "--title=renamed"},
|
||||
{"category", "add-conv", "--group=cid", "--category-ids=1,2"},
|
||||
{"category", "remove-conv", "--group=cid", "--category-ids=1,2"},
|
||||
|
||||
@@ -15,6 +15,7 @@ package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
@@ -285,6 +286,150 @@ func TestChatSendAndReplyDisableAITagWithEmptyClawType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
contentField string
|
||||
wantContent string
|
||||
wantAtAll bool
|
||||
wantOpenIDs []string
|
||||
}{
|
||||
{
|
||||
name: "send",
|
||||
args: []string{
|
||||
"message", "send", "--group", "cid",
|
||||
"--text", "收到 @D-target 和 <@D-second>",
|
||||
"--at-open-dingtalk-ids", "D-target,D-second",
|
||||
"--at-all",
|
||||
},
|
||||
contentField: "text",
|
||||
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
|
||||
wantAtAll: true,
|
||||
wantOpenIDs: []string{"D-target", "D-second"},
|
||||
},
|
||||
{
|
||||
name: "send keeps missing member placeholders unchanged",
|
||||
args: []string{
|
||||
"message", "send", "--group", "cid",
|
||||
"--text", "DWS 发消息自测",
|
||||
"--at-open-dingtalk-ids", "D-target",
|
||||
},
|
||||
contentField: "text",
|
||||
wantContent: "DWS 发消息自测",
|
||||
wantOpenIDs: []string{"D-target"},
|
||||
},
|
||||
{
|
||||
name: "reply",
|
||||
args: []string{
|
||||
"message", "reply",
|
||||
"--conversation-id", "cid",
|
||||
"--ref-msg-id", "mid",
|
||||
"--ref-sender", "D-sender",
|
||||
"--text", "收到 @D-target 和 <@D-second>",
|
||||
"--at-open-dingtalk-ids", "D-target,D-second",
|
||||
"--at-all",
|
||||
},
|
||||
contentField: "content",
|
||||
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
|
||||
wantAtAll: true,
|
||||
wantOpenIDs: []string{"D-target", "D-second"},
|
||||
},
|
||||
{
|
||||
name: "reply adds missing member placeholders",
|
||||
args: []string{
|
||||
"message", "reply",
|
||||
"--conversation-id", "cid",
|
||||
"--ref-msg-id", "mid",
|
||||
"--ref-sender", "D-sender",
|
||||
"--text", "DWS 回复艾特前津(非主用)自测",
|
||||
"--at-open-dingtalk-ids", "D-target,D-second,D-target",
|
||||
},
|
||||
contentField: "content",
|
||||
wantContent: "<@D-target> <@D-second> DWS 回复艾特前津(非主用)自测",
|
||||
wantOpenIDs: []string{"D-target", "D-second", "D-target"},
|
||||
},
|
||||
{
|
||||
name: "reply adds missing member placeholders after at-all",
|
||||
args: []string{
|
||||
"message", "reply",
|
||||
"--conversation-id", "cid",
|
||||
"--ref-msg-id", "mid",
|
||||
"--ref-sender", "D-sender",
|
||||
"--text", "请大家确认",
|
||||
"--at-open-dingtalk-ids", "D-target",
|
||||
"--at-all",
|
||||
},
|
||||
contentField: "content",
|
||||
wantContent: "<@all> <@D-target> 请大家确认",
|
||||
wantAtAll: true,
|
||||
wantOpenIDs: []string{"D-target"},
|
||||
},
|
||||
{
|
||||
name: "reply at-all preserves alliance word",
|
||||
args: []string{
|
||||
"message", "reply",
|
||||
"--conversation-id", "cid",
|
||||
"--ref-msg-id", "mid",
|
||||
"--ref-sender", "D-sender",
|
||||
"--text", "联系 @alliance",
|
||||
"--at-all",
|
||||
},
|
||||
contentField: "content",
|
||||
wantContent: "<@all> 联系 @alliance",
|
||||
wantAtAll: true,
|
||||
},
|
||||
{
|
||||
name: "reply without at flags preserves alliance word",
|
||||
args: []string{
|
||||
"message", "reply",
|
||||
"--conversation-id", "cid",
|
||||
"--ref-msg-id", "mid",
|
||||
"--ref-sender", "D-sender",
|
||||
"--text", "联系 @alliance",
|
||||
},
|
||||
contentField: "content",
|
||||
wantContent: "联系 @alliance",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &chatChangedContractCaller{}
|
||||
if err := executeChatChangedContract(t, caller, tc.args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != "send_personal_message" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
args := caller.calls[0].args
|
||||
gotAtAll, hasAtAll := args["atAll"]
|
||||
if tc.wantAtAll {
|
||||
if !hasAtAll || gotAtAll != true {
|
||||
t.Fatalf("atAll = %#v, present = %v; want true", gotAtAll, hasAtAll)
|
||||
}
|
||||
} else if hasAtAll {
|
||||
t.Fatalf("atAll = %#v; want absent", gotAtAll)
|
||||
}
|
||||
gotOpenIDs, hasOpenIDs := args["atOpenDingTalkIds"]
|
||||
if len(tc.wantOpenIDs) > 0 {
|
||||
if !hasOpenIDs || !reflect.DeepEqual(gotOpenIDs, tc.wantOpenIDs) {
|
||||
t.Fatalf("atOpenDingTalkIds = %#v, present = %v; want %#v", gotOpenIDs, hasOpenIDs, tc.wantOpenIDs)
|
||||
}
|
||||
} else if hasOpenIDs {
|
||||
t.Fatalf("atOpenDingTalkIds = %#v; want absent", gotOpenIDs)
|
||||
}
|
||||
var content map[string]string
|
||||
if err := json.Unmarshal([]byte(args["content"].(string)), &content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := content[tc.contentField]; got != tc.wantContent {
|
||||
t.Fatalf("content[%q] = %q; want %q", tc.contentField, got, tc.wantContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatSendFailsClosedWhenUserCannotResolve(t *testing.T) {
|
||||
caller := &chatChangedContractCaller{}
|
||||
err := executeChatChangedContract(t, caller, "message", "send", "--user", "123", "--text", "hello")
|
||||
|
||||
@@ -144,7 +144,6 @@ var (
|
||||
"clear": {},
|
||||
"refresh": {},
|
||||
"recover": {},
|
||||
"recovery": {},
|
||||
"login": {},
|
||||
"logout": {},
|
||||
"register": {},
|
||||
|
||||
@@ -310,6 +310,44 @@ func newContactUserUpdateSelfCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newContactUserUpdateOwnnessCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update-ownness",
|
||||
Aliases: []string{"set-ownness"},
|
||||
Short: "更新用户个人状态",
|
||||
Long: "更新指定用户的个人状态文本(展示在个人资料与聊天会话中,如「居家办公中」)。执行前需要确认,自动化场景在用户明确授权后传 --yes。",
|
||||
Example: ` dws contact user update-ownness --user-id user001 --ownness-text "居家办公中"`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateRequiredFlagWithAliases(cmd, "user-id", "id", "userid", "userId"); err != nil {
|
||||
return err
|
||||
}
|
||||
userID := strings.TrimSpace(flagOrFallback(cmd, "user-id", "id", "userid", "userId"))
|
||||
if userID == "" {
|
||||
return fmt.Errorf("--user-id 不能为空")
|
||||
}
|
||||
if err := validateRequiredFlagWithAliases(cmd, "ownness-text", "ownnessText"); err != nil {
|
||||
return err
|
||||
}
|
||||
ownnessText := strings.TrimSpace(flagOrFallback(cmd, "ownness-text", "ownnessText"))
|
||||
if ownnessText == "" {
|
||||
return fmt.Errorf("--ownness-text 不能为空")
|
||||
}
|
||||
return callMCPTool("user_ownness_update", map[string]any{
|
||||
"userId": userID,
|
||||
"ownnessText": ownnessText,
|
||||
})
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("user-id", "", "要更新个人状态的用户 userId (必填)")
|
||||
cmd.Flags().String("id", "", "--user-id 的别名")
|
||||
cmd.Flags().String("userid", "", "--user-id 的别名")
|
||||
_ = cmd.Flags().MarkHidden("id")
|
||||
_ = cmd.Flags().MarkHidden("userid")
|
||||
cmd.Flags().String("ownness-text", "", "个人状态文本 (必填),如 \"居家办公中\"")
|
||||
cli.AnnotateRuntimeRequiredFlags(cmd, "user-id", "ownness-text")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newContactAccountUpdateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update",
|
||||
@@ -391,7 +429,7 @@ func newContactCommand() *cobra.Command {
|
||||
|
||||
通讯录功能:
|
||||
- contact user get-self/search/search-mobile/get: 通讯录用户查询
|
||||
- contact user invite/update/update-self: 邀请与更新员工
|
||||
- contact user invite/update/update-self/update-ownness: 邀请与更新员工
|
||||
- contact dept search/get-info/list-children/list-members/create/update: 部门查询与管理
|
||||
- contact relation list-my-followings: 特别关注人查询
|
||||
|
||||
@@ -414,6 +452,7 @@ func newContactCommand() *cobra.Command {
|
||||
- 查询用户的部门、主管、管理员权限 → contact user get
|
||||
- 修改员工信息(姓名 / 部门 / 直属主管) → contact user update
|
||||
- 更新当前用户自己的 profile(昵称 / 头像) → contact user update-self
|
||||
- 更新用户个人状态(如「居家办公中」) → contact user update-ownness
|
||||
- 邀请员工加入企业 → contact user invite
|
||||
- 查询用户的学历、家庭、银行卡、合同等档案 → contact user profile get
|
||||
- 查询离职员工列表 → contact user dismission search`,
|
||||
@@ -1355,6 +1394,40 @@ contact user profile fields 获取可用字段列表。
|
||||
},
|
||||
},
|
||||
})
|
||||
contactUserUpdateOwnnessCmd := newContactUserUpdateOwnnessCommand()
|
||||
DeclareLeafMetadata(contactUserUpdateOwnnessCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "user_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "contact",
|
||||
Name: "user_ownness_update",
|
||||
CanonicalPath: "contact.user_ownness_update",
|
||||
CLIPath: "contact user update-ownness",
|
||||
PrimaryCLIPath: "contact user update-ownness",
|
||||
},
|
||||
Description: "更新指定用户的个人状态文本(展示在个人资料与聊天会话中,如「居家办公中」)",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed unpinned remote adapter: the executable CLI maps personal-status update flags to contact/user_ownness_update, which is absent from the pinned MCP metadata snapshot.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "更新指定用户的个人状态文本(如「居家办公中」)",
|
||||
UseWhen: []string{"用户明确要求设置或修改自己/指定用户的个人状态文本,且已确认目标 userId 和状态内容"},
|
||||
AvoidWhen: []string{"修改员工组织信息(姓名 / 部门 / 主管)应使用 contact user update;修改当前用户昵称或头像应使用 contact user update-self"},
|
||||
Examples: []string{"dws contact user update-ownness --user-id user001 --ownness-text \"居家办公中\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "id", Property: "userId", Required: boolPtr(false)},
|
||||
{Name: "ownness-text", Property: "ownnessText", Required: boolPtr(true)},
|
||||
{Name: "user-id", Property: "userId", Required: boolPtr(true)},
|
||||
{Name: "userid", Property: "userId", Required: boolPtr(false)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// ── flags 注册 ───────────────────────────────────────────────
|
||||
contactUserSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
|
||||
@@ -1372,11 +1445,12 @@ contact user profile fields 获取可用字段列表。
|
||||
_ = contactUserGetCmd.Flags().MarkHidden("userid")
|
||||
userCmd.AddCommand(
|
||||
contactUserGetSelfCmd, contactUserSearchCmd, contactUserSearchMobileCmd, contactUserGetCmd,
|
||||
contactUserInviteCmd, // 邀请员工加入企业
|
||||
contactUserUpdateCmd, // 修改员工信息
|
||||
contactUserUpdateSelfCmd, // 更新当前用户自己的 profile 信息
|
||||
contactUserProfileCmd, // 花名册档案
|
||||
contactUserDismissionCmd, // 离职员工
|
||||
contactUserInviteCmd, // 邀请员工加入企业
|
||||
contactUserUpdateCmd, // 修改员工信息
|
||||
contactUserUpdateSelfCmd, // 更新当前用户自己的 profile 信息
|
||||
contactUserUpdateOwnnessCmd, // 更新用户个人状态
|
||||
contactUserProfileCmd, // 花名册档案
|
||||
contactUserDismissionCmd, // 离职员工
|
||||
)
|
||||
|
||||
contactDeptSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
|
||||
|
||||
@@ -48,6 +48,7 @@ func TestCrossPlatformCoverageContactUpdateCommandsExposeExpectedFlags(t *testin
|
||||
{[]string{"dept", "update"}, []string{"dept", "name", "parent"}},
|
||||
{[]string{"user", "update"}, []string{"user-id", "org-user-name", "depts", "master-user-id"}},
|
||||
{[]string{"user", "update-self"}, []string{"nick", "avatar-file-id"}},
|
||||
{[]string{"user", "update-ownness"}, []string{"user-id", "ownness-text"}},
|
||||
{[]string{"account", "update"}, []string{"user-id", "org-user-name", "depts", "master-user-id", "nick", "avatar-file-id"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
@@ -98,6 +99,18 @@ func TestCrossPlatformCoverageContactUpdateCommandsMapMCPArguments(t *testing.T)
|
||||
toolName: "self_user_profile_update",
|
||||
wantArgs: map[string]any{"nick": "新昵称", "avatarFileId": "file-1"},
|
||||
},
|
||||
{
|
||||
name: "update user ownness",
|
||||
args: []string{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", "居家办公中", "--yes"},
|
||||
toolName: "user_ownness_update",
|
||||
wantArgs: map[string]any{"userId": "user-1", "ownnessText": "居家办公中"},
|
||||
},
|
||||
{
|
||||
name: "update user ownness with aliases",
|
||||
args: []string{"user", "set-ownness", "--userId", "user-1", "--ownnessText", "专注开发中", "--yes"},
|
||||
toolName: "user_ownness_update",
|
||||
wantArgs: map[string]any{"userId": "user-1", "ownnessText": "专注开发中"},
|
||||
},
|
||||
{
|
||||
name: "update enterprise account",
|
||||
args: []string{"account", "edit", "--user-id", "user-2", "--org-user-name", "李四", "--depts", `[{"deptId":2}]`, "--master-user-id", "manager-2", "--nick", "小李", "--avatar-file-id", "file-2", "--yes"},
|
||||
@@ -139,6 +152,7 @@ func TestCrossPlatformCoverageContactUpdateCommandsRequireConfirmation(t *testin
|
||||
{"dept", "update", "--dept", "7", "--name", "研发中心"},
|
||||
{"user", "update", "--user-id", "user-1", "--org-user-name", "张三"},
|
||||
{"user", "update-self", "--nick", "新昵称"},
|
||||
{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", "居家办公中"},
|
||||
{"account", "update", "--user-id", "user-2", "--nick", "小李"},
|
||||
}
|
||||
for _, args := range tests {
|
||||
@@ -174,6 +188,10 @@ func TestCrossPlatformCoverageContactUpdateCommandsValidateInput(t *testing.T) {
|
||||
{"employee no changes", []string{"user", "update", "--user-id", "user-1", "--org-user-name", " ", "--depts", " ", "--master-user-id", " ", "--yes"}, "至少需要一个修改项"},
|
||||
{"employee invalid departments", []string{"user", "update", "--user-id", "user-1", "--depts", "bad", "--yes"}, "--depts JSON 解析失败"},
|
||||
{"self no changes", []string{"user", "update-self", "--nick", " ", "--avatar-file-id", " ", "--yes"}, "至少需要一个修改项"},
|
||||
{"ownness missing id", []string{"user", "update-ownness", "--ownness-text", "居家办公中", "--yes"}, "required"},
|
||||
{"ownness blank id", []string{"user", "update-ownness", "--user-id", " ", "--ownness-text", "居家办公中", "--yes"}, "不能为空"},
|
||||
{"ownness missing text", []string{"user", "update-ownness", "--user-id", "user-1", "--yes"}, "required"},
|
||||
{"ownness blank text", []string{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", " ", "--yes"}, "不能为空"},
|
||||
{"account missing id", []string{"account", "update", "--nick", "小李", "--yes"}, "required"},
|
||||
{"account blank id", []string{"account", "update", "--user-id", " ", "--nick", "小李", "--yes"}, "不能为空"},
|
||||
{"account no changes", []string{"account", "update", "--user-id", "user-2", "--nick", " ", "--yes"}, "至少需要一个修改项"},
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -80,6 +81,101 @@ func requireTypedConfirmationError(t *testing.T, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
type guardedStepCaller struct {
|
||||
calls []guardedMutationCall
|
||||
steps []string
|
||||
index int
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func (c *guardedStepCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, guardedMutationCall{productID: productID, toolName: toolName, args: args})
|
||||
text := `{}`
|
||||
if c.index < len(c.steps) {
|
||||
text = c.steps[c.index]
|
||||
}
|
||||
c.index++
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
func (*guardedStepCaller) Format() string { return "json" }
|
||||
func (c *guardedStepCaller) DryRun() bool { return c.dryRun }
|
||||
func (*guardedStepCaller) Fields() string { return "" }
|
||||
func (*guardedStepCaller) JQ() string { return "" }
|
||||
|
||||
func executeGuardedMailMutationCommand(t *testing.T, caller edition.ToolCaller, args ...string) error {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = append([]string{"dws", "mail"}, args...)
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
root := newMailCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
if root.InOrStdin() == os.Stdin {
|
||||
root.SetIn(strings.NewReader(""))
|
||||
}
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestMailMessageShareToChatRequiresConfirmationBeforeToolCall(t *testing.T) {
|
||||
baseArgs := []string{
|
||||
"message", "share-to-chat",
|
||||
"--email", "user@company.com",
|
||||
"--id", "msg-1",
|
||||
"--users", "uid1",
|
||||
}
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMailMutationCommand(t, caller, baseArgs...)
|
||||
requireTypedConfirmationError(t, err)
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("tool calls = %#v, want none before confirmation", caller.calls)
|
||||
}
|
||||
|
||||
stepCaller := &guardedStepCaller{steps: []string{
|
||||
`{"result":{"sign":"sig","riskMessage":"careful"}}`,
|
||||
`{"ok":true}`,
|
||||
}}
|
||||
err = executeGuardedMailMutationCommand(t, stepCaller, append(append([]string(nil), baseArgs...), "--yes")...)
|
||||
if err != nil {
|
||||
t.Fatalf("confirmed share with sign returned error: %v", err)
|
||||
}
|
||||
if len(stepCaller.calls) != 2 {
|
||||
t.Fatalf("tool calls = %d, want 2 for sign retry", len(stepCaller.calls))
|
||||
}
|
||||
if stepCaller.calls[0].toolName != "share_message_to_chat" || stepCaller.calls[1].toolName != "share_message_to_chat" {
|
||||
t.Fatalf("tool calls = %#v, want share_message_to_chat twice", stepCaller.calls)
|
||||
}
|
||||
if sign, _ := stepCaller.calls[1].args["sign"].(string); sign != "sig" {
|
||||
t.Fatalf("second call sign = %q, want sig", sign)
|
||||
}
|
||||
|
||||
directCaller := &guardedStepCaller{steps: []string{`{"ok":true}`}}
|
||||
err = executeGuardedMailMutationCommand(t, directCaller, append(append([]string(nil), baseArgs...), "--yes")...)
|
||||
if err != nil {
|
||||
t.Fatalf("confirmed direct success returned error: %v", err)
|
||||
}
|
||||
if len(directCaller.calls) != 1 {
|
||||
t.Fatalf("tool calls = %d, want 1 for direct success", len(directCaller.calls))
|
||||
}
|
||||
if directCaller.calls[0].toolName != "share_message_to_chat" {
|
||||
t.Fatalf("tool call = %#v, want share_message_to_chat", directCaller.calls[0])
|
||||
}
|
||||
|
||||
dryRunCaller := &guardedMutationCaller{dryRun: true}
|
||||
err = executeGuardedMailMutationCommand(t, dryRunCaller, baseArgs...)
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run without --yes returned error: %v", err)
|
||||
}
|
||||
if len(dryRunCaller.calls) != 0 {
|
||||
t.Fatalf("dry-run tool calls = %#v, want none", dryRunCaller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatDismissGroupRequiresConfirmationBeforeToolCall(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
// Copyright 2022 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// This file is adapted from Go standard library's internal/diff package.
|
||||
// The original source can be found at /usr/local/go/src/internal/diff/diff.go.
|
||||
//
|
||||
// Modifications:
|
||||
// - Package changed from "diff" to "products"
|
||||
// - Diff() renamed to UnifiedDiff() and accepts a contextLines parameter
|
||||
// - const C replaced with the contextLines parameter
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A pair is a pair of values tracked for both the x and y side of a diff.
|
||||
// It is typically a pair of line indexes.
|
||||
type diffPair struct{ x, y int }
|
||||
|
||||
func nonNeg(v int) int {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// UnifiedDiff returns an anchored diff of the two texts old and new
|
||||
// in the "unified diff" format. If old and new are identical,
|
||||
// UnifiedDiff returns a nil slice (no output).
|
||||
//
|
||||
// Unix diff implementations typically look for a diff with
|
||||
// the smallest number of lines inserted and removed,
|
||||
// which can in the worst case take time quadratic in the
|
||||
// number of lines in the texts. As a result, many implementations
|
||||
// either can be made to run for a long time or cut off the search
|
||||
// after a predetermined amount of work.
|
||||
//
|
||||
// In contrast, this implementation looks for a diff with the
|
||||
// smallest number of "unique" lines inserted and removed,
|
||||
// where unique means a line that appears just once in both old and new.
|
||||
// We call this an "anchored diff" because the unique lines anchor
|
||||
// the chosen matching regions. An anchored diff is usually clearer
|
||||
// than a standard diff, because the algorithm does not try to
|
||||
// reuse unrelated blank lines or closing braces.
|
||||
// The algorithm also guarantees to run in O(n log n) time
|
||||
// instead of the standard O(n²) time.
|
||||
//
|
||||
// Some systems call this approach a "patience diff," named for
|
||||
// the "patience sorting" algorithm, itself named for a solitaire card game.
|
||||
// We avoid that name for two reasons. First, the name has been used
|
||||
// for a few different variants of the algorithm, so it is imprecise.
|
||||
// Second, the name is frequently interpreted as meaning that you have
|
||||
// to wait longer (to be patient) for the diff, meaning that it is a slower algorithm,
|
||||
// when in fact the algorithm is faster than the standard one.
|
||||
func UnifiedDiff(oldName string, old []byte, newName string, new []byte, contextLines int) []byte {
|
||||
if bytes.Equal(old, new) {
|
||||
return nil
|
||||
}
|
||||
x := diffLines(old)
|
||||
y := diffLines(new)
|
||||
|
||||
// Print diff header.
|
||||
var out bytes.Buffer
|
||||
fmt.Fprintf(&out, "diff %s %s\n", oldName, newName)
|
||||
fmt.Fprintf(&out, "--- %s\n", oldName)
|
||||
fmt.Fprintf(&out, "+++ %s\n", newName)
|
||||
|
||||
// Loop over matches to consider,
|
||||
// expanding each match to include surrounding lines,
|
||||
// and then printing diff chunks.
|
||||
// To avoid setup/teardown cases outside the loop,
|
||||
// tgs returns a leading {0,0} and trailing {len(x), len(y)} pair
|
||||
// in the sequence of matches.
|
||||
var (
|
||||
done diffPair // printed up to x[:done.x] and y[:done.y]
|
||||
chunk diffPair // start lines of current chunk
|
||||
count diffPair // number of lines from each side in current chunk
|
||||
ctext []string // lines for current chunk
|
||||
)
|
||||
for _, m := range diffTgs(x, y) {
|
||||
if m.x < done.x {
|
||||
// Already handled scanning forward from earlier match.
|
||||
continue
|
||||
}
|
||||
|
||||
// Expand matching lines as far as possible,
|
||||
// establishing that x[start.x:end.x] == y[start.y:end.y].
|
||||
// Note that on the first (or last) iteration we may (or definitely do)
|
||||
// have an empty match: start.x==end.x and start.y==end.y.
|
||||
start := m
|
||||
for start.x > done.x && start.y > done.y && x[start.x-1] == y[start.y-1] {
|
||||
start.x--
|
||||
start.y--
|
||||
}
|
||||
end := m
|
||||
for end.x < len(x) && end.y < len(y) && x[end.x] == y[end.y] {
|
||||
end.x++
|
||||
end.y++
|
||||
}
|
||||
|
||||
// Emit the mismatched lines before start into this chunk.
|
||||
// (No effect on first sentinel iteration, when start = {0,0}.)
|
||||
for _, s := range x[done.x:start.x] {
|
||||
ctext = append(ctext, "-"+s)
|
||||
count.x++
|
||||
}
|
||||
for _, s := range y[done.y:start.y] {
|
||||
ctext = append(ctext, "+"+s)
|
||||
count.y++
|
||||
}
|
||||
|
||||
// If we're not at EOF and have too few common lines,
|
||||
// the chunk includes all the common lines and continues.
|
||||
C := contextLines
|
||||
if C < 0 {
|
||||
// 防御性兼容:负值会破坏下方区间判定,按无上下文处理;0 是合法值,直接生效
|
||||
C = 0
|
||||
}
|
||||
if (end.x < len(x) || end.y < len(y)) &&
|
||||
(end.x-start.x < C || (len(ctext) > 0 && end.x-start.x < 2*C)) {
|
||||
for _, s := range x[start.x:end.x] {
|
||||
ctext = append(ctext, " "+s)
|
||||
count.x++
|
||||
count.y++
|
||||
}
|
||||
done = end
|
||||
continue
|
||||
}
|
||||
|
||||
// End chunk with common lines for context.
|
||||
if len(ctext) > 0 {
|
||||
n := end.x - start.x
|
||||
if n > C {
|
||||
n = C
|
||||
}
|
||||
for _, s := range x[start.x : start.x+n] {
|
||||
ctext = append(ctext, " "+s)
|
||||
count.x++
|
||||
count.y++
|
||||
}
|
||||
done = diffPair{start.x + n, start.y + n}
|
||||
|
||||
// Format and emit chunk.
|
||||
// Convert line numbers to 1-indexed.
|
||||
// Special case: empty file shows up as 0,0 not 1,0.
|
||||
if count.x > 0 {
|
||||
chunk.x++
|
||||
}
|
||||
if count.y > 0 {
|
||||
chunk.y++
|
||||
}
|
||||
fmt.Fprintf(&out, "@@ -%d,%d +%d,%d @@\n", chunk.x, count.x, chunk.y, count.y)
|
||||
for _, s := range ctext {
|
||||
out.WriteString(s)
|
||||
}
|
||||
count.x = 0
|
||||
count.y = 0
|
||||
ctext = ctext[:0]
|
||||
}
|
||||
|
||||
// If we reached EOF, we're done.
|
||||
if end.x >= len(x) && end.y >= len(y) {
|
||||
break
|
||||
}
|
||||
|
||||
// Otherwise start a new chunk.
|
||||
// C is clamped to >= 0 above; nonNeg saturates end-C when the next
|
||||
// hunk would start before line 0 (defensive; exercised via nonNeg tests).
|
||||
chunk = diffPair{nonNeg(end.x - C), nonNeg(end.y - C)}
|
||||
for _, s := range x[chunk.x:end.x] {
|
||||
ctext = append(ctext, " "+s)
|
||||
count.x++
|
||||
count.y++
|
||||
}
|
||||
done = end
|
||||
}
|
||||
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// diffLines returns the lines in the file x, including newlines.
|
||||
// If the file does not end in a newline, one is supplied
|
||||
// along with a warning about the missing newline.
|
||||
func diffLines(x []byte) []string {
|
||||
l := strings.SplitAfter(string(x), "\n")
|
||||
if l[len(l)-1] == "" {
|
||||
l = l[:len(l)-1]
|
||||
} else {
|
||||
// Treat last line as having a message about the missing newline attached,
|
||||
// using the same text as BSD/GNU diff (including the leading backslash).
|
||||
l[len(l)-1] += "\n\\ No newline at end of file\n"
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// diffTgs returns the pairs of indexes of the longest common subsequence
|
||||
// of unique lines in x and y, where a unique line is one that appears
|
||||
// once in x and once in y.
|
||||
//
|
||||
// The longest common subsequence algorithm is as described in
|
||||
// Thomas G. Szymanski, "A Special Case of the Maximal Common
|
||||
// Subsequence Problem," Princeton TR #170 (January 1975),
|
||||
// available at https://research.swtch.com/tgs170.pdf.
|
||||
func diffTgs(x, y []string) []diffPair {
|
||||
// Count the number of times each string appears in a and b.
|
||||
// We only care about 0, 1, many, counted as 0, -1, -2
|
||||
// for the x side and 0, -4, -8 for the y side.
|
||||
// Using negative numbers now lets us distinguish positive line numbers later.
|
||||
m := make(map[string]int)
|
||||
for _, s := range x {
|
||||
if c := m[s]; c > -2 {
|
||||
m[s] = c - 1
|
||||
}
|
||||
}
|
||||
for _, s := range y {
|
||||
if c := m[s]; c > -8 {
|
||||
m[s] = c - 4
|
||||
}
|
||||
}
|
||||
|
||||
// Now unique strings can be identified by m[s] = -1+-4.
|
||||
//
|
||||
// Gather the indexes of those strings in x and y, building:
|
||||
// xi[i] = increasing indexes of unique strings in x.
|
||||
// yi[i] = increasing indexes of unique strings in y.
|
||||
// inv[i] = index j such that x[xi[i]] = y[yi[j]].
|
||||
var xi, yi, inv []int
|
||||
for i, s := range y {
|
||||
if m[s] == -1+-4 {
|
||||
m[s] = len(yi)
|
||||
yi = append(yi, i)
|
||||
}
|
||||
}
|
||||
for i, s := range x {
|
||||
if j, ok := m[s]; ok && j >= 0 {
|
||||
xi = append(xi, i)
|
||||
inv = append(inv, j)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply Algorithm A from Szymanski's paper.
|
||||
// In those terms, A = J = inv and B = [0, n).
|
||||
// We add sentinel pairs {0,0}, and {len(x),len(y)}
|
||||
// to the returned sequence, to help the processing loop.
|
||||
J := inv
|
||||
n := len(xi)
|
||||
T := make([]int, n)
|
||||
L := make([]int, n)
|
||||
for i := range T {
|
||||
T[i] = n + 1
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
k := sort.Search(n, func(k int) bool {
|
||||
return T[k] >= J[i]
|
||||
})
|
||||
T[k] = J[i]
|
||||
L[i] = k + 1
|
||||
}
|
||||
k := 0
|
||||
for _, v := range L {
|
||||
if k < v {
|
||||
k = v
|
||||
}
|
||||
}
|
||||
seq := make([]diffPair, 2+k)
|
||||
seq[1+k] = diffPair{len(x), len(y)} // sentinel at end
|
||||
lastj := n
|
||||
for i := n - 1; i >= 0; i-- {
|
||||
if L[i] == k && J[i] < lastj {
|
||||
seq[k] = diffPair{xi[i], yi[J[i]]}
|
||||
k--
|
||||
}
|
||||
}
|
||||
seq[0] = diffPair{0, 0} // sentinel at start
|
||||
return seq
|
||||
}
|
||||
+64
-7
@@ -816,6 +816,8 @@ func newDocCommand() *cobra.Command {
|
||||
dws doc create 创建文档
|
||||
dws doc update 更新文档内容
|
||||
dws doc block [list|insert|update|delete] 块级编辑
|
||||
dws doc whiteboard insert 插入空白板卡片 (返回 blockId 与白板 partId)
|
||||
dws doc media [upload|download] 文档媒体资源 (上传可复用资源 / 下载附件)
|
||||
dws doc comment [list|create|reply|update|delete|create-inline] 文档评论管理
|
||||
dws doc export 导出在线文档 (支持 docx / markdown / pdf,自动完成提交→轮询→下载)
|
||||
dws doc export get 查询导出任务结果 (手动兜底)
|
||||
@@ -2525,6 +2527,54 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
|
||||
mediaDownloadCmd.Flags().String("node", "", "目标文档的标识,支持传入 URL 或 ID (必填)")
|
||||
mediaDownloadCmd.Flags().String("resource-id", "", "附件资源 ID,可通过 dws doc block list 获取 (必填)")
|
||||
|
||||
mediaUploadCmd := &cobra.Command{
|
||||
Use: "upload",
|
||||
Short: "上传可复用的文档媒体资源",
|
||||
Long: `将本地文件上传为绑定到目标 nodeId 的文档媒体资源,但不插入文档正文。
|
||||
|
||||
成功输出稳定的 resourceId 和 resourceUrl,可供同一 nodeId 下的白板 Vector/SVG
|
||||
等后续写入使用;临时 uploadUrl 不会输出。`,
|
||||
Example: ` dws doc media upload --node DOC_ID --file ./icon.svg --mime-type image/svg+xml --format json`,
|
||||
RunE: runDocMediaUpload,
|
||||
}
|
||||
DeclareLeafMetadata(mediaUploadCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "doc",
|
||||
Name: "media_upload",
|
||||
CanonicalPath: "doc.media_upload",
|
||||
CLIPath: "doc media upload",
|
||||
PrimaryCLIPath: "doc media upload",
|
||||
},
|
||||
Description: "上传可复用的文档媒体资源",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "命令先获取临时文档上传凭证,再在本地执行 OSS PUT,并仅暴露稳定的 node 绑定资源契约,不能绑定为单一 interface_ref",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "经用户确认后上传绑定到文档 nodeId 的可复用媒体资源而不插入正文",
|
||||
UseWhen: []string{"为同一文档内白板的 Vector/SVG 写入准备 resourceId 和 resourceUrl 时"},
|
||||
AvoidWhen: []string{"需要把附件直接插入文档正文时用 doc media insert;不要跨 nodeId 复用资源"},
|
||||
Examples: []string{"dws doc media upload --node <DOC_ID> --file ./icon.svg --mime-type image/svg+xml --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
|
||||
{Name: "file", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
mediaUploadCmd.Flags().String("node", "", "绑定媒体资源的文档标识,支持传入 URL 或 ID (必填)")
|
||||
mediaUploadCmd.Flags().String("file", "", "本地文件路径 (必填)")
|
||||
mediaUploadCmd.Flags().String("name", "", "资源文件名 (默认使用本地文件名)")
|
||||
mediaUploadCmd.Flags().String("mime-type", "", "文件 MIME 类型 (默认根据扩展名推断)")
|
||||
mediaUploadCmd.Flags().Bool("yes", false, "确认上传可复用文档媒体资源")
|
||||
|
||||
mediaInsertCmd := &cobra.Command{
|
||||
Use: "insert",
|
||||
Short: "上传附件并插入文档",
|
||||
@@ -2587,7 +2637,7 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
|
||||
mediaInsertCmd.Flags().String("ref-block", "", "参考块 ID (配合 --where)")
|
||||
|
||||
// media 子命令的 --node 隐藏别名
|
||||
mediaNodeAliasCmds := []*cobra.Command{mediaDownloadCmd, mediaInsertCmd}
|
||||
mediaNodeAliasCmds := []*cobra.Command{mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd}
|
||||
for _, c := range mediaNodeAliasCmds {
|
||||
c.Flags().String("url", "", "--node 的别名")
|
||||
c.Flags().String("id", "", "--node 的别名")
|
||||
@@ -2601,7 +2651,7 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
|
||||
_ = c.Flags().MarkHidden("file-id")
|
||||
}
|
||||
|
||||
mediaCmd.AddCommand(mediaDownloadCmd, mediaInsertCmd)
|
||||
mediaCmd.AddCommand(mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd)
|
||||
|
||||
// ── comment (文档评论) ──────────────────────────────────
|
||||
commentCmd := &cobra.Command{
|
||||
@@ -3607,11 +3657,13 @@ CLI 内部自动完成全部流程:
|
||||
PROCESSING 处理中
|
||||
SUCCESS 导出成功,返回 downloadUrl
|
||||
FAILED 导出失败`,
|
||||
Example: ` dws doc export get --job-id <JOB_ID>`,
|
||||
Example: ` dws doc export get --job-id <JOB_ID>
|
||||
dws doc export get --task-id <TASK_ID>`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
jobID := mustGetFlag(cmd, "job-id")
|
||||
if jobID == "" {
|
||||
return fmt.Errorf("flag --job-id is required")
|
||||
// Keep --job-id as the visible primary; --task-id is an add-only synonym.
|
||||
jobID, err := mustFlagOrFallback(cmd, "job-id", "task-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
@@ -3678,9 +3730,14 @@ CLI 内部自动完成全部流程:
|
||||
AvoidWhen: []string{"常规导出请直接 dws doc export(一体化提交+轮询+下载),不要先查 job"},
|
||||
Examples: []string{"dws doc export get --job-id <JOB_ID> --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "job-id", Property: "jobId"},
|
||||
},
|
||||
},
|
||||
})
|
||||
exportGetCmd.Flags().String("job-id", "", "导出任务 ID (必填)")
|
||||
exportGetCmd.Flags().String("task-id", "", "--job-id 的别名")
|
||||
_ = exportGetCmd.Flags().MarkHidden("task-id")
|
||||
|
||||
// --node 的隐藏别名(与 doc 下其他命令保持一致)
|
||||
exportCmd.Flags().String("url", "", "--node 的别名")
|
||||
@@ -4227,7 +4284,7 @@ CLI 内部自动完成全部流程:
|
||||
folderCmd.Hidden = true
|
||||
permissionCmd.Hidden = true
|
||||
|
||||
root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, importCmd, versionCmd, templateCmd, newDocStyleCommand())
|
||||
root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, importCmd, versionCmd, templateCmd, newDocStyleCommand(), newDocWhiteboardCommand())
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// runDocMediaUpload 上传绑定到文档 nodeId 的可复用媒体资源,但不插入正文块。
|
||||
// 白板 Vector/SVG 使用返回的 resourceId 与 resourceUrl 引用同一文档下的资源。
|
||||
func runDocMediaUpload(cmd *cobra.Command, _ []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filePath := mustGetFlag(cmd, "file")
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("flag --file is required")
|
||||
}
|
||||
fileInfo, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot read file %s: %w", filePath, err)
|
||||
}
|
||||
if fileInfo.IsDir() {
|
||||
return fmt.Errorf("%s is a directory, not a file", filePath)
|
||||
}
|
||||
|
||||
fileName, _ := cmd.Flags().GetString("name")
|
||||
if fileName == "" {
|
||||
fileName = filepath.Base(filePath)
|
||||
} else if filepath.Ext(fileName) == "" {
|
||||
fileName += filepath.Ext(filePath)
|
||||
}
|
||||
mimeType, _ := cmd.Flags().GetString("mime-type")
|
||||
if mimeType == "" {
|
||||
mimeType = inferMimeType(fileName)
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
return callMCPToolOnServer("doc", "get_doc_attachment_upload_info", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"fileName": fileName,
|
||||
"fileSize": float64(fileInfo.Size()),
|
||||
"mimeType": mimeType,
|
||||
})
|
||||
}
|
||||
|
||||
// 用户确认由 DeclareLeafMetadata(user_required) 的 ConfirmSafety 门控接管:
|
||||
// 推迟到首次 deps.Caller.CallTool(下方 get_doc_attachment_upload_info),
|
||||
// 避免与门控双读 stdin。--yes / --dry-run 经 confirmationBypass 跳过。
|
||||
text, err := callMCPToolReturnTextOnServer(cmd.Context(), "doc", "get_doc_attachment_upload_info", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"fileName": fileName,
|
||||
"fileSize": float64(fileInfo.Size()),
|
||||
"mimeType": mimeType,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uploadURL, resourceID, resourceURL, err := parseAttachmentUploadInfo(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resourceURL == "" {
|
||||
return fmt.Errorf("incomplete attachment upload info: missing resourceUrl")
|
||||
}
|
||||
if err := httpPutFile(cmd.Context(), uploadURL, map[string]string{"Content-Type": mimeType}, filePath, fileInfo.Size()); err != nil {
|
||||
message := strings.ReplaceAll(err.Error(), uploadURL, "<redacted upload URL>")
|
||||
return fmt.Errorf("document media upload failed: %s", message)
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"resourceId": resourceID,
|
||||
"resourceUrl": resourceURL,
|
||||
"fileName": fileName,
|
||||
"mimeType": mimeType,
|
||||
"size": fileInfo.Size(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package helpers
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
// RunDocImportShortcut exposes the existing, fully-tested Doc import pipeline
|
||||
// to the Shortcut application layer. The Cobra leaf still owns its own flags
|
||||
// and Contract; this bridge only shares the raw/API execution primitive.
|
||||
func RunDocImportShortcut(cmd *cobra.Command) error {
|
||||
return runImportCommand(cmd, nil, docImportFlowConfig())
|
||||
}
|
||||
|
||||
// RunDocMediaInsertShortcut shares the existing prepare + OSS PUT + block
|
||||
// insertion implementation with the canonical Doc Shortcut.
|
||||
func RunDocMediaInsertShortcut(cmd *cobra.Command) error {
|
||||
return runMediaInsert(cmd, nil)
|
||||
}
|
||||
|
||||
// RunDocResourceUpdateShortcut shares the cover upload/transfer pipeline.
|
||||
func RunDocResourceUpdateShortcut(cmd *cobra.Command) error {
|
||||
return runDocStyleCoverSet(cmd, nil)
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
const (
|
||||
whiteboardDrawPluginType = "application/x-alidocs-plugin-draw"
|
||||
whiteboardDefaultHeight = 600
|
||||
)
|
||||
|
||||
// errWhiteboardBlockPending 标记「块查询成功但目标块尚不可见」这一最终一致性场景。
|
||||
// 只有它允许插入后回查退化成 soft success;鉴权失败、MCP 错误、响应/JSONML 解析失败
|
||||
// 都是硬失败,必须 fail-closed,否则 Agent 会把它误判成最终一致性并带着空 partId
|
||||
// 继续调用 whiteboard query/update。
|
||||
var errWhiteboardBlockPending = errors.New("whiteboard card block is not visible yet")
|
||||
|
||||
var (
|
||||
whiteboardRetryDelays = []time.Duration{500 * time.Millisecond, time.Second, 2 * time.Second}
|
||||
whiteboardSleep = time.Sleep
|
||||
whiteboardJSONMarshal = json.Marshal
|
||||
prepareWhiteboardCard = prepareJsonMLNode
|
||||
)
|
||||
|
||||
func buildWhiteboardCardJSONML(blockUUID, whiteboardID string) string {
|
||||
node := []any{
|
||||
"card",
|
||||
map[string]any{
|
||||
"uuid": blockUUID,
|
||||
"cardType": "hetu",
|
||||
"height": whiteboardDefaultHeight,
|
||||
"metadata": map[string]any{"type": whiteboardDrawPluginType, "id": whiteboardID},
|
||||
},
|
||||
[]any{"span", map[string]any{"data-type": "text"},
|
||||
[]any{"span", map[string]any{"data-type": "leaf"}, ""}},
|
||||
}
|
||||
out, err := whiteboardJSONMarshal(node)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func extractWhiteboardID(attrs map[string]any) string {
|
||||
meta, _ := attrs["metadata"].(map[string]any)
|
||||
if meta == nil {
|
||||
return ""
|
||||
}
|
||||
id, _ := meta["id"].(string)
|
||||
return id
|
||||
}
|
||||
|
||||
func queryWhiteboardCardNode(ctx context.Context, nodeID, blockID string) ([]any, error) {
|
||||
text, err := callMCPToolReturnTextOnServer(ctx, "doc", "list_document_blocks", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"blockId": blockID,
|
||||
"format": "jsonml",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &data); err != nil {
|
||||
return nil, fmt.Errorf("parse list_document_blocks response: %w", err)
|
||||
}
|
||||
if result, ok := data["result"].(map[string]any); ok {
|
||||
data = result
|
||||
}
|
||||
blocksField, ok := data["blocks"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("list_document_blocks 响应缺少 blocks 字段")
|
||||
}
|
||||
blocks, ok := blocksField.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("list_document_blocks 响应的 blocks 字段不是数组")
|
||||
}
|
||||
var raw string
|
||||
for _, block := range blocks {
|
||||
entry, _ := block.(map[string]any)
|
||||
if entry == nil || entry["blockId"] != blockID {
|
||||
continue
|
||||
}
|
||||
raw, _ = entry["jsonml"].(string)
|
||||
break
|
||||
}
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("块 %s 不存在或查询无结果: %w", blockID, errWhiteboardBlockPending)
|
||||
}
|
||||
var node []any
|
||||
if err := json.Unmarshal([]byte(raw), &node); err != nil {
|
||||
return nil, fmt.Errorf("parse block jsonml: %w", err)
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
func queryWhiteboardCardAttrs(ctx context.Context, nodeID, blockID string) (map[string]any, error) {
|
||||
node, err := queryWhiteboardCardNode(ctx, nodeID, blockID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(node) < 2 {
|
||||
return nil, fmt.Errorf("块 %s 的 jsonml 节点缺少 attrs", blockID)
|
||||
}
|
||||
attrs, _ := node[1].(map[string]any)
|
||||
if attrs == nil {
|
||||
return nil, fmt.Errorf("块 %s 的 jsonml attrs 不是对象", blockID)
|
||||
}
|
||||
return attrs, nil
|
||||
}
|
||||
|
||||
func runWhiteboardInsert(cmd *cobra.Command, _ []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
blockUUID := uuid.New().String()
|
||||
whiteboardID := uuid.New().String()
|
||||
element := buildWhiteboardCardJSONML(blockUUID, whiteboardID)
|
||||
normalized, err := prepareWhiteboardCard(cmd, element)
|
||||
if err != nil {
|
||||
return fmt.Errorf("内部错误: 白板卡片模板未通过 JSONML 校验: %w", err)
|
||||
}
|
||||
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"jsonml": normalized,
|
||||
"format": "jsonml",
|
||||
}
|
||||
// --ref-block 与 --parent-block 已由 MarkFlagsMutuallyExclusive 保证互斥,
|
||||
// 这里用 else if 让「只有一条定位分支会写 referenceBlockId/where」在代码上自证。
|
||||
if v, _ := cmd.Flags().GetString("ref-block"); v != "" {
|
||||
toolArgs["referenceBlockId"] = v
|
||||
where, _ := cmd.Flags().GetString("where")
|
||||
if where == "" {
|
||||
where = "after"
|
||||
}
|
||||
toolArgs["where"] = where
|
||||
} else if v, _ := cmd.Flags().GetString("parent-block"); v != "" {
|
||||
toolArgs["referenceBlockId"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("index") {
|
||||
index, _ := cmd.Flags().GetInt("index")
|
||||
toolArgs["index"] = index
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
return callMCPToolOnServer("doc", "insert_document_block", toolArgs)
|
||||
}
|
||||
|
||||
// 用户确认由 DeclareLeafMetadata(user_required) 的 ConfirmSafety 门控接管:
|
||||
// 推迟到首次 deps.Caller.CallTool(下方 insert_document_block),避免与
|
||||
// 门控双读 stdin。--yes / --dry-run 经 confirmationBypass 跳过。
|
||||
ctx := cmd.Context()
|
||||
deps.Out.PrintProgress("[1/2] 插入白板卡片...")
|
||||
if _, err := callMCPToolReturnTextOnServer(ctx, "doc", "insert_document_block", toolArgs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deps.Out.PrintProgress("[2/2] 验证白板资源 ID 落库...")
|
||||
persistedID := ""
|
||||
for attempt := 0; attempt <= len(whiteboardRetryDelays); attempt++ {
|
||||
attrs, queryErr := queryWhiteboardCardAttrs(ctx, nodeID, blockUUID)
|
||||
switch {
|
||||
case queryErr == nil:
|
||||
// 块已可见;metadata.id 仍可能未落库,交给下方 soft success 分支重试。
|
||||
persistedID = extractWhiteboardID(attrs)
|
||||
case errors.Is(queryErr, errWhiteboardBlockPending):
|
||||
// 块暂不可见,属于最终一致性,继续重试。
|
||||
default:
|
||||
// 查询本身失败(鉴权 / MCP / 响应解析),不是最终一致性:
|
||||
// 必须 fail-closed,同时带出已插入的 blockId 供人工或后续回查复原。
|
||||
return fmt.Errorf(
|
||||
"白板卡片已插入 (blockId=%s),但回查验证失败,无法确认 whiteboardId: %w",
|
||||
blockUUID, queryErr)
|
||||
}
|
||||
if persistedID != "" {
|
||||
break
|
||||
}
|
||||
if attempt < len(whiteboardRetryDelays) {
|
||||
whiteboardSleep(whiteboardRetryDelays[attempt])
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]any{"blockId": blockUUID}
|
||||
if persistedID == "" {
|
||||
result["whiteboardId"] = nil
|
||||
deps.Out.PrintWarning(fmt.Sprintf(
|
||||
"白板已插入但未验证到 whiteboardId 落库,可稍后回查: dws doc block list --node %s --content-format jsonml --block-id %s",
|
||||
nodeID, blockUUID))
|
||||
} else {
|
||||
result["whiteboardId"] = persistedID
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{"success": true, "result": result})
|
||||
}
|
||||
|
||||
func newDocWhiteboardCommand() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "whiteboard",
|
||||
Short: "白板卡片管理",
|
||||
Long: `管理钉钉文档中的白板卡片:插入空白板并获取白板资源 ID。删除白板卡片请使用 dws doc block delete。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
insertCmd := &cobra.Command{
|
||||
Use: "insert",
|
||||
Short: "插入白板卡片",
|
||||
Long: `向文档插入一个空白板卡片(hetu draw card),并返回 blockId 与 whiteboardId。
|
||||
|
||||
CLI 生成卡片块 UUID 与白板资源 ID,插入后按块 UUID 回查并验证 metadata.id 落库。
|
||||
如果块暂不可见或 metadata.id 尚未落库,插入仍成功并返回 blockId,whiteboardId 为 null。
|
||||
如果回查本身失败(鉴权 / MCP 错误 / 响应解析失败),命令报错并在错误中带出已插入的 blockId。
|
||||
|
||||
定位方式互斥: --ref-block(配合 --where 同级插入)与 --parent-block(配合 --index 容器内插入)
|
||||
不能同时使用。`,
|
||||
Example: ` dws doc whiteboard insert --node DOC_ID
|
||||
dws doc whiteboard insert --node DOC_ID --ref-block BLOCK_ID --where before
|
||||
dws doc whiteboard insert --node DOC_ID --parent-block PARENT_ID --index 2`,
|
||||
RunE: runWhiteboardInsert,
|
||||
}
|
||||
insertCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
|
||||
insertCmd.Flags().String("ref-block", "", "参照块 UUID(同级插入,配合 --where)")
|
||||
insertCmd.Flags().String("where", "", "插入方向: before / after (默认 after,配合 --ref-block)")
|
||||
insertCmd.Flags().String("parent-block", "", "父容器 UUID(容器内插入,与 --index 配合)")
|
||||
insertCmd.Flags().Int("index", 0, "位置索引 (从 0 开始)")
|
||||
insertCmd.Flags().Bool("yes", false, "确认插入白板卡片")
|
||||
|
||||
// 同级插入与容器内插入共用 MCP 的 referenceBlockId:同时传两者会让 parent 静默
|
||||
// 覆盖 ref-block、而 --where 仍留在请求里污染容器插入语义。显式互斥而非静默取舍。
|
||||
insertCmd.MarkFlagsMutuallyExclusive("ref-block", "parent-block")
|
||||
insertCmd.MarkFlagsMutuallyExclusive("where", "parent-block")
|
||||
|
||||
for _, name := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
|
||||
insertCmd.Flags().String(name, "", "--node 的兼容别名")
|
||||
_ = insertCmd.Flags().MarkHidden(name)
|
||||
}
|
||||
|
||||
DeclareLeafMetadata(insertCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "user_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "doc",
|
||||
Name: "whiteboard_insert",
|
||||
CanonicalPath: "doc.whiteboard_insert",
|
||||
CLIPath: "doc whiteboard insert",
|
||||
PrimaryCLIPath: "doc whiteboard insert",
|
||||
},
|
||||
Description: "向文档插入空白板卡片并返回块 ID 与白板 part ID",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "命令生成卡片与白板 UUID、插入规范 JSONML,再回读块验证 metadata.id,不能绑定为单一 interface_ref",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "经用户确认后向钉钉文档插入空白板卡片并返回块 ID 与白板 part ID",
|
||||
UseWhen: []string{"目标文档还没有可操作白板,需要创建空白板卡片并取得后续 query/update 使用的 partId 时"},
|
||||
AvoidWhen: []string{"已有白板只需读取或编辑时使用 whiteboard query/update;删除卡片使用 doc block delete"},
|
||||
Examples: []string{"dws doc whiteboard insert --node <DOC_ID> --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
root.AddCommand(insertCmd)
|
||||
return root
|
||||
}
|
||||
@@ -316,12 +316,24 @@ func newDriveCommand() *cobra.Command {
|
||||
Example: ` dws drive list --limit 20
|
||||
dws drive list --folder <dentryUuid> --order-by name --order asc
|
||||
dws drive list --workspace <workspaceId>
|
||||
dws drive list --workspace <workspaceId> --folder <folderId>`,
|
||||
dws drive list --workspace <workspaceId> --folder <folderId>
|
||||
dws drive list --latest 5
|
||||
dws drive list --folder <dentryUuid> --latest 3 --pattern "*.docx"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
pattern, _ := cmd.Flags().GetString("pattern")
|
||||
|
||||
depth, _ := cmd.Flags().GetInt("depth")
|
||||
|
||||
latest, _ := cmd.Flags().GetInt("latest")
|
||||
if cmd.Flags().Changed("latest") {
|
||||
if err := validateDriveListLatest(cmd, latest); err != nil {
|
||||
return err
|
||||
}
|
||||
if cmd.Flags().Changed("versions") {
|
||||
return &CLIError{Code: CodeInvalidParam, Message: "--latest 不能与 --versions 同时使用"}
|
||||
}
|
||||
}
|
||||
|
||||
// --versions 模式:列出文件历史版本(仅普通文件)
|
||||
// 先于 --depth 校验执行:versions 模式合法使用 --limit,
|
||||
// 不应被「--limit 与 --depth 不兼容」的误导性报错拦截。
|
||||
@@ -363,7 +375,7 @@ func newDriveCommand() *cobra.Command {
|
||||
if workspaceID != "" {
|
||||
// depth>1 时 --pattern 放开(先递归后过滤);--order-by/--space-id/--thumbnail
|
||||
// 知识库无对应参数,静默忽略。
|
||||
if depth > 1 {
|
||||
if depth > 1 || latest > 0 {
|
||||
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||
baseArgs := map[string]any{"workspaceId": workspaceID}
|
||||
rootFolder := docFolderFlag(cmd, "node", "file-id")
|
||||
@@ -372,7 +384,7 @@ func newDriveCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
|
||||
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
|
||||
}
|
||||
if pattern != "" {
|
||||
return &CLIError{
|
||||
@@ -417,10 +429,27 @@ func newDriveCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
|
||||
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
|
||||
}
|
||||
|
||||
// 默认路由:钉盘文件列表
|
||||
if latest > 0 {
|
||||
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||
baseArgs := map[string]any{}
|
||||
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
|
||||
baseArgs["spaceId"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("thumbnail"); v {
|
||||
baseArgs["withThumbnail"] = true
|
||||
}
|
||||
rootFolder := flagOrFallback(cmd, "folder", "parent-id")
|
||||
if rootFolder != "" {
|
||||
if err := validateDriveParentID(rootFolder); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runDriveListLatest(cmd, baseArgs, rootFolder, latest, pattern, quiet)
|
||||
}
|
||||
maxResults, _ := cmd.Flags().GetInt("limit")
|
||||
if !cmd.Flags().Changed("limit") {
|
||||
if v, _ := cmd.Flags().GetInt("max"); v > 0 {
|
||||
@@ -491,7 +520,7 @@ func newDriveCommand() *cobra.Command {
|
||||
},
|
||||
Examples: []string{
|
||||
"dws drive list --limit 20 --format json",
|
||||
"dws drive list --folder <dentryUuid> --limit 20 --format json",
|
||||
"dws drive list --latest 5 --format json",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -701,6 +730,8 @@ func newDriveCommand() *cobra.Command {
|
||||
{Name: "part-size", Description: "分片下载的分片大小(如 8MB/16MB/1GB)"},
|
||||
{Name: "parallel", Description: "分片下载并发数(1-8)"},
|
||||
{Name: "no-resume", Description: "关闭断点续传"},
|
||||
// Wukong compat alias: routes to download-version; not a download_file property.
|
||||
{Name: "version", Description: "下载指定历史版本号(兼容别名,等价 download-version)"},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1052,7 +1083,8 @@ func newDriveCommand() *cobra.Command {
|
||||
driveListCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (--versions 模式下必填)")
|
||||
driveListCmd.Flags().String("pattern", "", "按名称通配过滤结果,如 \"*日报*\" (客户端过滤) (可选)")
|
||||
driveListCmd.Flags().Int("depth", 1, "递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥;与 --workspace 组合时走知识库递归 (可选)")
|
||||
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (仅 --depth>1 时有效) (可选)")
|
||||
driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥 (可选)")
|
||||
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (--depth>1 或 --latest 多页扫描时有效) (可选)")
|
||||
|
||||
driveInfoCmd.Flags().String("node", "", "节点 ID (dentryUuid) (必填)")
|
||||
driveInfoCmd.Flags().String("space-id", "", "节点所属空间 ID (可选)")
|
||||
@@ -1060,6 +1092,7 @@ func newDriveCommand() *cobra.Command {
|
||||
driveDownloadCmd.Flags().String("node", "", "文件 ID (dentryUuid) (必填)")
|
||||
driveDownloadCmd.Flags().String("space-id", "", "文件所属空间 ID (可选)")
|
||||
driveDownloadCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
|
||||
driveDownloadCmd.Flags().Int("version", 0, "下载指定历史版本号(兼容别名,等价 download-version)")
|
||||
driveDownloadCmd.Flags().String("part-size", "16MB", "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)")
|
||||
driveDownloadCmd.Flags().Int("parallel", 4, "分片下载并发数,范围 1-8 (可选)")
|
||||
driveDownloadCmd.Flags().Bool("no-resume", false, "关闭断点续传 (可选)")
|
||||
@@ -1074,6 +1107,14 @@ func newDriveCommand() *cobra.Command {
|
||||
driveDownloadVersionCmd.Flags().String(alias, "", "")
|
||||
_ = driveDownloadVersionCmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
// Wukong compat: `drive download --version N` routes to download-version.
|
||||
origDriveDownloadRunE := driveDownloadCmd.RunE
|
||||
driveDownloadCmd.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
if cmd.Flags().Changed("version") {
|
||||
return driveDownloadVersionCmd.RunE(cmd, args)
|
||||
}
|
||||
return origDriveDownloadRunE(cmd, args)
|
||||
}
|
||||
|
||||
driveMkdirCmd.Flags().String("name", "", "文件夹名称,最长 50 字符 (必填)")
|
||||
driveMkdirCmd.Flags().String("space-id", "", "目标空间 ID,不传则使用「我的文件」 (可选)")
|
||||
@@ -2019,6 +2060,8 @@ func newDriveCommand() *cobra.Command {
|
||||
limit := 0
|
||||
if cmd.Flags().Changed("limit") {
|
||||
limit, _ = cmd.Flags().GetInt("limit")
|
||||
} else if cmd.Flags().Changed("max-results") {
|
||||
limit, _ = cmd.Flags().GetInt("max-results")
|
||||
}
|
||||
if limit > 0 {
|
||||
toolArgs["maxResults"] = limit
|
||||
@@ -2070,6 +2113,8 @@ func newDriveCommand() *cobra.Command {
|
||||
})
|
||||
drivePermListCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
|
||||
drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
|
||||
drivePermListCmd.Flags().Int("max-results", 0, "")
|
||||
_ = drivePermListCmd.Flags().MarkHidden("max-results")
|
||||
drivePermListCmd.Flags().String("filter-role", "", "按角色过滤: OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
|
||||
drivePermListCmd.Flags().String("workspace", "", "知识库 ID (选填)")
|
||||
|
||||
@@ -2733,7 +2778,7 @@ func newDriveCommand() *cobra.Command {
|
||||
|
||||
// ── cross-product hidden aliases ──
|
||||
for _, cmd := range []*cobra.Command{
|
||||
driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd,
|
||||
driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd, driveDownloadVersionCmd,
|
||||
driveMkdirCmd, driveUploadInfoCmd, driveCommitCmd, driveUploadCmd, driveDeleteCmd,
|
||||
driveSearchCmd, driveCopyCmd, driveMoveCmd, driveRenameCmd, driveStatsCmd, driveShortcutCmd,
|
||||
driveFolderCreateCmd,
|
||||
@@ -3035,6 +3080,11 @@ func newDriveCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
driveCoverCmd.Flags().String("node", "", "节点 ID (dentryUuid) 或文档 URL (必填)")
|
||||
for _, alias := range []string{"url", "id"} {
|
||||
driveCoverCmd.Flags().String(alias, "", "--node 的别名")
|
||||
_ = driveCoverCmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
RegisterCrossProductAliases(driveCoverCmd)
|
||||
|
||||
// ── drive revert (回滚文件到指定历史版本) ──
|
||||
driveRevertCmd := &cobra.Command{
|
||||
@@ -3087,6 +3137,21 @@ func newDriveCommand() *cobra.Command {
|
||||
})
|
||||
driveRevertCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
|
||||
driveRevertCmd.Flags().Int("version", 0, "要回滚到的历史版本号 (必填,正整数)")
|
||||
for _, alias := range []string{"url", "id"} {
|
||||
driveRevertCmd.Flags().String(alias, "", "--node 的别名")
|
||||
_ = driveRevertCmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
RegisterCrossProductAliases(driveRevertCmd)
|
||||
|
||||
for _, child := range driveStarCmd.Commands() {
|
||||
for _, alias := range []string{"url", "id"} {
|
||||
if child.Flags().Lookup(alias) == nil {
|
||||
child.Flags().String(alias, "", "--node 的别名")
|
||||
_ = child.Flags().MarkHidden(alias)
|
||||
}
|
||||
}
|
||||
RegisterCrossProductAliases(child)
|
||||
}
|
||||
|
||||
driveCmd.AddCommand(
|
||||
driveListCmd,
|
||||
|
||||
@@ -178,7 +178,7 @@ func newDocDepthRoute() driveDepthRoute {
|
||||
}
|
||||
|
||||
// SIGINT 检查两点(出队后发首页前 + 翻页循环发每页前),入队是纯内存操作不检查。
|
||||
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool) error {
|
||||
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool, latest int) error {
|
||||
if deps.Caller.DryRun() {
|
||||
return printDriveDepthDryRun(route, baseArgs, maxDepth)
|
||||
}
|
||||
@@ -209,7 +209,7 @@ func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[s
|
||||
bfs:
|
||||
for len(queue) > 0 {
|
||||
if ctx.Err() != nil {
|
||||
return emitDriveDepthCancelled(collected, errs, pattern)
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
|
||||
}
|
||||
folder := queue[0]
|
||||
queue = queue[1:]
|
||||
@@ -220,7 +220,7 @@ bfs:
|
||||
pages := 0
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return emitDriveDepthCancelled(collected, errs, pattern)
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
|
||||
}
|
||||
pages++
|
||||
if pages > maxPagesPerFolder {
|
||||
@@ -233,7 +233,7 @@ bfs:
|
||||
args := route.buildArgs(baseArgs, folder.id, pageToken)
|
||||
text, err := route.fetchPage(ctx, args)
|
||||
if ctx.Err() != nil {
|
||||
return emitDriveDepthCancelled(collected, errs, pattern)
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
|
||||
}
|
||||
if err != nil {
|
||||
folderErr = err
|
||||
@@ -249,6 +249,12 @@ bfs:
|
||||
item["depth"] = folder.depth + 1
|
||||
item["parentId"] = folder.id // 根级为空串
|
||||
item["rel_path"] = rel // 不保证唯一,组树以 parentId 为准
|
||||
// 时间戳归一:钉盘 modifyTime / 知识库 updateTime 统一为 sortTime(毫秒 int64)
|
||||
if ms, ok := driveItemModifiedMillis(item); ok {
|
||||
item["sortTime"] = ms
|
||||
} else {
|
||||
item["sortTime"] = int64(0)
|
||||
}
|
||||
collected = append(collected, item)
|
||||
if len(collected) >= driveDepthMaxItems {
|
||||
// 未访问目录不记 errors[](没失败只是没扫),避免 errors 数组被淹没
|
||||
@@ -291,7 +297,7 @@ bfs:
|
||||
if driveDepthUnrecoverable(folderErr) {
|
||||
// partial 照吐 stdout,错误详情走 stderr,非零退出
|
||||
errs = append(errs, newDriveDepthError(folder, folderErr))
|
||||
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern); emitErr != nil {
|
||||
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth); emitErr != nil {
|
||||
return emitErr
|
||||
}
|
||||
return folderErr
|
||||
@@ -324,18 +330,26 @@ bfs:
|
||||
}
|
||||
}
|
||||
|
||||
return emitDriveDepthResult(collected, errs, truncated, pattern)
|
||||
if truncated && latest > 0 {
|
||||
return &CLIError{
|
||||
Code: CodeContentTruncated,
|
||||
Message: fmt.Sprintf("LATEST_SCAN_TRUNCATED: 扫描在全局上限 %d 条处截断,未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d", driveDepthMaxItems, latest),
|
||||
Suggestion: fmt.Sprintf("缩小扫描范围后重试:--folder 指定子目录,或降低 --depth 层数,如 dws drive list --folder <子目录ID> --latest %d", latest),
|
||||
}
|
||||
}
|
||||
|
||||
return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth)
|
||||
}
|
||||
|
||||
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string) error {
|
||||
if err := emitDriveDepthResult(items, errs, true, pattern); err != nil {
|
||||
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string, latest, reqDepth int) error {
|
||||
if err := emitDriveDepthResult(items, errs, true, pattern, latest, reqDepth); err != nil {
|
||||
return err
|
||||
}
|
||||
return &driveDepthCancelledError{}
|
||||
}
|
||||
|
||||
// depth>1 不输出 nextToken。
|
||||
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string) error {
|
||||
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string, latest, reqDepth int) error {
|
||||
if pattern != "" {
|
||||
// 先递归后过滤,过滤仅作用于输出项,不阻止文件夹下钻
|
||||
filtered := make([]map[string]any, 0, len(items))
|
||||
@@ -350,21 +364,28 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca
|
||||
}
|
||||
items = filtered
|
||||
}
|
||||
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
ri, _ := items[i]["rel_path"].(string)
|
||||
rj, _ := items[j]["rel_path"].(string)
|
||||
if ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return driveDepthItemID(items[i]) < driveDepthItemID(items[j])
|
||||
})
|
||||
if latest > 0 {
|
||||
items = applyDriveListLatest(items, latest)
|
||||
} else {
|
||||
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
ri, _ := items[i]["rel_path"].(string)
|
||||
rj, _ := items[j]["rel_path"].(string)
|
||||
if ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return driveDepthItemID(items[i]) < driveDepthItemID(items[j])
|
||||
})
|
||||
}
|
||||
maxDepth := 0
|
||||
for _, item := range items {
|
||||
if d, ok := item["depth"].(int); ok && d > maxDepth {
|
||||
maxDepth = d
|
||||
}
|
||||
}
|
||||
if latest > 0 && reqDepth == 1 {
|
||||
stripDriveDepthDecorations(items)
|
||||
}
|
||||
if items == nil {
|
||||
items = []map[string]any{}
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
{"name": "a-file.xlsx", "rel_path": "a", "depth": 2, "fileId": "f1"},
|
||||
{"name": "skip-me.csv", "rel_path": "c", "depth": 1, "fileId": "f3"},
|
||||
}
|
||||
if err := emitDriveDepthResult(items, nil, false, "*.xlsx"); err != nil {
|
||||
if err := emitDriveDepthResult(items, nil, false, "*.xlsx", 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := decodeDepthResult(t, out)
|
||||
@@ -325,7 +325,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
if err := emitDriveDepthResult(nil, nil, false, ""); err != nil {
|
||||
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result = decodeDepthResult(t, out)
|
||||
@@ -338,7 +338,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
{"name": "dup", "rel_path": "p/dup", "fileId": "a1"},
|
||||
}
|
||||
out.Reset()
|
||||
if err := emitDriveDepthResult(samePath, nil, false, ""); err != nil {
|
||||
if err := emitDriveDepthResult(samePath, nil, false, "", 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = decodeDepthResult(t, out)["items"].([]any)
|
||||
@@ -347,7 +347,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
}
|
||||
|
||||
deps.Out.w = failingWriter{}
|
||||
if err := emitDriveDepthResult(nil, nil, false, ""); err == nil {
|
||||
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err == nil {
|
||||
t.Fatal("failing writer returned nil")
|
||||
}
|
||||
}
|
||||
@@ -382,7 +382,7 @@ func runDepthBFS(t *testing.T, caller *scriptedToolCaller, route driveDepthRoute
|
||||
t.Helper()
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true)
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true, 0)
|
||||
return decodeDepthResult(t, out), err
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ func TestCrossPlatformCoverageRunDriveListDepthDryRun(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", dry: true}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true); err != nil {
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
@@ -413,7 +413,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPanBFS(t *testing.T) {
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false); err != nil {
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 2 {
|
||||
@@ -548,7 +548,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRateLimitResumesFromFailedPage(t
|
||||
deps.Out.w = out
|
||||
deps.Out.errW = io.Discard
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true); err != nil {
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 3 {
|
||||
@@ -593,7 +593,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRootFailure(t *testing.T) {
|
||||
}}
|
||||
installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil {
|
||||
t.Fatal("root failure returned nil")
|
||||
}
|
||||
@@ -610,7 +610,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverable(t *testing.T) {
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil {
|
||||
t.Fatal("unrecoverable returned nil")
|
||||
}
|
||||
@@ -635,7 +635,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverableEmitFailure(t *testi
|
||||
installDepthCaller(t, caller)
|
||||
deps.Out.w = failingWriter{}
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("err = %v, want emit failure", err)
|
||||
}
|
||||
@@ -648,7 +648,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPaginationLoop(t *testing.T) {
|
||||
}}
|
||||
installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "cursor loop suspected") {
|
||||
t.Fatalf("err = %v, want pagination anomaly", err)
|
||||
}
|
||||
@@ -690,7 +690,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelled(t *testing.T) {
|
||||
cancel()
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
@@ -710,7 +710,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledEmitFailure(t *testing.T
|
||||
cancel()
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("err = %v, want emit failure", err)
|
||||
}
|
||||
@@ -724,7 +724,7 @@ func TestCrossPlatformCoverageRunDriveListDepthFinalEmitFailure(t *testing.T) {
|
||||
installDepthCaller(t, caller)
|
||||
deps.Out.w = failingWriter{}
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("err = %v, want emit failure", err)
|
||||
}
|
||||
@@ -772,7 +772,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledInsidePagination(t *test
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true, 0)
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
@@ -801,7 +801,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledAfterFetch(t *testing.T)
|
||||
deps.Out.errW = io.Discard
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
// --latest 上限与 --limit 服务端每页硬上限 50 对齐。
|
||||
driveLatestMax = 50
|
||||
// 钉盘单层 latest 扫描上限(50×20 页)。
|
||||
driveLatestScanMax = 1000
|
||||
)
|
||||
|
||||
// validateDriveListLatest --latest 边界与互斥校验。
|
||||
func validateDriveListLatest(cmd *cobra.Command, latest int) error {
|
||||
if latest < 1 || latest > driveLatestMax {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: fmt.Sprintf("--latest 必须为 1~%d 的整数,当前: %d", driveLatestMax, latest),
|
||||
}
|
||||
}
|
||||
for _, f := range []string{"order-by", "order"} {
|
||||
if cmd.Flags().Changed(f) {
|
||||
return driveLatestExclusiveError(f, latest)
|
||||
}
|
||||
}
|
||||
if cmd.Flags().Changed("limit") || cmd.Flags().Changed("max") {
|
||||
return driveLatestExclusiveError("limit", latest)
|
||||
}
|
||||
if v := flagOrFallback(cmd, "cursor", "next-token"); v != "" {
|
||||
return driveLatestExclusiveError("cursor", latest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func driveLatestExclusiveError(flag string, latest int) error {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: fmt.Sprintf("--latest 不能与 --%s 同时使用:Top-N 排序语义由 latest 独占;如需自定义排序请改用 --order-by modifyTime --order desc --limit %d", flag, latest),
|
||||
}
|
||||
}
|
||||
|
||||
func applyDriveListLatest(items []map[string]any, latest int) []map[string]any {
|
||||
files := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
if isDriveDepthFolder(item) || isDocDepthFolder(item) {
|
||||
continue
|
||||
}
|
||||
files = append(files, item)
|
||||
}
|
||||
sort.SliceStable(files, func(i, j int) bool {
|
||||
ti, _ := files[i]["sortTime"].(int64)
|
||||
tj, _ := files[j]["sortTime"].(int64)
|
||||
if ti != tj {
|
||||
return ti > tj
|
||||
}
|
||||
ri, _ := files[i]["rel_path"].(string)
|
||||
rj, _ := files[j]["rel_path"].(string)
|
||||
if ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return driveDepthItemID(files[i]) < driveDepthItemID(files[j])
|
||||
})
|
||||
if len(files) > latest {
|
||||
files = files[:latest]
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func stripDriveDepthDecorations(items []map[string]any) {
|
||||
for _, item := range items {
|
||||
delete(item, "depth")
|
||||
delete(item, "parentId")
|
||||
delete(item, "rel_path")
|
||||
delete(item, "sortTime")
|
||||
}
|
||||
}
|
||||
|
||||
func driveItemModifiedMillis(item map[string]any) (int64, bool) {
|
||||
for _, k := range []string{"modifiedTime", "modifyTime", "modified_time", "gmtModified", "lastModifiedTime", "updateTime"} {
|
||||
if v, ok := item[k]; ok {
|
||||
if ms, ok := toMillis(v); ok {
|
||||
return ms, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func toMillis(v any) (int64, bool) {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
if t <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return int64(t), true
|
||||
case json.Number:
|
||||
if n, err := t.Int64(); err == nil && n > 0 {
|
||||
return n, true
|
||||
}
|
||||
case string:
|
||||
s := strings.TrimSpace(t)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 {
|
||||
return n, true
|
||||
}
|
||||
if tm, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return tm.UnixMilli(), true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// runDriveListLatest 钉盘单层 --latest 扫描。
|
||||
func runDriveListLatest(cmd *cobra.Command, baseArgs map[string]any, rootFolder string, latest int, pattern string, quiet bool) error {
|
||||
buildArgs := func(pageToken string) map[string]any {
|
||||
args := map[string]any{
|
||||
"maxResults": float64(driveDepthPageSize),
|
||||
"orderBy": "modifyTime",
|
||||
"order": "desc",
|
||||
}
|
||||
for k, v := range baseArgs {
|
||||
args[k] = v
|
||||
}
|
||||
if rootFolder != "" {
|
||||
args["parentId"] = rootFolder
|
||||
}
|
||||
if pageToken != "" {
|
||||
args["nextToken"] = pageToken
|
||||
}
|
||||
return args
|
||||
}
|
||||
if deps.Caller.DryRun() {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"tool": "list_files",
|
||||
"args": buildArgs(""),
|
||||
"latest": latest,
|
||||
"note": "dry-run:latest 为客户端能力,凑够 N 条即停,最多扫描 1000 条",
|
||||
})
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
collected := make([]map[string]any, 0, latest)
|
||||
scanned := 0
|
||||
pageToken := ""
|
||||
maxPages := driveLatestScanMax/driveDepthPageSize + 1
|
||||
for pages := 0; pages < maxPages; pages++ {
|
||||
text, err := callMCPToolReturnText(ctx, "list_files", buildArgs(pageToken))
|
||||
if err != nil {
|
||||
return fmt.Errorf("latest 扫描第 %d 页失败: %w", pages+1, err)
|
||||
}
|
||||
items, next, _ := parseDriveDepthPage(text)
|
||||
for _, item := range items {
|
||||
scanned++
|
||||
if isDriveDepthFolder(item) {
|
||||
continue
|
||||
}
|
||||
name, _ := item["name"].(string)
|
||||
if name == "" {
|
||||
name, _ = item["fileName"].(string)
|
||||
}
|
||||
if pattern != "" && !matchDriveNamePattern(name, pattern) {
|
||||
continue
|
||||
}
|
||||
collected = append(collected, item)
|
||||
if len(collected) >= latest {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(collected) >= latest || next == "" || scanned >= driveLatestScanMax {
|
||||
break
|
||||
}
|
||||
pageToken = next
|
||||
if !quiet {
|
||||
fmt.Fprintf(os.Stderr, "[drive-list] latest 扫描中: 已扫 %d 条,命中 %d/%d\n", scanned, len(collected), latest)
|
||||
}
|
||||
}
|
||||
if len(collected) < latest {
|
||||
hint := fmt.Sprintf("dws drive list --folder <子目录ID> --latest %d", latest)
|
||||
if pattern != "" {
|
||||
hint = fmt.Sprintf("dws drive list --folder <子目录ID> --pattern %q --latest %d", pattern, latest)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[drive-list] 已扫描 %d 条,找到 %d/%d 条;建议缩小范围:%s\n", scanned, len(collected), latest, hint)
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{"items": collected})
|
||||
}
|
||||
@@ -66,6 +66,8 @@ var (
|
||||
driveFileStat = (*os.File).Stat
|
||||
)
|
||||
|
||||
var driveWorkerContextErr = func(ctx context.Context) error { return ctx.Err() }
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// HTTP 状态错误
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -629,7 +631,7 @@ func downloadRangedParts(ctx context.Context, creds *driveCredentialState, destP
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for part := range jobs {
|
||||
if runCtx.Err() != nil {
|
||||
if driveWorkerContextErr(runCtx) != nil {
|
||||
return
|
||||
}
|
||||
if err := downloadOnePart(runCtx, creds, f, part, totalSize); err != nil {
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -2868,48 +2870,32 @@ func TestCrossPlatformCoverageDriveDownloadVersionCancelNoResume(t *testing.T) {
|
||||
|
||||
func TestCrossPlatformCoverageDriveTransferWorkerCtxCancelBeforeProcess(t *testing.T) {
|
||||
// 目标:覆盖 downloadRangedParts worker 中 "if runCtx.Err() != nil { return }"。
|
||||
// 策略:让 workers 正常处理分片,通过 context timeout 在处理过程中过期。
|
||||
// 当 worker 完成某个分片后循环回来收到新 job 时,发现 runCtx 已取消。
|
||||
// transport 每次请求加 50μs 延迟,使总处理时间接近 timeout,最大化命中率。
|
||||
|
||||
totalSize := int64(200)
|
||||
content := makeTestContent(int(totalSize))
|
||||
|
||||
origClient := driveRangeClient
|
||||
t.Cleanup(func() { driveRangeClient = origClient })
|
||||
|
||||
driveRangeClient = &http.Client{
|
||||
// 通过结构化 seam 让 worker 在收到唯一分片后确定性观察到取消状态;
|
||||
// 不再依赖微秒级 timeout 与 goroutine 调度概率。
|
||||
var checks atomic.Int32
|
||||
testseam.Swap(t, &driveWorkerContextErr, func(context.Context) error {
|
||||
checks.Add(1)
|
||||
return context.Canceled
|
||||
})
|
||||
var requests atomic.Int32
|
||||
testseam.Swap(t, &driveRangeClient, &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
// 每次请求加小延迟,让总处理时间接近 deadline
|
||||
time.Sleep(50 * time.Microsecond)
|
||||
var start, end int64
|
||||
if _, err := fmt.Sscanf(req.Header.Get("Range"), "bytes=%d-%d", &start, &end); err != nil {
|
||||
return &http.Response{StatusCode: 400, Body: io.NopCloser(strings.NewReader("bad"))}, nil
|
||||
}
|
||||
if end >= int64(len(content)) {
|
||||
end = int64(len(content)) - 1
|
||||
}
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusPartialContent,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(string(content[start : end+1]))),
|
||||
}
|
||||
resp.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(content)))
|
||||
return resp, nil
|
||||
requests.Add(1)
|
||||
return nil, errors.New("worker context guard did not stop the request")
|
||||
}),
|
||||
})
|
||||
|
||||
creds := &driveCredentialState{url: "http://127.0.0.1:1/fake"}
|
||||
dest := filepath.Join(t.TempDir(), "worker-context-guard.bin")
|
||||
opts := driveDownloadOptions{partSize: 1, parallel: 1, resume: false, knownSize: 1}
|
||||
if err := downloadRangedParts(context.Background(), creds, dest, 1, opts); err != nil {
|
||||
t.Fatalf("downloadRangedParts context guard: %v", err)
|
||||
}
|
||||
|
||||
// 多次尝试以确保覆盖(goroutine 调度非确定性)
|
||||
for attempt := 0; attempt < 50; attempt++ {
|
||||
// timeout 设为约为总处理时间的50%,确保在处理过程中过期
|
||||
// 40分片/4workers=10轮*50μs=500μs,timeout设300μs使其在中间过期
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Microsecond)
|
||||
|
||||
creds := &driveCredentialState{url: "http://127.0.0.1:1/fake"}
|
||||
dest := filepath.Join(t.TempDir(), fmt.Sprintf("wkr-%d.bin", attempt))
|
||||
opts := driveDownloadOptions{partSize: 5, parallel: 4, resume: false, knownSize: totalSize}
|
||||
_ = downloadRangedParts(ctx, creds, dest, totalSize, opts)
|
||||
cancel()
|
||||
if checks.Load() != 1 {
|
||||
t.Fatalf("worker context checks = %d, want 1", checks.Load())
|
||||
}
|
||||
if requests.Load() != 0 {
|
||||
t.Fatalf("worker requests = %d, want 0", requests.Load())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ func TestCrossPlatformCoverageReadToolNameContractAndHelperBoundary(t *testing.T
|
||||
"LIST_MESSAGES": true,
|
||||
"query_send_status": true,
|
||||
"search_messages": true,
|
||||
"enterprise_person_search": true,
|
||||
"unread_message_conversation_list": true,
|
||||
"send_personal_message": false,
|
||||
"": false,
|
||||
|
||||
@@ -190,6 +190,9 @@ func callMCPReadToolReturnTextOnServer(ctx context.Context, serverID, toolName s
|
||||
// ReadToolCaller.
|
||||
func IsReadToolName(toolName string) bool {
|
||||
toolName = strings.TrimSpace(strings.ToLower(toolName))
|
||||
if toolName == "enterprise_person_search" {
|
||||
return true
|
||||
}
|
||||
for _, prefix := range []string{
|
||||
"get_", "list_", "query_", "search_", "unread_",
|
||||
} {
|
||||
|
||||
+495
-3
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -195,7 +196,72 @@ func newMailCommand() *cobra.Command {
|
||||
|
||||
mailboxProfileCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
|
||||
mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd)
|
||||
mailboxSharedWithMeCmd := &cobra.Command{
|
||||
Use: "shared-with-me",
|
||||
Short: "查询共享给我的邮箱",
|
||||
Long: `查询他人共享给当前用户的邮箱账号列表,包含共享关系类型。
|
||||
|
||||
共享关系(relationships)取值:
|
||||
LOGIN 登录(可登录该共享邮箱)
|
||||
SEND_AS 代发(以该邮箱身份发送邮件)
|
||||
SEND_ON_BEHALF 代表发送(代表该邮箱发送邮件)
|
||||
|
||||
返回字段:
|
||||
total 可访问的共享账号总数
|
||||
targets 可访问的共享账号列表`,
|
||||
Example: ` dws mail mailbox shared-with-me
|
||||
dws mail mailbox shared-with-me --limit 20 --offset 0`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
toolArgs := map[string]any{}
|
||||
if cmd.Flags().Changed("limit") {
|
||||
limit, _ := cmd.Flags().GetInt("limit")
|
||||
toolArgs["limit"] = limit
|
||||
}
|
||||
if cmd.Flags().Changed("offset") {
|
||||
offset, _ := cmd.Flags().GetInt("offset")
|
||||
toolArgs["offset"] = offset
|
||||
}
|
||||
if len(toolArgs) == 0 {
|
||||
return callMCPTool("list_shared_with_me", nil)
|
||||
}
|
||||
return callMCPTool("list_shared_with_me", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(mailboxSharedWithMeCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail",
|
||||
Name: "list_shared_with_me",
|
||||
CanonicalPath: "mail.list_shared_with_me",
|
||||
CLIPath: "mail mailbox shared-with-me",
|
||||
PrimaryCLIPath: "mail mailbox shared-with-me",
|
||||
},
|
||||
Description: "查询共享给我的邮箱",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_shared_with_me"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询他人共享给当前用户的邮箱",
|
||||
UseWhen: []string{"需要列出登录/代发/代表发送权限的共享邮箱时"},
|
||||
AvoidWhen: []string{"列出自己邮箱用 mail mailbox list"},
|
||||
Examples: []string{"dws mail mailbox shared-with-me"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "limit", Property: "limit", InterfaceType: "integer"},
|
||||
{Name: "offset", Property: "offset", InterfaceType: "integer"},
|
||||
},
|
||||
},
|
||||
})
|
||||
mailboxSharedWithMeCmd.Flags().Int("limit", 0, "返回数量上限 (可选)")
|
||||
mailboxSharedWithMeCmd.Flags().Int("offset", 0, "偏移量 (可选)")
|
||||
|
||||
mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd, mailboxSharedWithMeCmd)
|
||||
|
||||
messageCmd := &cobra.Command{Use: "message", Short: "邮件管理", RunE: groupRunE}
|
||||
|
||||
@@ -2126,9 +2192,256 @@ internetMessageId 来源:message send / draft send / message reply / message r
|
||||
messageVerifyCmd.Flags().String("email", "", "邮件所属邮箱地址 (必填)")
|
||||
messageVerifyCmd.Flags().String("internet-message-id", "", "邮件的 internetMessageId (必填),取自发送类命令返回值")
|
||||
|
||||
messageExportCmd := &cobra.Command{
|
||||
Use: "export",
|
||||
Short: "导出/备份邮件(EML格式)",
|
||||
Long: `导出指定邮件为 EML 格式文件并保存到本地。
|
||||
|
||||
不指定 --filename 时,默认以邮件主题作为文件名。
|
||||
文件保存在当前工作目录下,扩展名为 .eml。
|
||||
默认不覆盖同名文件,使用 --overwrite 强制覆盖。
|
||||
|
||||
注意:目前仅支持 100KB 以内的邮件导出。
|
||||
|
||||
编排流程:
|
||||
1. 调用 get_email_by_message_id 获取邮件主题(用作默认文件名)
|
||||
2. 调用 export_message_mime 获取 EML 内容
|
||||
3. 将 EML 内容原子写入本地文件`,
|
||||
Example: ` dws mail message export --email user@company.com --id <messageId>
|
||||
dws mail message export --email user@company.com --id <messageId> --filename my-mail`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email", "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
email := mustGetFlag(cmd, "email")
|
||||
messageID := mustGetFlag(cmd, "id")
|
||||
filename := mustGetFlag(cmd, "filename")
|
||||
overwrite, _ := cmd.Flags().GetBool("overwrite")
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
// Human plan summary (no "[DRY-RUN]" tag): Schema dry-run
|
||||
// evidence classifies "操作:" + audited DryRun() as plan.
|
||||
deps.Out.PrintKeyValue("操作", "导出邮件为 EML 文件")
|
||||
deps.Out.PrintKeyValue("email", email)
|
||||
deps.Out.PrintKeyValue("messageId", messageID)
|
||||
if filename != "" {
|
||||
deps.Out.PrintKeyValue("filename", filename)
|
||||
}
|
||||
deps.Out.PrintKeyValue("overwrite", fmt.Sprintf("%v", overwrite))
|
||||
deps.Out.PrintKeyValue("编排", "get_email_by_message_id → export_message_mime → 写入本地 .eml 文件")
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
if filename == "" {
|
||||
msgText, err := callMCPToolReturnText(ctx, "get_email_by_message_id", map[string]any{
|
||||
"email": email,
|
||||
"messageId": messageID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取邮件信息失败: %w", err)
|
||||
}
|
||||
var msgData map[string]any
|
||||
if err := json.Unmarshal([]byte(msgText), &msgData); err == nil {
|
||||
data := msgData
|
||||
if result, ok := data["result"].(map[string]any); ok {
|
||||
data = result
|
||||
}
|
||||
if msg, ok := data["message"].(map[string]any); ok {
|
||||
data = msg
|
||||
}
|
||||
if subj, ok := data["subject"].(string); ok && subj != "" {
|
||||
filename = subj
|
||||
}
|
||||
}
|
||||
if filename == "" {
|
||||
filename = messageID
|
||||
}
|
||||
}
|
||||
filename = sanitizeMailFilename(filename)
|
||||
|
||||
exportText, err := callMCPToolReturnText(ctx, "export_message_mime", map[string]any{
|
||||
"email": email,
|
||||
"id": messageID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("导出邮件失败: %w", err)
|
||||
}
|
||||
var exportData map[string]any
|
||||
if err := json.Unmarshal([]byte(exportText), &exportData); err != nil {
|
||||
return fmt.Errorf("解析导出结果失败: %w", err)
|
||||
}
|
||||
if result, ok := exportData["result"].(map[string]any); ok {
|
||||
exportData = result
|
||||
}
|
||||
emlContent, _ := exportData["emlContent"].(string)
|
||||
if emlContent == "" {
|
||||
return fmt.Errorf("导出结果为空: %s", exportText)
|
||||
}
|
||||
destPath := filename + ".eml"
|
||||
if err := atomicWriteFile(destPath, []byte(emlContent), 0600, overwrite); err != nil {
|
||||
if os.IsExist(err) {
|
||||
return fmt.Errorf("文件 %s 已存在,使用 --overwrite 覆盖", destPath)
|
||||
}
|
||||
return fmt.Errorf("保存文件失败: %w", err)
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("邮件已导出到: %s", destPath))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(messageExportCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail",
|
||||
Name: "export_message_mime",
|
||||
CanonicalPath: "mail.export_message_mime",
|
||||
CLIPath: "mail message export",
|
||||
PrimaryCLIPath: "mail message export",
|
||||
},
|
||||
Description: "导出/备份邮件为本地 EML 文件",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Orchestrates get_email_by_message_id + export_message_mime + local file write.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "导出邮件为本地 EML 备份",
|
||||
UseWhen: []string{"需要把单封邮件备份为本地 .eml 文件时"},
|
||||
AvoidWhen: []string{"仅查看正文用 mail message get;分享到 IM 用 mail message share-to-chat"},
|
||||
Examples: []string{"dws mail message export --email user@company.com --id <messageId>"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "email", Property: "email", Required: boolPtr(true)},
|
||||
{Name: "id", Property: "id", Required: boolPtr(true)},
|
||||
{Name: "filename", Property: "filename"},
|
||||
{Name: "overwrite", Property: "overwrite", InterfaceType: "boolean"},
|
||||
},
|
||||
},
|
||||
})
|
||||
messageExportCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
messageExportCmd.Flags().String("id", "", "邮件ID (必填)")
|
||||
messageExportCmd.Flags().String("filename", "", "导出文件名(不含扩展名),默认使用邮件主题")
|
||||
messageExportCmd.Flags().Bool("overwrite", false, "是否覆盖同名文件,默认 false")
|
||||
|
||||
messageShareToChatCmd := &cobra.Command{
|
||||
Use: "share-to-chat",
|
||||
Short: "[危险] 分享邮件至IM聊天",
|
||||
Long: `将指定邮件分享到钉钉单聊。
|
||||
|
||||
参数说明:
|
||||
--users 目标用户UID列表,逗号分隔(规范名),兼容 --uids
|
||||
--yes 确认执行此危险操作 (必填)
|
||||
|
||||
默认需要 --yes 确认才能执行;--dry-run 仅预览分享计划,不发起真实请求。
|
||||
服务端可能返回风险提示(riskMessage)和 sign;在 --yes 已通过的前提下,
|
||||
将展示风险提示并自动携带 sign 重新请求。`,
|
||||
Example: ` dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1,uid2 --yes
|
||||
dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1 --yes`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email", "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
mcpArgs := map[string]any{
|
||||
"email": mustGetFlag(cmd, "email"),
|
||||
"id": mustGetFlag(cmd, "id"),
|
||||
}
|
||||
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
|
||||
mcpArgs["uids"] = parseRecipients(users)
|
||||
}
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
if deps.Caller.DryRun() {
|
||||
// Human plan summary (no "[DRY-RUN]" tag): Schema dry-run
|
||||
// evidence classifies "操作:" + audited DryRun() as plan.
|
||||
deps.Out.PrintKeyValue("操作", "分享邮件至 IM 聊天")
|
||||
deps.Out.PrintKeyValue("email", mustGetFlag(cmd, "email"))
|
||||
deps.Out.PrintKeyValue("messageId", mustGetFlag(cmd, "id"))
|
||||
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
|
||||
deps.Out.PrintKeyValue("users", users)
|
||||
}
|
||||
deps.Out.PrintKeyValue("yes", fmt.Sprintf("%v", yes))
|
||||
deps.Out.PrintKeyValue("说明", "仅预览分享计划,不发起真实分享请求")
|
||||
return nil
|
||||
}
|
||||
if !commandBoolFlag(cmd, "yes") {
|
||||
return apperrors.NewValidation(
|
||||
"分享邮件至 IM 为高风险操作;获得用户确认后加 --yes 执行",
|
||||
apperrors.WithReason("confirmation_required"),
|
||||
apperrors.WithHint("先确认目标用户与邮件内容;用户明确同意后以相同参数追加 --yes"),
|
||||
apperrors.WithActions("确认目标用户与邮件", "获得用户确认后使用 --yes 执行"),
|
||||
)
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
firstText, err := callMCPToolReturnText(ctx, "share_message_to_chat", mcpArgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("分享邮件失败: %w", err)
|
||||
}
|
||||
var firstResult map[string]any
|
||||
if err := json.Unmarshal([]byte(firstText), &firstResult); err != nil {
|
||||
return fmt.Errorf("解析分享结果失败: %w", err)
|
||||
}
|
||||
if result, ok := firstResult["result"].(map[string]any); ok {
|
||||
firstResult = result
|
||||
}
|
||||
if sign, ok := firstResult["sign"].(string); ok && sign != "" {
|
||||
if riskMsg, _ := firstResult["riskMessage"].(string); riskMsg != "" {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[风险提示] %s", riskMsg))
|
||||
}
|
||||
mcpArgs["sign"] = sign
|
||||
return callMCPTool("share_message_to_chat", mcpArgs)
|
||||
}
|
||||
// firstResult is already a parsed object (possibly unwrapped from result).
|
||||
return deps.Out.PrintJSON(firstResult)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(messageShareToChatCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail",
|
||||
Name: "share_message_to_chat",
|
||||
CanonicalPath: "mail.share_message_to_chat",
|
||||
CLIPath: "mail message share-to-chat",
|
||||
PrimaryCLIPath: "mail message share-to-chat",
|
||||
},
|
||||
Description: "分享邮件至 IM 单聊",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "share_message_to_chat"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "把邮件分享到钉钉单聊",
|
||||
UseWhen: []string{"需要将指定邮件分享给钉钉用户(单聊)时"},
|
||||
AvoidWhen: []string{"仅导出本地备份用 mail message export;群聊发消息用 chat message send"},
|
||||
Examples: []string{"dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "email", Property: "email", Required: boolPtr(true)},
|
||||
{Name: "id", Property: "id", Required: boolPtr(true)},
|
||||
{Name: "users", Property: "uids"},
|
||||
{Name: "yes", Property: "yes", InterfaceType: "boolean"},
|
||||
},
|
||||
},
|
||||
})
|
||||
messageShareToChatCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
messageShareToChatCmd.Flags().String("id", "", "邮件ID (必填)")
|
||||
messageShareToChatCmd.Flags().String("users", "", "目标用户UID列表,逗号分隔")
|
||||
messageShareToChatCmd.Flags().String("uids", "", "--users 的别名")
|
||||
_ = messageShareToChatCmd.Flags().MarkHidden("uids")
|
||||
messageShareToChatCmd.Flags().Bool("yes", false, "确认执行此危险操作 (必填)")
|
||||
|
||||
messageCmd.AddCommand(messageListCmd, messageSearchCmd, messageGetCmd, messageSendCmd,
|
||||
messageReplyCmd, messageReplyAllCmd, messageForwardCmd,
|
||||
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd)
|
||||
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd, messageExportCmd, messageShareToChatCmd)
|
||||
|
||||
sentMessageCmd := &cobra.Command{Use: "sent-message", Short: "已发送邮件管理", RunE: groupRunE}
|
||||
|
||||
@@ -3400,7 +3713,129 @@ object 与 operation 合法组合:
|
||||
blockListRemoveCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)")
|
||||
blockListCmd.AddCommand(blockListListCmd, blockListAddCmd, blockListRemoveCmd)
|
||||
|
||||
root.AddCommand(mailboxCmd, messageCmd, sentMessageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd, allowListCmd, blockListCmd)
|
||||
calendarCmd := &cobra.Command{Use: "calendar", Short: "邮箱日历管理", RunE: groupRunE}
|
||||
calendarListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "列出用户可访问的日历列表",
|
||||
Long: `列出用户可访问的日历列表,包括用户自己创建以及接受共享后生成的日历。
|
||||
返回的 id 可作为 calendar-event list 的 --id / --folder-id 使用。`,
|
||||
Example: ` dws mail calendar list --email user@company.com`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("list_mailbox_calendars", map[string]any{
|
||||
"email": mustGetFlag(cmd, "email"),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(calendarListCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail",
|
||||
Name: "list_mailbox_calendars",
|
||||
CanonicalPath: "mail.list_mailbox_calendars",
|
||||
CLIPath: "mail calendar list",
|
||||
PrimaryCLIPath: "mail calendar list",
|
||||
},
|
||||
Description: "列出邮箱日历文件夹",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_mailbox_calendars"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出邮箱可访问的日历文件夹",
|
||||
UseWhen: []string{"查询邮箱日历文件夹 id,以便继续查日程时"},
|
||||
AvoidWhen: []string{"钉钉主日历日程请用 dws calendar event list"},
|
||||
Examples: []string{"dws mail calendar list --email user@company.com"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "email", Property: "email", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
calendarListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
calendarCmd.AddCommand(calendarListCmd)
|
||||
|
||||
calendarEventCmd := &cobra.Command{Use: "calendar-event", Short: "邮箱日历日程管理", RunE: groupRunE}
|
||||
calendarEventListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "查询指定日历时间范围内的日程",
|
||||
Long: `查询指定邮箱日历文件夹在 UTC 时间区间 [startTime, endTime) 内出现的日程,支持 cursor 分页。循环日程会展开为该时间范围内的单次日程。`,
|
||||
Example: ` dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start "2026-07-01T00:00:00Z" --end "2026-07-31T23:59:59Z"
|
||||
dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start "2026-07-01T00:00:00Z" --end "2026-07-31T23:59:59Z" --cursor <cursor>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email", "start", "end"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlagWithAliases(cmd, "id", "folder-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"email": mustGetFlag(cmd, "email"),
|
||||
"id": flagOrFallback(cmd, "id", "folder-id"),
|
||||
"startTime": flagOrFallback(cmd, "start", "start-time"),
|
||||
"endTime": flagOrFallback(cmd, "end", "end-time"),
|
||||
}
|
||||
if cursor := mustGetFlag(cmd, "cursor"); cursor != "" {
|
||||
toolArgs["cursor"] = cursor
|
||||
}
|
||||
return callMCPTool("list_mailbox_calendar_events", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(calendarEventListCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail",
|
||||
Name: "list_mailbox_calendar_events",
|
||||
CanonicalPath: "mail.list_mailbox_calendar_events",
|
||||
CLIPath: "mail calendar-event list",
|
||||
PrimaryCLIPath: "mail calendar-event list",
|
||||
},
|
||||
Description: "查询邮箱日历文件夹时间范围内的日程",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_mailbox_calendar_events"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询邮箱日历日程列表",
|
||||
UseWhen: []string{"已知邮箱日历文件夹 id,需要按 UTC 时间窗列出日程时"},
|
||||
AvoidWhen: []string{"钉钉主日历请用 dws calendar event list;未知文件夹 id 时先 mail calendar list"},
|
||||
Examples: []string{"dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start \"2026-07-01T00:00:00Z\" --end \"2026-07-31T23:59:59Z\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "email", Property: "email", Required: boolPtr(true)},
|
||||
{Name: "id", Property: "id", Required: boolPtr(true)},
|
||||
{Name: "start", Property: "startTime", Required: boolPtr(true)},
|
||||
{Name: "end", Property: "endTime", Required: boolPtr(true)},
|
||||
{Name: "cursor", Property: "cursor"},
|
||||
},
|
||||
},
|
||||
})
|
||||
calendarEventListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
calendarEventListCmd.Flags().String("id", "", "日历文件夹ID (必填)")
|
||||
calendarEventListCmd.Flags().String("folder-id", "", "--id 的别名")
|
||||
_ = calendarEventListCmd.Flags().MarkHidden("folder-id")
|
||||
calendarEventListCmd.Flags().String("start", "", "视图开始UTC时间 (必填)")
|
||||
calendarEventListCmd.Flags().String("start-time", "", "--start 的别名")
|
||||
_ = calendarEventListCmd.Flags().MarkHidden("start-time")
|
||||
calendarEventListCmd.Flags().String("end", "", "视图结束UTC时间 (必填)")
|
||||
calendarEventListCmd.Flags().String("end-time", "", "--end 的别名")
|
||||
_ = calendarEventListCmd.Flags().MarkHidden("end-time")
|
||||
calendarEventListCmd.Flags().String("cursor", "", "分页光标 (可选)")
|
||||
calendarEventCmd.AddCommand(calendarEventListCmd)
|
||||
|
||||
root.AddCommand(mailboxCmd, messageCmd, sentMessageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd, allowListCmd, blockListCmd, calendarCmd, calendarEventCmd)
|
||||
|
||||
return root
|
||||
}
|
||||
@@ -3935,3 +4370,60 @@ func runMailAttachmentDownload(cmd *cobra.Command) error {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("附件已保存到: %s", destPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeMailFilename(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
name = strings.ReplaceAll(name, "/", "_")
|
||||
name = strings.ReplaceAll(name, "\\", "_")
|
||||
name = strings.ReplaceAll(name, "\x00", "")
|
||||
if name == "" {
|
||||
return "mail"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// mailAtomicLink is the no-clobber commit for atomicWriteFile (test-injectable).
|
||||
var mailAtomicLink = os.Link
|
||||
|
||||
// atomicWriteFile 原子写入文件:先写同目录临时文件,成功后提交到目标路径。
|
||||
// overwrite=false 使用 link(2) 实现存在即失败;overwrite=true 使用 rename 覆盖。
|
||||
func atomicWriteFile(path string, data []byte, perm os.FileMode, overwrite bool) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := atomicCreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建临时文件失败: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
_ = tmp.Close()
|
||||
_ = atomicRemove(tmpName)
|
||||
}
|
||||
}()
|
||||
if err := tmp.Chmod(perm); err != nil {
|
||||
return fmt.Errorf("设置文件权限失败: %w", err)
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
return fmt.Errorf("写入数据失败: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return fmt.Errorf("同步磁盘失败: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("关闭临时文件失败: %w", err)
|
||||
}
|
||||
if overwrite {
|
||||
if err := atomicRename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("重命名文件失败: %w", err)
|
||||
}
|
||||
success = true
|
||||
return nil
|
||||
}
|
||||
if err := mailAtomicLink(tmpName, path); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = atomicRemove(tmpName)
|
||||
success = true
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -49,12 +49,13 @@ func newMarkdownCommand() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "markdown",
|
||||
Short: "Markdown 文件处理",
|
||||
Long: "创建、覆盖、修补和获取钉盘或文档空间中的原生 Markdown 文件。",
|
||||
Long: "创建、覆盖、修补、对比和获取钉盘或文档空间中的原生 Markdown 文件。",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
root.AddCommand(
|
||||
newMarkdownFetchCmd(),
|
||||
newMarkdownCreateCmd(),
|
||||
newMarkdownDiffCmd(),
|
||||
newMarkdownOverwriteCmd(),
|
||||
newMarkdownPatchCmd(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// dws markdown diff
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// diffResult 是 diff 命令的输出结构。
|
||||
type diffResult struct {
|
||||
Mode string `json:"mode"`
|
||||
Changed bool `json:"changed"`
|
||||
AddedLines int `json:"added_lines"`
|
||||
DeletedLines int `json:"deleted_lines"`
|
||||
Hunks int `json:"hunks"`
|
||||
Diff string `json:"diff"`
|
||||
}
|
||||
|
||||
// diff 命令的限制(包级变量以便覆盖测试注入更小阈值)。
|
||||
var (
|
||||
maxDiffFileSize int64 = 10 * 1024 * 1024 // 单侧文件大小上限 10MB
|
||||
diffDownloadTimeout = 10 * time.Minute // 下载远端内容超时(与项目其他下载命令一致)
|
||||
diffComputeTimeout = 30 * time.Second // 本地 diff 计算超时
|
||||
diffJSONMarshalIndent = json.MarshalIndent
|
||||
runMarkdownUnifiedDiff = computeUnifiedDiff
|
||||
)
|
||||
|
||||
// formatFileSize 返回人类可读的文件大小。
|
||||
func formatFileSize(size int64) string {
|
||||
if size >= 1024*1024 {
|
||||
return fmt.Sprintf("%.1f MB", float64(size)/float64(1024*1024))
|
||||
}
|
||||
if size >= 1024 {
|
||||
return fmt.Sprintf("%.1f KB", float64(size)/float64(1024))
|
||||
}
|
||||
return fmt.Sprintf("%d B", size)
|
||||
}
|
||||
|
||||
// checkFileSize 校验文件大小是否超过限制。
|
||||
func checkFileSize(path string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Size() > maxDiffFileSize {
|
||||
return fmt.Errorf("文件大小 %s 超过限制 %s,请使用更小的文件", formatFileSize(info.Size()), formatFileSize(maxDiffFileSize))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadRemoteContent 下载远端文件内容并返回其文本。
|
||||
// versionNum <= 0 时走 download_file(用 fileId),> 0 时走 download_file_version(用 nodeId + version)。
|
||||
func downloadRemoteContent(ctx context.Context, fileID string, versionNum int) (string, error) {
|
||||
var text string
|
||||
var err error
|
||||
if versionNum > 0 {
|
||||
text, err = callMCPToolReturnTextOnServer(ctx, "drive", "download_file_version", map[string]any{
|
||||
"nodeId": fileID,
|
||||
"version": versionNum,
|
||||
})
|
||||
} else {
|
||||
text, err = callMCPToolReturnTextOnServer(ctx, "drive", "download_file", map[string]any{
|
||||
"fileId": fileID,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resourceURL, dlHeaders, err := parseDownloadInfo(text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 下载时即限制大小,避免完整下载超大文件后才拦截(约束网络流量与内存占用)
|
||||
content, err := diffDownloadLimited(ctx, resourceURL, dlHeaders)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
// diffDownloadLimited 下载远端内容,并在下载过程中强制执行 maxDiffFileSize 上限。
|
||||
// 包级变量以便测试注入。
|
||||
var diffDownloadLimited = defaultDiffDownloadLimited
|
||||
|
||||
// defaultDiffDownloadLimited 通过 HTTP GET 下载内容:先用 Content-Length 预检,
|
||||
// 再用 io.LimitReader 将实际读取量限制为 maxDiffFileSize+1 字节,超限即报错,
|
||||
// 从而约束网络流量与内存占用,而非在完整下载后才校验。
|
||||
func defaultDiffDownloadLimited(ctx context.Context, url string, headers map[string]string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: diffDownloadTimeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Content-Length 预检:可在读取 body 前提前拦截超大文件
|
||||
if resp.ContentLength > maxDiffFileSize {
|
||||
return nil, fmt.Errorf("远端文件大小 %s 超过限制 %s,请使用更小的文件", formatFileSize(resp.ContentLength), formatFileSize(maxDiffFileSize))
|
||||
}
|
||||
|
||||
// 实际读取限制为 maxDiffFileSize+1 字节,读满即判定超限(防止 Content-Length 缺失或造假)
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxDiffFileSize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > maxDiffFileSize {
|
||||
return nil, fmt.Errorf("远端文件大小超过限制 %s,请使用更小的文件", formatFileSize(maxDiffFileSize))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// computeUnifiedDiff 使用 Go stdlib patience diff 算法计算 unified diff,并统计变更行数。
|
||||
func computeUnifiedDiff(left, right string, contextLines int) (string, int, int, int, bool) {
|
||||
out := UnifiedDiff("left", []byte(left), "right", []byte(right), contextLines)
|
||||
if len(out) == 0 {
|
||||
return "", 0, 0, 0, false
|
||||
}
|
||||
|
||||
text := string(out)
|
||||
added, deleted, hunks := 0, 0, 0
|
||||
// 只统计首个 @@ 之后的 hunk 区行:头部三行(diff/---/+++)不参与计数,
|
||||
// hunk 区内每行必带单字符前缀,内容行以 --/++ 开头也不会被误判为文件头而漏计
|
||||
inHunk := false
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if strings.HasPrefix(line, "@@") {
|
||||
hunks++
|
||||
inHunk = true
|
||||
continue
|
||||
}
|
||||
if !inHunk {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "+") {
|
||||
added++
|
||||
} else if strings.HasPrefix(line, "-") {
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
changed := added > 0 || deleted > 0
|
||||
return text, added, deleted, hunks, changed
|
||||
}
|
||||
|
||||
// ensureMarkdownDiffType 校验 markdown diff 的目标文件类型。
|
||||
// markdown 产品域面向 .md 文件,非 md 文件拦截并回引到对应产品命令。
|
||||
// 类型探测复用 fetchFileInfo(显式路由 drive server 的 get_file_info),
|
||||
// 探测失败或类型未知时不阻断,让后续 MCP 工具自行报错。
|
||||
func ensureMarkdownDiffType(ctx context.Context, nodeID string) error {
|
||||
info := fetchFileInfo(ctx, nodeID)
|
||||
switch info.extension {
|
||||
case "", "md", "markdown":
|
||||
return nil
|
||||
case "adoc":
|
||||
return fmt.Errorf("该文件为钉钉在线文档 (adoc),不支持 markdown diff\n请使用 dws doc 对应命令(如 dws doc version list / dws doc export)")
|
||||
case "axls":
|
||||
return fmt.Errorf("该文件为钉钉在线表格 (axls),不支持 markdown diff\n请使用 dws sheet 对应命令")
|
||||
case "amind", "adraw":
|
||||
return fmt.Errorf("该文件为钉钉在线%s (%s),暂不支持历史版本管理\nmarkdown diff 与 dws drive list --versions / dws drive download --version 均不支持该类型", describeDingTalkDocType(info.extension), info.extension)
|
||||
default:
|
||||
return fmt.Errorf("该文件为 %s 文件,markdown diff 仅支持 .md 文件\n普通文件的历史版本请使用 dws drive list --versions / dws drive download --version / dws drive revert", info.extension)
|
||||
}
|
||||
}
|
||||
|
||||
// newMarkdownDiffCmd 创建 markdown diff 子命令。
|
||||
func newMarkdownDiffCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "diff",
|
||||
Short: "比较 Markdown 内容差异",
|
||||
Long: `比较远端 Markdown 文件的两个版本,或远端版本与本地文件,生成 unified diff。
|
||||
|
||||
模式:
|
||||
remote_vs_remote: --version V1 --version2 V2 (两个历史版本)
|
||||
remote_vs_remote: --version V1 (历史版本 vs 最新)
|
||||
remote_vs_local: --file ./local.md (最新 vs 本地)
|
||||
remote_vs_local: --version V1 --file ./local.md (历史版本 vs 本地)
|
||||
|
||||
历史版本号通过 dws drive list --versions 获取。
|
||||
--file 与 --version2 不能同时使用。
|
||||
|
||||
限制:
|
||||
- 仅支持 .md 文件(在线文档/表格请使用 doc/sheet 命令)
|
||||
- 单侧文件大小上限: 10 MB
|
||||
- 下载超时: 10 分钟
|
||||
- diff 计算超时: 30 秒`,
|
||||
Example: ` # 比较两个历史版本
|
||||
dws markdown diff --node <dentryUuid> --version 3 --version2 5
|
||||
|
||||
# 历史版本 vs 最新版本
|
||||
dws markdown diff --node <dentryUuid> --version 3
|
||||
|
||||
# 最新版本 vs 本地文件
|
||||
dws markdown diff --node <dentryUuid> --file ./draft.md
|
||||
|
||||
# 历史版本 vs 本地文件
|
||||
dws markdown diff --node <dentryUuid> --version 3 --file ./draft.md`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
version1, _ := cmd.Flags().GetInt("version")
|
||||
version2, _ := cmd.Flags().GetInt("version2")
|
||||
localFile, _ := cmd.Flags().GetString("file")
|
||||
contextLines, _ := cmd.Flags().GetInt("context")
|
||||
|
||||
// fail-fast 参数校验(置于 dry-run 之前):显式传了版本号但非正整数时立即报错,
|
||||
// 避免静默降级为“最新版本”;--context 允许 0(无上下文),仅拒绝负值
|
||||
if cmd.Flags().Changed("version") && version1 <= 0 {
|
||||
return fmt.Errorf("--version 必须为正整数,当前值: %d", version1)
|
||||
}
|
||||
if cmd.Flags().Changed("version2") && version2 <= 0 {
|
||||
return fmt.Errorf("--version2 必须为正整数,当前值: %d", version2)
|
||||
}
|
||||
if contextLines < 0 {
|
||||
return fmt.Errorf("--context 不能为负数,当前值: %d", contextLines)
|
||||
}
|
||||
|
||||
// 互斥校验:--file 与 --version2 不能同时使用
|
||||
if localFile != "" && version2 > 0 {
|
||||
return fmt.Errorf("--file 与 --version2 不能同时使用")
|
||||
}
|
||||
|
||||
// 模式判定
|
||||
var mode string
|
||||
if localFile != "" {
|
||||
mode = "remote_vs_local"
|
||||
} else {
|
||||
mode = "remote_vs_remote"
|
||||
}
|
||||
|
||||
// remote_vs_remote 模式至少需要一个版本号,否则两侧均取最新版本,diff 必为空
|
||||
if localFile == "" && version1 == 0 && version2 == 0 {
|
||||
return fmt.Errorf("remote_vs_remote 模式至少需要指定 --version 或 --version2 之一")
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
deps.Out.PrintKeyValue("操作", "Markdown 内容 Diff")
|
||||
deps.Out.PrintKeyValue("模式", mode)
|
||||
deps.Out.PrintKeyValue("节点ID", nodeID)
|
||||
if version1 > 0 {
|
||||
deps.Out.PrintKeyValue("左侧版本", fmt.Sprintf("%d", version1))
|
||||
} else {
|
||||
deps.Out.PrintKeyValue("左侧版本", "最新")
|
||||
}
|
||||
if localFile != "" {
|
||||
deps.Out.PrintKeyValue("右侧", localFile)
|
||||
} else if version2 > 0 {
|
||||
deps.Out.PrintKeyValue("右侧版本", fmt.Sprintf("%d", version2))
|
||||
} else {
|
||||
deps.Out.PrintKeyValue("右侧版本", "最新")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 下载超时 context(与项目其他下载命令一致:10 分钟)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), diffDownloadTimeout)
|
||||
defer cancel()
|
||||
|
||||
// 类型守卫置于 dry-run 之后,确保 dry-run 不产生任何服务端调用
|
||||
if err := ensureMarkdownDiffType(ctx, nodeID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// remote_vs_local 模式:先校验本地文件大小,避免下载后才发现过大
|
||||
if localFile != "" {
|
||||
if err := checkFileSize(localFile); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 输出格式读全局 --format(默认 json),json 时输出结构化结果,其余输出文本摘要
|
||||
isJSON := deps.Caller.Format() == "json"
|
||||
// 进度属带外诊断信息,统一写入 stderr,保证 stdout 在两种模式下都是纯净输出
|
||||
progress := func(msg string) { fmt.Fprintln(os.Stderr, msg) }
|
||||
|
||||
// 下载左侧内容
|
||||
progress("[1/3] 获取左侧内容...")
|
||||
leftContent, err := downloadRemoteContent(ctx, nodeID, version1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 获取右侧内容
|
||||
var rightContent string
|
||||
if localFile != "" {
|
||||
// remote_vs_local: 读取本地文件(大小已校验)
|
||||
progress("[2/3] 读取本地文件...")
|
||||
data, err := os.ReadFile(localFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取本地文件失败: %w", err)
|
||||
}
|
||||
rightContent = string(data)
|
||||
} else {
|
||||
// remote_vs_remote: 下载右侧远端内容
|
||||
progress("[2/3] 获取右侧内容...")
|
||||
rightContent, err = downloadRemoteContent(ctx, nodeID, version2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 计算 diff(带超时保护)
|
||||
progress("[3/3] 计算差异...")
|
||||
type diffOutput struct {
|
||||
text string
|
||||
added int
|
||||
deleted int
|
||||
hunks int
|
||||
changed bool
|
||||
}
|
||||
resultCh := make(chan diffOutput, 1)
|
||||
// Capture seams before the goroutine so test restorers cannot race
|
||||
// against a still-running compute after the timeout path returns.
|
||||
computeDiff := runMarkdownUnifiedDiff
|
||||
marshalIndent := diffJSONMarshalIndent
|
||||
go func() {
|
||||
text, added, deleted, hunks, changed := computeDiff(leftContent, rightContent, contextLines)
|
||||
resultCh <- diffOutput{text, added, deleted, hunks, changed}
|
||||
}()
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
diffText, added, deleted, hunks, changed := res.text, res.added, res.deleted, res.hunks, res.changed
|
||||
result := diffResult{
|
||||
Mode: mode,
|
||||
Changed: changed,
|
||||
AddedLines: added,
|
||||
DeletedLines: deleted,
|
||||
Hunks: hunks,
|
||||
Diff: diffText,
|
||||
}
|
||||
|
||||
// 输出
|
||||
if isJSON {
|
||||
data, err := marshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("JSON 序列化失败: %w", err)
|
||||
}
|
||||
deps.Out.PrintRaw(string(data))
|
||||
} else {
|
||||
deps.Out.PrintKeyValue("模式", result.Mode)
|
||||
if result.Changed {
|
||||
deps.Out.PrintKeyValue("是否有变更", "是")
|
||||
} else {
|
||||
deps.Out.PrintKeyValue("是否有变更", "否")
|
||||
}
|
||||
deps.Out.PrintKeyValue("新增行数", fmt.Sprintf("%d", result.AddedLines))
|
||||
deps.Out.PrintKeyValue("删除行数", fmt.Sprintf("%d", result.DeletedLines))
|
||||
deps.Out.PrintKeyValue("差异块数", fmt.Sprintf("%d", result.Hunks))
|
||||
if result.Changed {
|
||||
deps.Out.PrintRaw("")
|
||||
deps.Out.PrintRaw(result.Diff)
|
||||
}
|
||||
}
|
||||
case <-time.After(diffComputeTimeout):
|
||||
return fmt.Errorf("diff 计算超时(%s),文件可能过大,请尝试减小 --context 或使用更小的文件", diffComputeTimeout)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
|
||||
cmd.Flags().Int("version", 0, "左侧历史版本号 (可选,不传=最新版本)")
|
||||
cmd.Flags().Int("version2", 0, "右侧历史版本号 (可选,不传=最新版本;不能与 --file 同时使用)")
|
||||
cmd.Flags().String("file", "", "本地 .md 文件路径 (可选,指定后进入 remote_vs_local 模式)")
|
||||
cmd.Flags().Int("context", 3, "diff 上下文行数 (默认 3)")
|
||||
|
||||
// --node 隐藏别名(与 version/fetch 子命令一致)
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("id", "", "")
|
||||
cmd.Flags().String("node-id", "", "")
|
||||
cmd.Flags().String("doc-id", "", "")
|
||||
cmd.Flags().String("file-id", "", "")
|
||||
_ = cmd.Flags().MarkHidden("url")
|
||||
_ = cmd.Flags().MarkHidden("id")
|
||||
_ = cmd.Flags().MarkHidden("node-id")
|
||||
_ = cmd.Flags().MarkHidden("doc-id")
|
||||
_ = cmd.Flags().MarkHidden("file-id")
|
||||
|
||||
RegisterCrossProductAliases(cmd)
|
||||
|
||||
cli.AnnotateRuntimeRequiredFlags(cmd, "node")
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "markdown",
|
||||
Name: "diff",
|
||||
CanonicalPath: "markdown.diff",
|
||||
CLIPath: "markdown diff",
|
||||
PrimaryCLIPath: "markdown diff",
|
||||
},
|
||||
Description: "比较远端 Markdown 文件的两个版本,或远端版本与本地文件,生成 unified diff",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Local diff workflow: download remote version(s) and/or read a local file, then compute unified diff client-side.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "比较 Markdown 文件版本或本地草稿差异",
|
||||
UseWhen: []string{"需要对比远端 .md 历史版本,或远端最新/历史版本与本地草稿的差异时"},
|
||||
AvoidWhen: []string{"在线文档/表格差异请用 doc/sheet;普通二进制文件版本请用 drive list --versions / drive download --version"},
|
||||
Examples: []string{"dws markdown diff --node <nodeId> --version 3 --version2 5"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
|
||||
{Name: "version", Property: "version", InterfaceType: "integer"},
|
||||
{Name: "version2", Property: "version2", InterfaceType: "integer"},
|
||||
{Name: "file", Property: "file"},
|
||||
{Name: "context", Property: "context", InterfaceType: "integer"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// fetchFileInfo 通过 get_file_info 获取扩展名(markdown diff 类型守卫用)。
|
||||
// 探测失败返回零值,由调用方决定是否阻断。
|
||||
type markdownFileInfo struct {
|
||||
name string
|
||||
extension string
|
||||
}
|
||||
|
||||
func fetchFileInfo(ctx context.Context, nodeID string) (info markdownFileInfo) {
|
||||
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "get_file_info", map[string]any{"fileId": nodeID})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &resp); err != nil {
|
||||
return
|
||||
}
|
||||
data := resp
|
||||
if result, ok := resp["result"].(map[string]any); ok {
|
||||
data = result
|
||||
}
|
||||
if name, ok := data["name"].(string); ok {
|
||||
info.name = name
|
||||
}
|
||||
if ext, ok := data["extension"].(string); ok {
|
||||
info.extension = strings.ToLower(strings.TrimPrefix(ext, "."))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func describeDingTalkDocType(ext string) string {
|
||||
switch strings.ToLower(ext) {
|
||||
case "adoc":
|
||||
return "文档"
|
||||
case "axls":
|
||||
return "表格"
|
||||
case "amind":
|
||||
return "脑图"
|
||||
case "adraw":
|
||||
return "画图"
|
||||
default:
|
||||
return "文件"
|
||||
}
|
||||
}
|
||||
+260
-10
@@ -3,6 +3,7 @@ package helpers
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -18,7 +19,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "minutes",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "查询和维护钉钉听记的转写、摘要、待办、权限、录音、标签、说话人总结及文件上传会话。",
|
||||
AgentSummary: "查询和维护钉钉听记的转写、摘要、待办、权限、录音、标签、说话人总结、语音备忘及文件上传会话。",
|
||||
UseWhen: []string{
|
||||
"用户要查找、读取、编辑或管理钉钉听记及其录音、转写、摘要和衍生内容。",
|
||||
},
|
||||
@@ -1282,7 +1283,10 @@ func newMinutesCommand() *cobra.Command {
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "添加听记个人热词,用于优化语音识别中专有名词、人名等的识别准确率。",
|
||||
UseWhen: []string{"需要添加听记个人热词以优化专有名词/人名识别时(单词不超过约10汉字)"},
|
||||
AvoidWhen: []string{"要查看已有热词时改用 dws minutes hot-word list"},
|
||||
AvoidWhen: []string{
|
||||
"要查看已有热词时改用 dws minutes hot-word list",
|
||||
"要删除热词时改用 dws minutes hot-word delete",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws minutes hot-word add --words \"钉钉\"",
|
||||
"dws minutes hot-word add --words \"OKR,钉钉,Copilot\"",
|
||||
@@ -1328,13 +1332,70 @@ func newMinutesCommand() *cobra.Command {
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询当前用户配置的所有听记热词列表。",
|
||||
UseWhen: []string{"需要查看当前用户已配置的听记个人热词列表时"},
|
||||
AvoidWhen: []string{"要添加热词时改用 hot-word add"},
|
||||
Examples: []string{"dws minutes hot-word list"},
|
||||
AvoidWhen: []string{
|
||||
"要添加热词时改用 hot-word add",
|
||||
"要删除热词时改用 hot-word delete",
|
||||
},
|
||||
Examples: []string{"dws minutes hot-word list"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
hotWordCmd.AddCommand(hotWordAddCmd, hotWordListCmd)
|
||||
hotWordDeleteCmd := &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "批量删除个人热词",
|
||||
Long: `批量删除听记个人热词。
|
||||
支持一次删除多个热词(逗号分隔)。删除后对应热词不再参与后续语音识别优化。`,
|
||||
Example: ` dws minutes hot-word delete --words "钉钉"
|
||||
dws minutes hot-word delete --words "OKR,钉钉,Copilot"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "words"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("delete_personal_hotword", map[string]any{
|
||||
"hotWordList": parseCSVValues(mustGetFlag(cmd, "words")),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(hotWordDeleteCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "minutes",
|
||||
Name: "delete_personal_hotword",
|
||||
CanonicalPath: "minutes.delete_personal_hotword",
|
||||
CLIPath: "minutes hot-word delete",
|
||||
PrimaryCLIPath: "minutes hot-word delete",
|
||||
},
|
||||
Description: "批量删除听记个人热词。删除后对应热词不再参与后续语音识别优化。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "delete_personal_hotword"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "批量删除听记个人热词,清理误加或过时热词。",
|
||||
UseWhen: []string{"用户要删除/移除已配置的听记个人热词时"},
|
||||
AvoidWhen: []string{
|
||||
"要添加热词时改用 hot-word add",
|
||||
"不确定现有热词时先用 hot-word list",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws minutes hot-word delete --words \"钉钉\"",
|
||||
"dws minutes hot-word delete --words \"OKR,钉钉,Copilot\"",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "words", Property: "hotWordList"},
|
||||
},
|
||||
},
|
||||
})
|
||||
hotWordDeleteCmd.Flags().String("words", "", "要删除的热词,多个用逗号分隔 (必填)")
|
||||
|
||||
hotWordCmd.AddCommand(hotWordAddCmd, hotWordListCmd, hotWordDeleteCmd)
|
||||
|
||||
// ── replace-text 命令 ───────────────────────────────────────
|
||||
replaceTextCmd := &cobra.Command{
|
||||
@@ -1618,8 +1679,8 @@ func newMinutesCommand() *cobra.Command {
|
||||
uploadCmd.AddCommand(uploadCreateCmd, uploadCompleteCmd, uploadCancelCmd)
|
||||
|
||||
// ── permission 子组 ─────────────────────────────────────────
|
||||
// 听记成员权限管理:批量添加/移除成员及其权限。
|
||||
// 对应 MCP 工具 add_member_permission / remove_member_permission。
|
||||
// 听记成员权限管理:批量添加/移除成员及其权限、为当前用户申请权限。
|
||||
// 对应 MCP 工具 add_member_permission / remove_member_permission / apply_minutes_permission。
|
||||
permissionCmd := &cobra.Command{Use: "permission", Short: "听记成员权限管理", RunE: groupRunE}
|
||||
|
||||
// permission add — 对应 MCP 工具 add_member_permission
|
||||
@@ -1709,6 +1770,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
UseWhen: []string{"已知听记 uuid,需要批量给听记增加成员并设置权限(policy 0管理员/1所有者/2可编辑/3可查看下载/4仅查看)时"},
|
||||
AvoidWhen: []string{
|
||||
"要移除成员权限时改用 dws minutes permission remove",
|
||||
"当前用户自己申请访问权限时改用 dws minutes permission apply",
|
||||
"成员、权限策略或听记 id 未确认时不要添加",
|
||||
},
|
||||
Examples: []string{
|
||||
@@ -1784,6 +1846,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
UseWhen: []string{"用户明确要求批量移除听记成员权限,使其失去访问时"},
|
||||
AvoidWhen: []string{
|
||||
"要添加权限时改用 permission add",
|
||||
"当前用户自己申请访问权限时改用 permission apply",
|
||||
"成员或听记 id 未确认时不要移除",
|
||||
},
|
||||
Examples: []string{
|
||||
@@ -1803,7 +1866,85 @@ func newMinutesCommand() *cobra.Command {
|
||||
_ = permissionRemoveCmd.Flags().MarkHidden("task-uuids")
|
||||
permissionRemoveCmd.Flags().String("member-uids", "", "成员钉钉 UID 列表,逗号分隔 (必填)")
|
||||
|
||||
permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd)
|
||||
// permission apply — 对应 MCP 工具 apply_minutes_permission
|
||||
permissionApplyCmd := &cobra.Command{
|
||||
Use: "apply",
|
||||
Short: "为当前用户申请听记权限",
|
||||
Long: `为当前登录用户申请指定听记的权限。
|
||||
适用于用户无权限访问某听记(如打开分享链接提示无权限)时,主动向听记所有者发起权限申请。
|
||||
|
||||
权限类型 (--policy):
|
||||
2 = 可编辑
|
||||
3 = 可查看/下载
|
||||
4 = 仅查看`,
|
||||
Example: ` dws minutes permission apply --id <taskUuid> --policy 4
|
||||
dws minutes permission apply --id <taskUuid> --policy 2`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlagWithAliases(cmd, "id", "url", "task-uuid", "uuid"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "policy"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
policyID, err := strconv.ParseInt(mustGetFlag(cmd, "policy"), 10, 64)
|
||||
if err != nil || policyID < 2 || policyID > 4 {
|
||||
return fmt.Errorf("flag --policy must be an integer between 2 and 4 (2=可编辑, 3=可查看/下载, 4=仅查看)")
|
||||
}
|
||||
|
||||
return callMCPTool("apply_minutes_permission", map[string]any{
|
||||
"taskUuid": flagOrFallback(cmd, "id", "url", "task-uuid", "uuid"),
|
||||
"policyId": float64(policyID),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(permissionApplyCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "minutes",
|
||||
Name: "apply_minutes_permission",
|
||||
CanonicalPath: "minutes.apply_minutes_permission",
|
||||
CLIPath: "minutes permission apply",
|
||||
PrimaryCLIPath: "minutes permission apply",
|
||||
},
|
||||
Description: "为当前登录用户申请指定听记的权限。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "apply_minutes_permission"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "为当前登录用户申请指定听记的访问权限(可编辑/可查看下载/仅查看)。",
|
||||
UseWhen: []string{"当前用户对某听记无权限,需要向所有者申请访问(policy 2/3/4)时"},
|
||||
AvoidWhen: []string{
|
||||
"所有者批量给他人加权限时改用 permission add",
|
||||
"要移除他人权限时改用 permission remove",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws minutes permission apply --id <taskUuid> --policy 4",
|
||||
"dws minutes permission apply --id <taskUuid> --policy 2",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "id", Property: "taskUuid"},
|
||||
{Name: "policy", Property: "policyId"},
|
||||
},
|
||||
},
|
||||
})
|
||||
permissionApplyCmd.Flags().String("id", "", "听记 taskUuid (必填)")
|
||||
permissionApplyCmd.Flags().String("url", "", "--id 的别名")
|
||||
_ = permissionApplyCmd.Flags().MarkHidden("url")
|
||||
permissionApplyCmd.Flags().String("task-uuid", "", "--id 的别名 (兼容 OpenAPI 字段名)")
|
||||
_ = permissionApplyCmd.Flags().MarkHidden("task-uuid")
|
||||
permissionApplyCmd.Flags().String("uuid", "", "--id 的别名")
|
||||
_ = permissionApplyCmd.Flags().MarkHidden("uuid")
|
||||
permissionApplyCmd.Flags().String("policy", "", "权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填)")
|
||||
|
||||
permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd, permissionApplyCmd)
|
||||
|
||||
// ── tag 子组 ────────────────────────────────────────────────
|
||||
// 听记标签/分组管理:查询用户标签列表、按标签查询听记。
|
||||
@@ -1928,13 +2069,122 @@ func newMinutesCommand() *cobra.Command {
|
||||
|
||||
tagCmd.AddCommand(tagListCmd, tagQueryCmd)
|
||||
|
||||
// ── audio-memo 子组 ────────────────────────────
|
||||
// 语音备忘查询:对应 MCP 工具 list_audio_memos。
|
||||
// 用户身份由网关按登录态注入 uid,agent/CLI 无需传入。
|
||||
// 返回值 items[].audioUrl 为带签名的音频 URL(含 &),因此使用
|
||||
// callMCPToolUnescaped 输出,避免 & 被转义为 \u0026(与 upload 一致)。
|
||||
audioMemoCmd := &cobra.Command{Use: "audio-memo", Short: "语音备忘查询", RunE: groupRunE}
|
||||
|
||||
audioMemoListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "查询语音备忘列表",
|
||||
Long: `查询当前用户的语音备忘列表,支持分页和时间范围筛选。
|
||||
分页:首页 --cursor 留空(或 0),后续把上一页返回的 nextCursor 回填到 --cursor。
|
||||
时间范围:--start/--end 为 ISO-8601(可选),不传默认查询近一年。`,
|
||||
Example: ` dws minutes audio-memo list
|
||||
dws minutes audio-memo list --max 500
|
||||
dws minutes audio-memo list --start "2026-01-01T00:00:00+08:00" --end "2026-07-21T23:59:59+08:00"
|
||||
dws minutes audio-memo list --cursor 1740000000000`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
toolArgs := map[string]any{}
|
||||
|
||||
max, _ := cmd.Flags().GetFloat64("max")
|
||||
if max <= 0 || max > 1000 {
|
||||
return fmt.Errorf("flag --max must be between 1 and 1000")
|
||||
}
|
||||
toolArgs["pageSize"] = max
|
||||
|
||||
if cmd.Flags().Changed("cursor") {
|
||||
cursor, _ := cmd.Flags().GetInt64("cursor")
|
||||
if cursor < 0 {
|
||||
return fmt.Errorf("flag --cursor must be >= 0")
|
||||
}
|
||||
toolArgs["cursor"] = float64(cursor)
|
||||
}
|
||||
|
||||
startStr, _ := cmd.Flags().GetString("start")
|
||||
endStr, _ := cmd.Flags().GetString("end")
|
||||
// China Standard Time has no DST; FixedZone avoids zoneinfo nil-fallback branches.
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
var startMs, endMs int64
|
||||
if startStr != "" {
|
||||
var err error
|
||||
startMs, err = parseISOTimeToMillis("start", startStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["startTime"] = time.UnixMilli(startMs).In(loc).Format(time.RFC3339)
|
||||
}
|
||||
if endStr != "" {
|
||||
var err error
|
||||
endMs, err = parseISOTimeToMillis("end", endStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["endTime"] = time.UnixMilli(endMs).In(loc).Format(time.RFC3339)
|
||||
}
|
||||
if startStr != "" && endStr != "" {
|
||||
if err := validateTimeRange(startMs, endMs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return callMCPToolUnescaped("list_audio_memos", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(audioMemoListCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "minutes",
|
||||
Name: "list_audio_memos",
|
||||
CanonicalPath: "minutes.list_audio_memos",
|
||||
CLIPath: "minutes audio-memo list",
|
||||
PrimaryCLIPath: "minutes audio-memo list",
|
||||
},
|
||||
Description: "查询当前用户的语音备忘列表,支持分页和时间范围筛选。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "list_audio_memos"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询当前用户的语音备忘列表(独立于听记列表与 get audio)。",
|
||||
UseWhen: []string{"用户要查看语音备忘/录音备忘列表时(可带时间范围或翻页)"},
|
||||
AvoidWhen: []string{
|
||||
"要查听记列表改用 minutes list",
|
||||
"只要某篇听记的音频地址改用 minutes get audio",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws minutes audio-memo list",
|
||||
"dws minutes audio-memo list --start \"2026-01-01T00:00:00+08:00\" --end \"2026-07-21T23:59:59+08:00\"",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "max", Property: "pageSize"},
|
||||
{Name: "cursor", Property: "cursor"},
|
||||
{Name: "start", Property: "startTime"},
|
||||
{Name: "end", Property: "endTime"},
|
||||
},
|
||||
},
|
||||
})
|
||||
audioMemoListCmd.Flags().Float64("max", 200, "每页数据条数 (默认 200,上限 1000)")
|
||||
audioMemoListCmd.Flags().Int64("cursor", 0, "翻页游标,回填上一页返回的 nextCursor (首页留空)")
|
||||
audioMemoListCmd.Flags().String("start", "", "开始时间 ISO-8601 (可选,默认近一年)")
|
||||
audioMemoListCmd.Flags().String("end", "", "结束时间 ISO-8601 (可选)")
|
||||
audioMemoCmd.AddCommand(audioMemoListCmd)
|
||||
|
||||
minutesCmd := &cobra.Command{
|
||||
Use: "minutes",
|
||||
Short: "AI 听记 / 会议纪要",
|
||||
Long: `管理钉钉AI听记:查询列表、获取详情、摘要、转写、待办、关键字、音频地址、思维导图、发言人管理、文件上传、成员权限管理,以及修改标题和纪要内容。`,
|
||||
Long: `管理钉钉AI听记:查询列表、获取详情、摘要、转写、待办、关键字、音频地址、思维导图、发言人管理、文件上传、成员权限管理、语音备忘查询,以及修改标题和纪要内容。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
minutesCmd.AddCommand(minutesListCmd, minutesGetCmd, minutesUpdateCmd, minutesRecordCmd, mindGraphCmd, speakerCmd, hotWordCmd, replaceTextCmd, uploadCmd, permissionCmd, tagCmd)
|
||||
minutesCmd.AddCommand(minutesListCmd, minutesGetCmd, minutesUpdateCmd, minutesRecordCmd, mindGraphCmd, speakerCmd, hotWordCmd, replaceTextCmd, audioMemoCmd, uploadCmd, permissionCmd, tagCmd)
|
||||
return minutesCmd
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user