Compare commits
124
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8802dcf4dc | ||
|
|
28bb377e66 | ||
|
|
a0fe8d70c2 | ||
|
|
13d0ae66a6 | ||
|
|
4770e5a8e6 | ||
|
|
0bcc2f27c6 | ||
|
|
f3b0fcdc4c | ||
|
|
f10d552fd7 | ||
|
|
8b8756b00e | ||
|
|
ec59cf8065 | ||
|
|
2ffddbd5a0 | ||
|
|
0b3abfad4b | ||
|
|
1d3c56f9fa | ||
|
|
502317db68 | ||
|
|
66516755e6 | ||
|
|
6e3f528f48 | ||
|
|
d70e6b85b6 | ||
|
|
5e71a4ea52 | ||
|
|
0793238d47 | ||
|
|
c8f83533fb | ||
|
|
08e80bcb89 | ||
|
|
39d9a65616 | ||
|
|
a325ca80d8 | ||
|
|
75f08da197 | ||
|
|
b246b7d83b | ||
|
|
f4cb8aa282 | ||
|
|
c15480c452 | ||
|
|
c0b013afa9 | ||
|
|
34d33e0492 | ||
|
|
be15dd05df | ||
|
|
3578e4019b | ||
|
|
ede8e3c555 | ||
|
|
7a1b85ab62 | ||
|
|
490818dfe9 | ||
|
|
1ab8f113a5 | ||
|
|
4f4ea43549 | ||
|
|
4fd67c52dc | ||
|
|
a3b06befbc | ||
|
|
290f39ecb8 | ||
|
|
7fbe7593c8 | ||
|
|
9fb61f8e99 | ||
|
|
2287abe644 | ||
|
|
b3991d473e | ||
|
|
32bd2118af | ||
|
|
f290a2101e | ||
|
|
c8490da527 | ||
|
|
f26806bc55 | ||
|
|
c53e1f465d | ||
|
|
176a556355 | ||
|
|
8609963ef8 | ||
|
|
e625da4c27 | ||
|
|
c68603fea0 | ||
|
|
f57d9a51f4 | ||
|
|
f118a369b0 | ||
|
|
9dc7f64b87 | ||
|
|
b334794168 | ||
|
|
5aaf22782c | ||
|
|
089c5491ec | ||
|
|
7186a69b78 | ||
|
|
9c202c7eae | ||
|
|
2969fb3c21 | ||
|
|
149a2481f4 | ||
|
|
4da2f382ec | ||
|
|
a3a96a6bd4 | ||
|
|
548809f72e | ||
|
|
7568d05434 | ||
|
|
6aaa15be3c | ||
|
|
ac8e41aa5f | ||
|
|
57bc1bcea8 | ||
|
|
f1c5a887b6 | ||
|
|
7c76e4fc03 | ||
|
|
606f712a52 | ||
|
|
dac4f6c029 | ||
|
|
b7a6abb780 | ||
|
|
7dab8df861 | ||
|
|
288212748c | ||
|
|
ef2c3ac163 | ||
|
|
b057c89a70 | ||
|
|
6ddfa59a28 | ||
|
|
721a40b05e | ||
|
|
d04511b8a6 | ||
|
|
f913c95ed1 | ||
|
|
effde76227 | ||
|
|
43f0813acd | ||
|
|
33d8cd7e36 | ||
|
|
cfbe5b9b0d | ||
|
|
6e85983ad4 | ||
|
|
edbb175d4e | ||
|
|
b7bc0acb14 | ||
|
|
48e5d603bc | ||
|
|
12ff9d6138 | ||
|
|
ea18feb0a8 | ||
|
|
c5e3c2ec56 | ||
|
|
92c80f81f9 | ||
|
|
70ed89c6bf | ||
|
|
07b14aa72a | ||
|
|
d245ea4c84 | ||
|
|
33623d09d9 | ||
|
|
b20055a0b5 | ||
|
|
caf81b7984 | ||
|
|
5742239c74 | ||
|
|
3dce49020e | ||
|
|
4ec2635830 | ||
|
|
f319906f29 | ||
|
|
fac92c252e | ||
|
|
8db297fe4b | ||
|
|
dc2aec7696 | ||
|
|
404af112b7 | ||
|
|
5425d1565f | ||
|
|
1a58e3c3e6 | ||
|
|
9d8b338833 | ||
|
|
ea92e0212b | ||
|
|
857279e076 | ||
|
|
b7aa6bddf5 | ||
|
|
64ad5f22b0 | ||
|
|
c68207ad4b | ||
|
|
4f57967c56 | ||
|
|
b49bc0ed14 | ||
|
|
3f2fc2e5f0 | ||
|
|
ef27877628 | ||
|
|
6a2e9dd10e | ||
|
|
42b5004bf8 | ||
|
|
3a3cf00072 | ||
|
|
5abef59c7c |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **AI Table parameter aliases** — accepts reviewed equivalent spellings for Base, table, workflow, search, pagination, and description parameters while keeping role-changing or semantically ambiguous inputs blocked.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **AI Table server-side statistics** — adds `dws aitable record stats` for
|
||||
ungrouped record-set metrics through `query_records_stats`, plus `dws aitable
|
||||
record group-stats` for grouped, distinct, and advanced aggregation through
|
||||
`query_stats`; both commands validate their JSON aggregation contracts before
|
||||
dispatch.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Calendar and To-do Shortcut workflows** — aligns 47 public task-oriented
|
||||
entries with lark-cli where the DingTalk backend supports equivalent
|
||||
semantics, rejects malformed or missing collections instead of returning
|
||||
false empty success, preserves truthful pagination, and requires stable
|
||||
identifiers plus read-back or explicit terminal receipts for writes. Adds
|
||||
deterministic contract coverage, a PII-safe live E2E runner, and a sanitized
|
||||
capability review with documented platform boundaries.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Doc and Sheet comment lifecycle commands** — adds `comment batch-query`,
|
||||
`comment resolve`, `comment restore`, and the lightweight
|
||||
`comment react-reply` to both `dws doc` and `dws sheet`. The two domains share
|
||||
the same `doc-comment` MCP capabilities; batch queries preserve input order
|
||||
for repeated `topicId:commentKey` references, while reaction replies require
|
||||
DingTalk reaction names such as `憨笑` or `鼓掌` rather than raw Unicode emoji.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Wait framework capability** — adds the reviewed `Contract.Wait`
|
||||
declaration (`contract.WaitSpec`) with three execution modes: `poll`
|
||||
(cadence-poll the leaf's `WaitPoll` hook), `event` (consume the leaf's
|
||||
`WaitEvents` push stream, correlate events to the accepted resource via
|
||||
`match_field`/`resource_query`, apply the same terminal map), and `auto`
|
||||
(event first, fall back to polling when the stream ends or the
|
||||
subscription fails — one deadline spans both phases). Declared commands
|
||||
must use the `ResultInvoke` dispatcher; mode and hooks are paired at
|
||||
construction (poll↔WaitPoll, event↔WaitEvents, auto↔both; surplus hooks
|
||||
are rejected too). Declared commands register `--wait` /
|
||||
`--wait-timeout` (framework-owned flags that never enter MCP toolArgs);
|
||||
undeclared commands reject the flags as unknown. The wait phase closes
|
||||
the unified envelope exactly once: terminal success → `success`,
|
||||
terminal failure → `failure` with new wire-stable `error.type: "wait"`
|
||||
(exit code 8), timeout → `pending` with `meta.operation.timed_out: true`
|
||||
and the last observed state (exit 0). Deadline exhaustion during a poll,
|
||||
during event consumption, or between polls always closes as timed-out
|
||||
pending, never as a poll/stream failure; a correlated event with an
|
||||
unknown status fails closed exactly like a poll. The capability is
|
||||
projected into the Schema catalog (`wait` key) alongside `dry_run`. No
|
||||
business command declares it yet; approval/export/batch adoption lands
|
||||
separately.
|
||||
@@ -6,6 +6,61 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.59-beta.3] - 2026-08-19
|
||||
|
||||
### Added
|
||||
|
||||
- **Robot group reference replies** (#928) — `chat message send-by-bot` supports paired `--reply` and `--ref-sender` flags for Markdown replies that quote an existing group message.
|
||||
|
||||
- **AI Table server-side statistics** — adds `dws aitable record stats` for
|
||||
ungrouped record-set metrics through `query_records_stats`, plus `dws aitable
|
||||
record group-stats` for grouped, distinct, and advanced aggregation through
|
||||
`query_stats`; both commands validate their JSON aggregation contracts before
|
||||
dispatch.
|
||||
|
||||
- **Calendar event share-info** (#980) — adds `dws calendar event share-info` to fetch a calendar event's share info (title, organizer, location, join info) for sharing with others; supports `--calendar-id` and `--language`.
|
||||
|
||||
- **Calendar and To-do Shortcut workflows** — aligns 47 public task-oriented
|
||||
entries with lark-cli where the DingTalk backend supports equivalent
|
||||
semantics, rejects malformed or missing collections instead of returning
|
||||
false empty success, preserves truthful pagination, and requires stable
|
||||
identifiers plus read-back or explicit terminal receipts for writes. Adds
|
||||
deterministic contract coverage, a PII-safe live E2E runner, and a sanitized
|
||||
capability review with documented platform boundaries.
|
||||
|
||||
- **Doc and Sheet comment lifecycle commands** — adds `comment batch-query`,
|
||||
`comment resolve`, `comment restore`, and the lightweight
|
||||
`comment react-reply` to both `dws doc` and `dws sheet`. The two domains share
|
||||
the same `doc-comment` MCP capabilities; batch queries preserve input order
|
||||
for repeated `topicId:commentKey` references, while reaction replies require
|
||||
DingTalk reaction names such as `憨笑` or `鼓掌` rather than raw Unicode emoji.
|
||||
|
||||
- **Sheet SourceRange dropdowns** — supports range-backed dropdowns across direct, cell, and batch write paths, with structured readback for valid and invalid references. Batch `set-dropdown` now rejects unsupported top-level `colors` / `source-colors`; Inline colors belong in `options[].color`, while SourceRange color writes remain unsupported.
|
||||
- **Sheet read completion metadata** — documents and preserves returned ranges, truncation reasons, and partial-read status for large range and CSV reads.
|
||||
|
||||
### Changed
|
||||
|
||||
- **AI Table parameter aliases** — accepts reviewed equivalent spellings for Base, table, workflow, search, pagination, and description parameters while keeping role-changing or semantically ambiguous inputs blocked.
|
||||
|
||||
- **Doc/drive description scope** — restates the `dingtalk-doc` description as document-entity-and-content operations with an explicit exclusion list, and narrows `dingtalk-drive` to file-level management of DingTalk documents, so first-round Agent selection separates content work from file management without changing CLI behavior.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Aitable pagination and Minutes unshare verification** (#1006) — keeps
|
||||
record queries on the service's 20-record page boundary so multi-page reads
|
||||
and mutation readbacks no longer report false retryable failures, preserves
|
||||
`totalCount` when supplied, validates `--dry-run` plans before transport,
|
||||
follows active deletion readback continuations before proving absence, and
|
||||
rejects Minutes unshare success until the listening note exists and the
|
||||
service acknowledges the exact task and member targets.
|
||||
|
||||
- **Document write verification** (#960) — avoids false partial-success results when normalized Markdown, paginated blocks, inline images, or version reverts are confirmed by server readback. Document reverts and media inserts now require explicit readback evidence and report partial success when the server cannot prove the requested result.
|
||||
|
||||
- **Chat sender identity guards** — preserves unverified mixed sender inputs after exact message `senderId` matches and aligns `--sender-query` Skill guidance with fail-closed Runtime behavior.
|
||||
|
||||
- **Windows event bus lifecycle** — start event consumers without unsupported inherited file descriptors, stop buses through local IPC with a termination fallback, and preserve subscription cleanup when startup fails.
|
||||
|
||||
|
||||
## [1.0.59-beta.2] - 2026-08-17
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.59-beta.2"
|
||||
version "1.0.59-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.59-beta.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "7f11218d3222f3e93c3b1e94b3a004c061eb0a297b206447ea95fe6b2b1ec674"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-arm64.tar.gz"
|
||||
sha256 "9c99adcefd9104368eb443f0a1b4af8e7aceaa1ffdd4462e486854c1692bb6ce"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "72f06a334cf29d23123639fabe13bf2951765066136e47ccc6453667c382f8c2"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-amd64.tar.gz"
|
||||
sha256 "f5cc8efb1f982d68ae549190fd683292359c2ab542b532fa52bb35e6b5c049af"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "3edbfabb7718b53914a2d9efe7b1152e9ebd70765ff0b6f19ad3125e4a2458ed"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-arm64.tar.gz"
|
||||
sha256 "7a4efd04b417ce8013b1e431274b396179958da244164f59974358ba327ff093"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "e1c610070a9c1b3763656cbd53c818290f199097adc07cb9fe629ed765c5e1e0"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-amd64.tar.gz"
|
||||
sha256 "90181e8f2e9010c1943a5773c3d45d7d3ac85d6bc93e18a9aaa7c69909e553d7"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-skills.zip"
|
||||
sha256 "aa2854651eaa2c857b526aaec73fd1358d31b11999fe7fd3e99e5060b2522a1f"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-skills.zip"
|
||||
sha256 "e7028914a4a826af9b18ed4922d68fa8f279473817fed4f305465bc8a7aad363"
|
||||
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 multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples tool-search-evaluation-harness generate-tool-search-comparison 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
|
||||
|
||||
@@ -37,8 +37,6 @@ help:
|
||||
@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"
|
||||
@printf " make tool-search-evaluation-harness - Verify Go retrieval, trust metrics, frozen fixtures, and build-time report generation\n"
|
||||
@printf " make generate-tool-search-comparison - Generate the declaration-assembled comparison under .worktrees/policy-tmp\n"
|
||||
@printf " make generate-schema - Refresh param_aliases + verify Schema assembly determinism\n"
|
||||
@printf " make generate-schema-catalog - Optional assembled Catalog dump under artifacts/ (not a delivery step)\n"
|
||||
@printf " make package - Build all release artifacts locally\n"
|
||||
@@ -86,7 +84,7 @@ fmt:
|
||||
$(GO_SOURCE_LIST) > "$$go_files"; \
|
||||
xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -w -- "$$@"; fi' sh < "$$go_files"
|
||||
|
||||
policy: test-auth-legacy-compat tool-search-evaluation-harness skill-mono-multi-content
|
||||
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
|
||||
@@ -163,25 +161,6 @@ mock-mcp-smoke:
|
||||
test-schema-agent-examples:
|
||||
DWS_AGENT_EXAMPLES_DRY_RUN=1 $(GO) test -v -count=1 ./internal/app -run '^TestAgentExamplesDryRun$$'
|
||||
|
||||
tool-search-evaluation-harness:
|
||||
python3 -m unittest \
|
||||
scripts/dev/eval_tool_search_ranking_test.py \
|
||||
scripts/dev/verify_tool_search_eval_manifest_test.py
|
||||
@mkdir -p "$(DWS_POLICY_TMPDIR)"
|
||||
$(GO) run ./internal/generator/cmd_schema_catalog \
|
||||
-root . \
|
||||
-output "$(DWS_POLICY_TMPDIR)/tool-search-schema-catalog" \
|
||||
-meta-index "$(DWS_POLICY_TMPDIR)/tool-search-schema-meta-index.gob"
|
||||
python3 scripts/dev/verify_tool_search_eval_manifest.py \
|
||||
--catalog-dir "$(DWS_POLICY_TMPDIR)/tool-search-schema-catalog"
|
||||
$(GO) test ./internal/cli -run '^TestToolSearch|^TestSchemaSearch|^TestDefaultToolSearch|^TestAggregateToolSearch|^TestScoreToolSearch' -count=1
|
||||
$(GO) test ./internal/app -run '^TestToolSearchDelivery|^TestSchemaSearchInspectCatalogVersionContract$$' -count=1
|
||||
$(GO) run ./internal/generator/cmd_tool_search_comparison -output "$(DWS_POLICY_TMPDIR)/tool-search-comparison.json"
|
||||
|
||||
generate-tool-search-comparison:
|
||||
@mkdir -p "$(DWS_POLICY_TMPDIR)"
|
||||
$(GO) run ./internal/generator/cmd_tool_search_comparison -output "$(DWS_POLICY_TMPDIR)/tool-search-comparison.json"
|
||||
|
||||
# generate-schema refreshes param_aliases_generated.go and verifies that
|
||||
# ResolveSchemaBuild assembly is deterministic. Catalog is runtime-assembled
|
||||
# (声明即 Catalog); cmd_schema_catalog is not a committed delivery step.
|
||||
|
||||
@@ -482,7 +482,7 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
|
||||
<details>
|
||||
<summary><strong>Personal Event Subscription</strong> — real-time DingTalk messages for event-driven agents</summary>
|
||||
|
||||
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and six OA approval task/instance events.
|
||||
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and seven OA approval task/instance events.
|
||||
|
||||
The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`.
|
||||
|
||||
@@ -530,12 +530,13 @@ dws event consume user_im_group_disbanded --group <openConversationId> --flatten
|
||||
dws event +listen-im --kind sender --user <userId> \
|
||||
--events message,read,recall -f ndjson
|
||||
|
||||
# Listen for all six public OA approval events in one process
|
||||
# Listen for all seven public OA approval events in one process
|
||||
dws event consume \
|
||||
user_oa_approval_task_created \
|
||||
user_oa_approval_task_finished \
|
||||
user_oa_approval_task_redirected \
|
||||
user_oa_approval_instance_started \
|
||||
user_oa_approval_instance_cc \
|
||||
user_oa_approval_instance_terminated \
|
||||
user_oa_approval_instance_finished \
|
||||
--flatten -f ndjson
|
||||
|
||||
+3
-2
@@ -476,7 +476,7 @@ multi setup 或 upgrade 后,DWS 会把官方 bundle 快照和统一所有权
|
||||
<details>
|
||||
<summary><strong>个人事件订阅</strong> — 实时接收钉钉消息,驱动事件触发的 Agent</summary>
|
||||
|
||||
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及六个 OA 审批任务/实例事件。
|
||||
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及七个 OA 审批任务/实例事件。
|
||||
|
||||
默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。
|
||||
|
||||
@@ -524,12 +524,13 @@ dws event consume user_im_group_disbanded --group <openConversationId> --flatten
|
||||
dws event +listen-im --kind sender --user <userId> \
|
||||
--events message,read,recall -f ndjson
|
||||
|
||||
# 一个进程监听全部六个公开 OA 审批事件
|
||||
# 一个进程监听全部七个公开 OA 审批事件
|
||||
dws event consume \
|
||||
user_oa_approval_task_created \
|
||||
user_oa_approval_task_finished \
|
||||
user_oa_approval_task_redirected \
|
||||
user_oa_approval_instance_started \
|
||||
user_oa_approval_instance_cc \
|
||||
user_oa_approval_instance_terminated \
|
||||
user_oa_approval_instance_finished \
|
||||
--flatten -f ndjson
|
||||
|
||||
@@ -41,7 +41,7 @@ PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁
|
||||
scripts/policy/interface-migrations/approved-flag-migrations-v1.json
|
||||
```
|
||||
|
||||
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。当前清单登记了 IM ID rename 的 `pending` 记录;`pending` 只记录已评审计划,候选与 merge-base 仍必须精确匹配 `before`,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。
|
||||
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
|
||||
|
||||
## 两阶段迁移与回执清理
|
||||
|
||||
|
||||
@@ -360,6 +360,7 @@ Definition(仅声明;不可编译)
|
||||
| | `idempotency` | 评审源(或未来 Contract) | reviewed metadata | 今日非框架声明;不得推断 |
|
||||
| | `effect_source` / provenance | 组装派生物 | resolver 写入 `FieldProvenance` | 派生,不手写 |
|
||||
| **DryRun** | `preview_kind`, `remote_reads` | 评审源 | `schema_dry_run_capabilities`(正能力声明) | 否;无条目 ≠ 推断「不支持」之外的假能力 |
|
||||
| **Wait** | `mode`(`poll`/`event`/`auto`), `poll_command`, `status_query`, `terminal`(状态→success/failure), `pending_values`, `event_key`/`match_field`/`resource_query`(event/auto), `default_timeout_secs` | **声明**(`ContractDecl.Wait` 正能力声明,且必须搭配 ResultInvoke dispatcher + 按模式的 hook:poll↔`WaitPoll`、event↔`WaitEvents`、auto↔两者,构造期配对校验,多余 hook 同样拒绝) | 声明后注册 `--wait`/`--wait-timeout`(框架 flag,不进 toolArgs);Schema 投影 `wait` 键;auto = 事件优先、流终止/订阅失败回退轮询,一个 deadline 覆盖两阶段并传入 `WaitPoll`/`WaitEvents`(及 `Command().Context()`);仅 pending 初始结果进入等待,success/failure/partial 原样返回 | 否;未声明命令传 `--wait` = unknown flag。终态失败经统一信封 `error.type: "wait"`(rc=8),超时保持 pending + `meta.operation.timed_out`(rc=0);轮询间/轮询中/事件消费中超时一律按 pending 关闭 |
|
||||
| **Interface** | `interface_mode`, `interface_ref`, `availability`, `reason` | 评审源 | MCP meta + agent metadata 解析 | 否;与 CLI Identity 分离 |
|
||||
| **Selection** | `agent_summary`, `use_when`, `avoid_when`, `examples`, `prerequisites`, `tips`, `workflow_refs`, … | 声明(`ContractDecl.Selection` / `ProductDecl`) | `ContractDecl` / `ProductDecl`(`schema_hints/` 已退役) | 可声明;声明载荷**不得携带** `Reviewed`(旧路径专用),携带即组装报错 |
|
||||
| **FieldProvenance** | 各字段 winner / candidates | 组装派生物 | Schema 组装器 | 派生;须与 delivered value 一致 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"generated_at": "2026-08-14T13:50:36.324505",
|
||||
"count": 418,
|
||||
"generated_at": "2026-08-18T17:38:50.904696",
|
||||
"count": 436,
|
||||
"results": [
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -489,7 +489,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一 ID、筛选、关键词和分页查询,并投影稳定的 records/count/cursor 结果。",
|
||||
"semantic_delta": "统一 ID、筛选、关键词、字段投影和分页查询;fieldIds 只返回用户要求的列,并投影稳定的 records/count/cursor 结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -692,6 +692,16 @@
|
||||
"semantic_delta": "按当前父目录语义调整文件夹展示顺序。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+table-bootstrap",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在已有 Base 中声明式创建一张表和字段,字段自动分片,逐层读回验证并发布可恢复检查点。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
@@ -1056,144 +1066,274 @@
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+agenda",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 result.events 与分页证据,稳定投影 id 为 eventId;显式空数组才是空日程。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+attendee-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证参会人数组并投影显示名、响应状态和 self;真实后端不返回稳定参会人 userId,因此不伪造身份字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+book",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按参会人姓名解析后创建日程,并在多步骤路径提供失败回滚与详情读回。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+book-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证日历本数组并稳定投影 calendarId,缺失数组不再误报为空。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+book-search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按日历本名称搜索并严格区分零命中与协议错误。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+cancel-event",
|
||||
"risk": "high-risk-write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "删除前读取并展示目标,经高风险确认后取消指定日程。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+conflicts",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在指定范围分析重叠日程并输出冲突对,避免人工逐项比对。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "创建响应必须给出 eventId/id,且标题和起止时间通过详情读回后才成功。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+free",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名解析参与者后查询闲忙,省去手工 userId 解析。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+free-slots",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "从已有日程计算工作时段内空档,并显式输出满足时长的候选区间。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+freebusy",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一按用户或会议室 ID 查询指定范围闲忙,并声明至少一类目标约束。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "严格读取日程对象并把稳定 id 规范化为 eventId,拒绝空响应和对象漂移。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+invite",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名唯一解析用户后邀请到已有日程。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+my-free",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "以当前用户身份查询一个时间段是否空闲。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+next-event",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在未来时间窗内定位最近的下一场日程。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+reschedule",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "修改前读取目标日程,再更新完整起止时间。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+room-find",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按单个未来时间段严格查询可用会议室,公开真实 page/pageSize/hasMore 证据和后端筛选边界。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+room-groups",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格读取 result.groupList,避免会议室分组被响应 wrapper 漂移静默清空。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+room-search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按名称查询会议室但不声称检查时间可用性;缺失会议室数组直接失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+rsvp",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "把直白 RSVP 动作映射为 responseStatus,拒绝空 ack,并在写后读取日程终态。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+search-event",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "在服务端日程当前页按标题、描述和地点过滤,同时保留 hasMore/nextCursor,绝不把单页零命中当全局未找到。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+suggest-time",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名逐个唯一解析参与者,再查询大家共同可用的建议时间。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+suggestion",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "直接按 userId 查询建议时段,严格区分显式空 recommendEventTimes 与缺失或畸形响应。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+today",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "查询并整理今天的日程视图。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+tomorrow",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "查询并整理明天的日程视图。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "组合字段与参会人分阶段更新,明确非事务步骤并以详情和参会人列表读回验证。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+week",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "查询并按日期整理本周日程。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -3582,81 +3722,214 @@
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+assign",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "保留历史按姓名解析后创建并指派的新任务语义,避免同名破坏性变更。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+assign-multi",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "全部姓名唯一解析后才创建一次,并以单一统一结果输出稳定 taskId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+comment",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "新增评论必须取得稳定 commentId,并分页读回验证内容。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+complete",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "先读当前状态避免重复写,必要时完成后再读回核验。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "创建后要求稳定 taskId,并读取 todoDetailModel 验证标题。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+created-todos",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "遍历创建者角色的全部分页后投影。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+due-today",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按本地日历日构造服务端截止窗口并遍历全部分页。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格绑定 todoDetailModel.taskId,缺失或错绑均失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+get-my-tasks",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格识别 todoCards/hasMore/page/size;显式空数组才是空结果,并支持有界全量分页。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+get-related-tasks",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "遍历与我相关三种角色的全部分页并按 taskId 去重。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+list-attachment",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格区分显式空附件与缺失容器,并要求每项稳定附件 ID。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+list-comment",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格区分显式空评论与缺失容器,并要求每项稳定 commentId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+list-sub",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格校验子待办容器、对象元素和稳定 taskId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+overdue",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "遍历执行者角色的全部分页并按当前时间筛选逾期项。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+related-tasks",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留旧命令拼写,新的 Agent 路由统一使用 +get-related-tasks。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+remind",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "解析当前用户后创建给自己的待办;该命令不是独立提醒规则。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+reminder",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证提醒参数和 success=true 终端回执;上游不能读回规则,固定 verified=false。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+reopen",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "先读当前状态避免重复写,必要时重开后再读回核验。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+search",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "遍历全部 hasMore 分页并按标题匹配;达到页上限仍未耗尽时失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+todo-done",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "跨全部分页按标题唯一定位后完成任务。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "更新指定字段后读取详情逐字段核验。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
<tr><td><code>minutes +latest-minutes</code></td><td>列妙记→取最新一条详情</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>minutes +action-items</code></td><td>列妙记→取最新→取其待办</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>wiki +wiki-new-doc --space <名></code></td><td>按名搜知识空间→建文档(跨 doc server 路由)</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>doc +doc-append --doc --text</code></td><td>文档末尾追加文本</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>doc +doc-append --doc --content</code></td><td>文档末尾追加文本</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>doc +share-doc --to <名> --url</code></td><td>解析人→把文档链接私信 TA(跨服务)</td><td class="c ok">编译/挂载</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -56,13 +56,13 @@
|
||||
|
||||
| shortcut | 多步/智能逻辑 | 验证 |
|
||||
|----------|--------------|------|
|
||||
| `chat +dm --to <姓名> --text` | 搜人→解析唯一 userId→发单聊;多人消歧 | ✅ dry-run 真机 |
|
||||
| `chat +dm --to <姓名> --content` | 搜人→解析唯一 userId→发单聊;多人消歧 | ✅ dry-run 真机 |
|
||||
| `contact +lookup --name <姓名>` | 搜人→解析 userId→取完整资料 | ✅ **真机端到端** |
|
||||
| `todo +assign --to <姓名> --task` | 解析人→建待办并把 TA 设为执行人 | ✅ dry-run 真机 |
|
||||
| `chat +send-to-group --group <群名> --text` | 按群名搜群(search_groups)→消歧→发消息 | ✅ 编译/挂载 |
|
||||
| `chat +send-to-group --group <群名> --content` | 按群名搜群(search_groups)→消歧→发消息 | ✅ 编译/挂载 |
|
||||
| `calendar +book --title --start --end [--with <姓名CSV>]` | 建日程→按名加参与者→**失败回滚删日程**(对标 lark `calendar +create`) | ✅ dry-run 真机 |
|
||||
| `calendar +free --who <姓名> --start --end` | 解析人→查其时段忙闲 | ✅ **真机端到端**(解析 202397→查忙闲) |
|
||||
| `chat +broadcast --to <姓名CSV> --text` | 多名逐一解析→群发单聊,失败汇总不中断 | ✅ 编译/挂载 |
|
||||
| `chat +broadcast --to <姓名CSV> --content` | 多名逐一解析→群发单聊,失败汇总不中断 | ✅ 编译/挂载 |
|
||||
| `minutes +latest-minutes` | 列妙记→取最新一条详情 | ✅ 编译/挂载 |
|
||||
| `chat +group-members --group <群名>` | 按群名搜群→列群成员 | ✅ 编译/挂载 |
|
||||
| `contact +org --name <姓名>` | 解析人→取详情拿 deptId→查部门详情 | ✅ **真机端到端**(3 步:董鑫阳→模型算法/16人) |
|
||||
@@ -76,7 +76,7 @@
|
||||
| `todo +todo-done --task <关键词>` | 列我的待办→按标题匹配→标记完成 | ✅ 编译/挂载 |
|
||||
| `calendar +reschedule --event <id>` | 查日程详情→改时间(查→改机械多步) | ✅ 编译/挂载 |
|
||||
| `wiki +wiki-new-doc --space <名>` | 按名搜知识空间→在其下建文档(跨 doc server 路由) | ✅ 编译/挂载 |
|
||||
| `doc +doc-append --doc --text` | 文档末尾追加文本(update_document append 模式) | ✅ 编译/挂载 |
|
||||
| `doc +doc-append --doc --content` | 文档末尾追加文本(update_document append 模式) | ✅ 编译/挂载 |
|
||||
| `minutes +action-items` | 列妙记→取最新→取其待办事项 | ✅ 编译/挂载 |
|
||||
| `minutes +detail --id <taskUuid>` | 一条命令聚合听记 basic/summary/keywords/transcript/todos,partial-failure 容错 | ✅ 全量测试 |
|
||||
| `minutes +replace-batch --id --pair "原文=>替换"…` | 多组批量替换文字,去重校验+逐组结果聚合 | ✅ 全量测试 |
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# OA Attachment Download URL Output Design
|
||||
|
||||
## Goal
|
||||
|
||||
Keep the existing command and MCP request unchanged while making the returned
|
||||
OSS signed URL directly copyable from JSON output:
|
||||
|
||||
```text
|
||||
dws oa approval attachment download-url
|
||||
```
|
||||
|
||||
## Scope
|
||||
|
||||
Only `oa approval attachment download-url` changes. The other OA attachment
|
||||
commands and the global JSON formatter retain their current behavior.
|
||||
|
||||
## Design
|
||||
|
||||
The command continues to invoke MCP server `oa`, tool
|
||||
`get_attachment_download_url`, with the same arguments. Its leaf declaration
|
||||
provides a command-specific `Call` callback that invokes the existing MCP
|
||||
dispatcher with HTML escaping disabled when the selected output format is
|
||||
JSON. This preserves literal `&` separators in `result.downloadUri` instead of
|
||||
rendering them as `\u0026`.
|
||||
|
||||
For `raw`, `table`, and other non-JSON formats, the callback uses the existing
|
||||
escaped dispatcher behavior so their current rendering remains unchanged.
|
||||
|
||||
The change does not alter the URL, decode or re-sign it, download the file, or
|
||||
change global JSON serialization.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Authentication, MCP transport, gateway, PAT, and business errors continue
|
||||
through the existing dispatcher and retain their current classification and
|
||||
output behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
Add a `TestCrossPlatformCoverage*` regression test that executes the real Cobra
|
||||
leaf in explicit JSON mode with a fake MCP result containing a signed URL. It
|
||||
must verify:
|
||||
|
||||
- the request still targets `oa/get_attachment_download_url`;
|
||||
- the exact request arguments remain unchanged, including omission of the
|
||||
optional boolean when the flag was not supplied;
|
||||
- stdout contains literal `&OSSAccessKeyId=` and `&Signature=`;
|
||||
- stdout contains no `\u0026` escape.
|
||||
|
||||
The fake caller must report JSON format (or the command must be executed with
|
||||
`--format json`) so the test fails against the current escaped JSON path rather
|
||||
than accidentally exercising raw MCP text output.
|
||||
|
||||
Run the focused OA attachment tests, format modified Go files, and rebuild the
|
||||
CLI. No commit is created.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -403,7 +403,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "消费 OA、群生命周期或需要底层控制的个人事件流;Agent 通常使用 --flatten 输出 NDJSON",
|
||||
UseWhen: []string{
|
||||
"需要监听六个公开 OA 审批任务/实例 EventKey 中的一个或多个事件",
|
||||
"需要监听七个公开 OA 审批任务/实例 EventKey 中的一个或多个事件",
|
||||
"需要监听指定群的标题变更、成员进退群或群解散事件",
|
||||
"用户显式给出原始 EventKey、Filter DSL、subscribe_id,要求原始 transport envelope,或需要普通 IM facade 不提供的高级多事件控制",
|
||||
},
|
||||
|
||||
@@ -152,8 +152,8 @@ func TestCrossPlatformCoveragePersonalSubscriptionProtectionCoversAllPublicEvent
|
||||
}
|
||||
}
|
||||
|
||||
if publicCount != 22 {
|
||||
t.Fatalf("public personal events = %d, want 22 (16 IM + 6 OA)", publicCount)
|
||||
if publicCount != 23 {
|
||||
t.Fatalf("public personal events = %d, want 23 (16 IM + 7 OA)", publicCount)
|
||||
}
|
||||
for _, ruleType := range []string{"at", "all", "singleChat", "sender", "group"} {
|
||||
if !ruleTypes[ruleType] {
|
||||
|
||||
@@ -61,6 +61,13 @@ func TestPersonalOAEventListAndSchemaCommands(t *testing.T) {
|
||||
"process_code", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: personal.EventOAApprovalInstanceCC,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: personal.EventOAApprovalInstanceTerminated,
|
||||
properties: []string{
|
||||
@@ -161,6 +168,7 @@ func TestPersonalOAEventConsumeDryRunAndValidation(t *testing.T) {
|
||||
personal.EventOAApprovalTaskFinished,
|
||||
personal.EventOAApprovalTaskRedirected,
|
||||
personal.EventOAApprovalInstanceStarted,
|
||||
personal.EventOAApprovalInstanceCC,
|
||||
personal.EventOAApprovalInstanceTerminated,
|
||||
personal.EventOAApprovalInstanceFinished,
|
||||
}
|
||||
@@ -414,6 +422,7 @@ func TestPersonalOAMultiConsumeCreatesIndependentAllSubscriptionsOnSharedBus(t *
|
||||
personal.EventOAApprovalTaskFinished,
|
||||
personal.EventOAApprovalTaskRedirected,
|
||||
personal.EventOAApprovalInstanceStarted,
|
||||
personal.EventOAApprovalInstanceCC,
|
||||
personal.EventOAApprovalInstanceTerminated,
|
||||
personal.EventOAApprovalInstanceFinished,
|
||||
}
|
||||
|
||||
@@ -51,7 +51,38 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
|
||||
func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string {
|
||||
switch tool {
|
||||
case "list_calendar_events":
|
||||
return `{"result":{"events":[]}}`
|
||||
return `{"success":true,"result":{"events":[],"hasMore":false,"nextCursor":""}}`
|
||||
case "get_calendar_detail":
|
||||
return c.paramAliasCalendarDetailResponse()
|
||||
case "get_calendar_participants":
|
||||
return `{"success":true,"result":{"participants":[{"userId":"fixture-user","displayName":"Fixture User"},{"userId":"user-2","displayName":"User Two"}]}}`
|
||||
case "search_calendar":
|
||||
return `{"success":true,"result":{"calendars":[]}}`
|
||||
case "search_rooms":
|
||||
return `{"success":true,"result":{"rooms":[]}}`
|
||||
case "query_available_meeting_room":
|
||||
return `{"success":true,"result":{"rooms":[],"hasMore":false}}`
|
||||
case "list_meeting_room_groups":
|
||||
return `{"success":true,"result":{"groups":[]}}`
|
||||
case "query_busy_status":
|
||||
return `{"success":true,"result":[]}`
|
||||
case "list_suggested_event_times":
|
||||
return `{"success":true,"result":{"recommendEventTimes":[]}}`
|
||||
case "create_calendar_event":
|
||||
return `{"success":true,"result":{"eventId":"event-1"}}`
|
||||
case "update_calendar_event", "delete_calendar_event", "add_calendar_participant", "remove_calendar_participant":
|
||||
return `{"success":true}`
|
||||
case "respond":
|
||||
status := "accepted"
|
||||
if call := c.lastParamAliasCall(); call != nil {
|
||||
if value, ok := call.args["responseStatus"].(string); ok && value != "" {
|
||||
status = value
|
||||
}
|
||||
}
|
||||
encoded, _ := json.Marshal(map[string]any{"success": true, "result": map[string]any{"responseStatus": status}})
|
||||
return string(encoded)
|
||||
case "get_current_user_profile":
|
||||
return `{"success":true,"result":{"userId":"user-1","name":"Fixture Current User"}}`
|
||||
case "query_records":
|
||||
return `{"success":true,"status":"success","error":{},"data":{}}`
|
||||
case "search_mail_users":
|
||||
@@ -68,6 +99,8 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
|
||||
return `{"revertedToVersion":3}`
|
||||
case "search_doc_templates":
|
||||
return `{"result":[{"templateId":"fixture-template-id"}]}`
|
||||
case "list_workflows":
|
||||
return `{"workflows":[]}`
|
||||
case "create_document":
|
||||
return `{"nodeId":"fixture-node"}`
|
||||
case "list_files":
|
||||
@@ -125,6 +158,41 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
|
||||
}
|
||||
}
|
||||
|
||||
func (c *paramAliasCaptureCaller) lastParamAliasCall() *paramAliasToolCall {
|
||||
if len(c.calls) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &c.calls[len(c.calls)-1]
|
||||
}
|
||||
|
||||
func (c *paramAliasCaptureCaller) paramAliasCalendarDetailResponse() string {
|
||||
event := map[string]any{
|
||||
"eventId": "event-1",
|
||||
"summary": "Fixture Meeting",
|
||||
"description": "fixture description",
|
||||
"startDateTime": "2026-03-10T09:00:00+08:00",
|
||||
"endDateTime": "2026-03-10T10:00:00+08:00",
|
||||
}
|
||||
for _, call := range c.calls {
|
||||
switch call.tool {
|
||||
case "create_calendar_event", "update_calendar_event":
|
||||
for _, key := range []string{"eventId", "summary", "description", "startDateTime", "endDateTime", "timeZone", "location", "freeBusy"} {
|
||||
if value, ok := call.args[key]; ok {
|
||||
event[key] = value
|
||||
}
|
||||
}
|
||||
case "respond":
|
||||
if value, ok := call.args["responseStatus"]; ok {
|
||||
event["responseStatus"] = value
|
||||
}
|
||||
case "delete_calendar_event":
|
||||
event["status"] = "cancelled"
|
||||
}
|
||||
}
|
||||
encoded, _ := json.Marshal(map[string]any{"success": true, "result": event})
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func (*paramAliasCaptureCaller) Format() string { return "json" }
|
||||
func (*paramAliasCaptureCaller) DryRun() bool { return false }
|
||||
func (*paramAliasCaptureCaller) Fields() string { return "" }
|
||||
|
||||
@@ -5,6 +5,8 @@ package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -17,6 +19,8 @@ import (
|
||||
const (
|
||||
appFixtureCurrentDOpenID = "DAAAAAAAAAAAiE"
|
||||
appFixtureCurrentDOpenID2 = "DAQEBAQEBAQEiE"
|
||||
|
||||
paramAliasCalendarPayloadChildEnv = "DWS_TEST_CALENDAR_PARAM_ALIAS_PAYLOAD_CHILD"
|
||||
)
|
||||
|
||||
// paramAliasCompleteCommands is deliberately keyed by the exact reviewed
|
||||
@@ -27,15 +31,56 @@ const (
|
||||
// its spelling while holding every other input constant.
|
||||
var paramAliasCompleteCommands = map[string][]string{
|
||||
"aitable +base-search": {"aitable", "+base-search", "--query", "fixture"},
|
||||
"aitable +export-data": {"aitable", "+export-data", "--base-id", "base-1", "--scope", "all", "--format", "excel"},
|
||||
"aitable +field-get": {"aitable", "+field-get", "--base-id", "base-1", "--table-id", "table-1"},
|
||||
"aitable +find-record": {"aitable", "+find-record", "--base", "base-1", "--table", "table-1", "--query", "fixture"},
|
||||
"aitable +list-tables": {"aitable", "+list-tables", "--base", "base-1"},
|
||||
"aitable +record-query": {"aitable", "+record-query", "--base-id", "base-1", "--table-id", "table-1", "--query", "fixture"},
|
||||
"aitable +record-share-links": {"aitable", "+record-share-links", "--base", "base-1", "--table", "table-1", "--record-ids", "record-1"},
|
||||
"aitable +record-share-url": {"aitable", "+record-share-url", "--base-id", "base-1", "--table-id", "table-1", "--record-ids", "record-1"},
|
||||
"aitable +table-get": {"aitable", "+table-get", "--base-id", "base-1"},
|
||||
"aitable +workflow-list": {"aitable", "+workflow-list", "--base-id", "base-1", "--limit", "7"},
|
||||
"aitable attachment upload": {"aitable", "attachment", "upload", "--base-id", "base-1", "--file-name", "fixture.txt", "--size", "7"},
|
||||
"aitable base list": {"aitable", "base", "list", "--cursor", "cursor-1", "--limit", "7"},
|
||||
"aitable base update": {"aitable", "base", "update", "--base-id", "base-1", "--name", "Fixture Base", "--desc", "fixture description"},
|
||||
"aitable field search-options": {"aitable", "field", "search-options", "--base-id", "base-1", "--table-id", "table-1", "--field-id", "field-1", "--keyword", "fixture", "--limit", "7"},
|
||||
"aitable record query": {"aitable", "record", "query", "--base-id", "base-1", "--table-id", "table-1", "--limit", "7"},
|
||||
"aitable workflow get": {"aitable", "workflow", "get", "--base-id", "base-1", "--workflow-id", "workflow-1"},
|
||||
"aitable workflow history": {"aitable", "workflow", "history", "--base-id", "base-1", "--workflow-id", "workflow-1", "--after-time", "1000", "--before-time", "2000", "--page", "2", "--size", "25"},
|
||||
"aitable workflow run": {"aitable", "workflow", "run", "--base-id", "base-1", "--workflow-id", "workflow-1", "--table-id", "table-1", "--record-ids", "record-1", "--yes"},
|
||||
"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 +agenda": {"calendar", "+agenda", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
|
||||
"calendar +attendee-list": {"calendar", "+attendee-list", "--event", "event-1", "--calendar-id", "primary"},
|
||||
"calendar +book": {"calendar", "+book", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--with", "Fixture User", "--yes"},
|
||||
"calendar +book-search": {"calendar", "+book-search", "--query", "fixture"},
|
||||
"calendar +cancel-event": {"calendar", "+cancel-event", "--event", "event-1", "--yes"},
|
||||
"calendar +conflicts": {"calendar", "+conflicts", "--in-days", "1"},
|
||||
"calendar +create": {"calendar", "+create", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--desc", "fixture description", "--attendees", "user-1,user-2", "--rooms", "room-1,room-2", "--calendar-id", "primary", "--yes"},
|
||||
"calendar +free": {"calendar", "+free", "--who", "Fixture User", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
|
||||
"calendar +free-slots": {"calendar", "+free-slots", "--from", "9", "--to", "18", "--in-days", "1"},
|
||||
"calendar +freebusy": {"calendar", "+freebusy", "--users", "user-1,user-2", "--rooms", "room-1,room-2", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
|
||||
"calendar +get": {"calendar", "+get", "--event", "event-1", "--calendar-id", "primary"},
|
||||
"calendar +invite": {"calendar", "+invite", "--event", "event-1", "--with", "Fixture User", "--yes"},
|
||||
"calendar +my-free": {"calendar", "+my-free", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
|
||||
"calendar +reschedule": {"calendar", "+reschedule", "--event", "event-1", "--start", "2026-03-10T10:00:00+08:00", "--end", "2026-03-10T11:00:00+08:00", "--yes"},
|
||||
"calendar +room-find": {"calendar", "+room-find", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--room-name", "Fixture Room", "--group-id", "group-1", "--page", "1", "--limit", "7"},
|
||||
"calendar +room-groups": {"calendar", "+room-groups", "--page", "1", "--limit", "7"},
|
||||
"calendar +room-search": {"calendar", "+room-search", "--room-name", "Fixture Room"},
|
||||
"calendar +rsvp": {"calendar", "+rsvp", "--event", "event-1", "--status", "accept", "--calendar-id", "primary", "--yes"},
|
||||
"calendar +search-event": {"calendar", "+search-event", "--query", "fixture", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
|
||||
"calendar +suggest-time": {"calendar", "+suggest-time", "--with", "Fixture User", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
|
||||
"calendar +suggestion": {"calendar", "+suggestion", "--users", "user-1,user-2", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--timezone", "Asia/Shanghai"},
|
||||
"calendar +update": {"calendar", "+update", "--event", "event-1", "--title", "Fixture Updated Meeting", "--desc", "fixture updated description", "--start", "2026-03-10T10:00:00+08:00", "--end", "2026-03-10T11:00:00+08:00", "--add-attendees", "user-2", "--remove-attendees", "user-1", "--yes"},
|
||||
"calendar busy search": {"calendar", "busy", "search", "--users", "user-1,user-2", "--rooms", "room-1,room-2", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
|
||||
"calendar event create": {"calendar", "event", "create", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--remind-minutes", "15", "--timezone", "Asia/Shanghai", "--rooms", "room-1,room-2"},
|
||||
"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"},
|
||||
"calendar event respond": {"calendar", "event", "respond", "--id", "event-1", "--status", "accepted"},
|
||||
"calendar event suggest": {"calendar", "event", "suggest", "--users", "user-1,user-2", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--timezone", "Asia/Shanghai"},
|
||||
"calendar event update": {"calendar", "event", "update", "--id", "event-1", "--timezone", "Asia/Shanghai"},
|
||||
"calendar room add": {"calendar", "room", "add", "--event", "event-1", "--rooms", "room-1,room-2"},
|
||||
"calendar room delete": {"calendar", "room", "delete", "--event", "event-1", "--rooms", "room-1,room-2"},
|
||||
"calendar room search": {"calendar", "room", "search", "--room-name", "Fixture Room", "--group-id", "group-1", "--start", "2027-03-10T09:00:00+08:00", "--end", "2027-03-10T10:00:00+08:00", "--page", "1", "--limit", "7"},
|
||||
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
|
||||
"chat +chat-add-bot": {"chat", "+chat-add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
|
||||
"chat +chat-audit-join": {"chat", "+chat-audit-join", "--group", "fixture-conversation", "--record-id", "7", "--applicant", "user-1", "--inviter", "user-2", "--status", "AuditApprove", "--yes"},
|
||||
@@ -62,12 +107,12 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"chat +messages-list": {"chat", "+messages-list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
|
||||
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"},
|
||||
"chat +messages-reply": {"chat", "+messages-reply", "--group", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--content", "hello fixture", "--yes"},
|
||||
"chat +messages-resource-download": {"chat", "+messages-resource-download", "--resource-id", "resource-1", "--message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--output", "downloads/fixture.bin"},
|
||||
"chat +messages-set-pin": {"chat", "+messages-set-pin", "--open-conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
|
||||
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
|
||||
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--content", "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 +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--content", "hello fixture", "--yes"},
|
||||
"chat +unread-chats": {"chat", "+unread-chats", "--count", "7", "--exclude-muted"},
|
||||
"chat bot find": {"chat", "bot", "find", "--query", "fixture", "--limit", "7"},
|
||||
"chat bot search": {"chat", "bot", "search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
|
||||
@@ -93,11 +138,11 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"chat message list-by-ids": {"chat", "message", "list-by-ids", "--msg-ids", "message-1,message-2"},
|
||||
"chat message list-unread-conversations": {"chat", "message", "list-unread-conversations", "--count", "7", "--exclude-muted"},
|
||||
"chat message recall": {"chat", "message", "recall", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
|
||||
"chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"},
|
||||
"chat message reply": {"chat", "message", "reply", "--group", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--content", "hello fixture", "--yes"},
|
||||
"chat message search-advanced": {"chat", "message", "search-advanced", "--conversation-ids", "fixture-conversation", "--query", "fixture"},
|
||||
"chat message send": {"chat", "message", "send", "--user", appFixtureCurrentDOpenID, "--text", "hello fixture", "--idempotency-key", "param-alias-equivalence", "--yes"},
|
||||
"chat message send": {"chat", "message", "send", "--user", appFixtureCurrentDOpenID, "--content", "hello fixture", "--idempotency-key", "param-alias-equivalence", "--yes"},
|
||||
"chat message send-by-bot": {"chat", "message", "send-by-bot", "--robot-code", "robot-1", "--group", "fixture-conversation", "--title", "Fixture Alert", "--text", "@user-1 @user-2 fixture", "--at-user-ids", "user-1,user-2", "--yes"},
|
||||
"chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
|
||||
"chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--content", "fixture", "--at-users", "user-1,user-2", "--yes"},
|
||||
"contact +dept-members": {"contact", "+dept-members", "--dept", "Fixture Dept"},
|
||||
"contact +list-sub-depts": {"contact", "+list-sub-depts", "--dept", "1"},
|
||||
"contact +resolve-dept": {"contact", "+resolve-dept", "--name", "Fixture Dept"},
|
||||
@@ -116,7 +161,7 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"doc +copy": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
|
||||
"doc +create": {"doc", "+create", "--name", "Fixture Document", "--content", "fixture body", "--doc-format", "markdown"},
|
||||
"doc +create-from-template": {"doc", "+create-from-template", "--query", "fixture template", "--name", "Fixture From Template", "--folder", "folder-1", "--workspace", "workspace-1"},
|
||||
"doc +doc-append": {"doc", "+doc-append", "--doc", "node-1", "--text", "fixture appendix", "--yes"},
|
||||
"doc +doc-append": {"doc", "+doc-append", "--doc", "node-1", "--content", "fixture appendix", "--yes"},
|
||||
"doc +export-submit": {"doc", "+export-submit", "--node", "node-1", "--export-format", "docx"},
|
||||
"doc +fetch": {"doc", "+fetch", "--node", "node-1", "--scope", "section", "--start-block-id", "block-1"},
|
||||
"doc +find-doc": {"doc", "+find-doc", "--query", "fixture", "--limit", "7"},
|
||||
@@ -132,8 +177,8 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"},
|
||||
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"},
|
||||
"doc +export": {"doc", "+export", "--node", "node-1", "--export-format", "docx", "--output", "exports/fixture.docx"},
|
||||
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--content", "fixture paragraph", "--yes"},
|
||||
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--content", "fixture paragraph", "--yes"},
|
||||
"doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"},
|
||||
"doc comment create-inline": {"doc", "comment", "create-inline", "--node", "node-1", "--block-id", "block-1", "--start", "0", "--end", "7", "--content", "fixture comment", "--yes"},
|
||||
"doc comment delete": {"doc", "comment", "delete", "--node", "node-1", "--comment-key", "comment-1", "--yes"},
|
||||
@@ -196,7 +241,7 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
|
||||
"workspace": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
|
||||
},
|
||||
"doc block insert": {
|
||||
"parent-block": {"doc", "block", "insert", "--node", "node-1", "--parent-block", "parent-block-1", "--index", "0", "--text", "fixture paragraph", "--yes"},
|
||||
"parent-block": {"doc", "block", "insert", "--node", "node-1", "--parent-block", "parent-block-1", "--index", "0", "--content", "fixture paragraph", "--yes"},
|
||||
},
|
||||
"doc +inspect": {
|
||||
"include-permissions": {"doc", "+inspect", "--node", "node-1", "--include-permissions"},
|
||||
@@ -219,8 +264,8 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
|
||||
"sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", appFixtureCurrentDOpenID, "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
|
||||
},
|
||||
"chat message send": {
|
||||
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--idempotency-key", "param-alias-equivalence-group", "--yes"},
|
||||
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--idempotency-key", "param-alias-equivalence-file", "--yes"},
|
||||
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--content", "hello fixture", "--idempotency-key", "param-alias-equivalence-group", "--yes"},
|
||||
"file": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--idempotency-key", "param-alias-equivalence-file", "--yes"},
|
||||
},
|
||||
"chat +conversation-set-top": {
|
||||
"conversation-ids": {"chat", "+conversation-set-top", "--conversation-ids", "fixture-conversation-1,fixture-conversation-2", "--yes"},
|
||||
@@ -260,7 +305,6 @@ var paramAliasNewIMCases = []struct {
|
||||
{command: "chat message list-favorites", emitted: "limit", canonical: "size"},
|
||||
{command: "chat message list-unread-conversations", emitted: "limit", canonical: "count"},
|
||||
{command: "chat message list-unread-conversations", emitted: "size", canonical: "count"},
|
||||
{command: "chat message send", emitted: "file", canonical: "file-path"},
|
||||
{command: "chat message send-by-bot", emitted: "at-users", canonical: "at-user-ids"},
|
||||
{command: "chat message send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
|
||||
{command: "chat +chat-update", emitted: "chat-id", canonical: "group"},
|
||||
@@ -278,7 +322,7 @@ var paramAliasNewIMCases = []struct {
|
||||
{command: "chat +chat-members-get", emitted: "chat", canonical: "id"},
|
||||
{command: "chat +messages-list", emitted: "start", canonical: "time"},
|
||||
{command: "chat +messages-reply", emitted: "msg-id", canonical: "ref-msg-id"},
|
||||
{command: "chat +messages-reply", emitted: "chat", canonical: "conversation-id"},
|
||||
{command: "chat +messages-reply", emitted: "chat", canonical: "group"},
|
||||
{command: "chat +flag-cancel", emitted: "group", canonical: "conversation-id"},
|
||||
{command: "chat +flag-cancel", emitted: "chat", canonical: "conversation-id"},
|
||||
{command: "chat +flag-create", emitted: "group", canonical: "conversation-id"},
|
||||
@@ -372,16 +416,113 @@ var paramAliasNewDriveCases = []struct {
|
||||
{command: "drive +upload", emitted: "file-id", canonical: "node"},
|
||||
}
|
||||
|
||||
// paramAliasNewDriveConfirmationCases selects one newly reviewed alias for
|
||||
// every Drive command in the expansion whose declared runtime safety requires
|
||||
// confirmation. The full matrix below proves all spellings preserve the
|
||||
// confirmed payload; this smaller matrix proves aliases cannot cross the
|
||||
// confirmation boundary before any transport call is made.
|
||||
var paramAliasNewDriveConfirmationCases = []struct {
|
||||
// paramAliasAITableDeleteDisableCompleteCommands contains complete invocations
|
||||
// for every AITable delete/disable command whose confirmation boundary is
|
||||
// reached by aliases introduced in the AITable expansion. These templates are
|
||||
// intentionally separate from paramAliasCompleteCommands: that map mirrors
|
||||
// the reviewed validation fixture one-for-one, while this matrix exhaustively
|
||||
// proves the safety boundary for generated aliases beyond the fixture sample.
|
||||
var paramAliasAITableDeleteDisableCompleteCommands = map[string][]string{
|
||||
"aitable +advperm-disable": {"aitable", "+advperm-disable", "--base-id", "base-1", "--yes"},
|
||||
"aitable +base-delete": {"aitable", "+base-delete", "--base-id", "base-1", "--yes"},
|
||||
"aitable +chart-delete": {"aitable", "+chart-delete", "--base-id", "base-1", "--dashboard-id", "dashboard-1", "--chart-id", "chart-1", "--yes"},
|
||||
"aitable +dashboard-delete": {"aitable", "+dashboard-delete", "--base-id", "base-1", "--dashboard-id", "dashboard-1", "--yes"},
|
||||
"aitable +field-delete": {"aitable", "+field-delete", "--base-id", "base-1", "--table-id", "table-1", "--field-id", "field-1", "--yes"},
|
||||
"aitable +form-delete": {"aitable", "+form-delete", "--base-id", "base-1", "--table-id", "table-1", "--view-id", "view-1", "--yes"},
|
||||
"aitable +record-delete": {"aitable", "+record-delete", "--base-id", "base-1", "--table-id", "table-1", "--record-ids", "record-1", "--yes"},
|
||||
"aitable +role-delete": {"aitable", "+role-delete", "--base-id", "base-1", "--role-id", "role-1", "--yes"},
|
||||
"aitable +section-delete": {"aitable", "+section-delete", "--base-id", "base-1", "--section-id", "section-1", "--yes"},
|
||||
"aitable +table-delete": {"aitable", "+table-delete", "--base-id", "base-1", "--table-id", "table-1", "--yes"},
|
||||
"aitable +view-delete": {"aitable", "+view-delete", "--base-id", "base-1", "--table-id", "table-1", "--view-id", "view-1", "--yes"},
|
||||
"aitable +workflow-disable": {"aitable", "+workflow-disable", "--base-id", "base-1", "--workflow-id", "workflow-1", "--yes"},
|
||||
"aitable advperm disable": {"aitable", "advperm", "disable", "--base-id", "base-1", "--yes"},
|
||||
"aitable advperm role-delete": {"aitable", "advperm", "role-delete", "--base-id", "base-1", "--role-id", "role-1", "--yes"},
|
||||
"aitable base delete": {"aitable", "base", "delete", "--base-id", "base-1", "--yes"},
|
||||
"aitable chart delete": {"aitable", "chart", "delete", "--base-id", "base-1", "--dashboard-id", "dashboard-1", "--chart-id", "chart-1", "--yes"},
|
||||
"aitable dashboard delete": {"aitable", "dashboard", "delete", "--base-id", "base-1", "--dashboard-id", "dashboard-1", "--yes"},
|
||||
"aitable field delete": {"aitable", "field", "delete", "--base-id", "base-1", "--table-id", "table-1", "--field-id", "field-1", "--yes"},
|
||||
"aitable form delete": {"aitable", "form", "delete", "--base-id", "base-1", "--table-id", "table-1", "--view-id", "view-1", "--yes"},
|
||||
"aitable form questions delete": {"aitable", "form", "questions", "delete", "--base-id", "base-1", "--table-id", "table-1", "--field-id", "field-1", "--yes"},
|
||||
"aitable record delete": {"aitable", "record", "delete", "--base-id", "base-1", "--table-id", "table-1", "--record-ids", "record-1", "--yes"},
|
||||
"aitable table delete": {"aitable", "table", "delete", "--base-id", "base-1", "--table-id", "table-1", "--yes"},
|
||||
"aitable view delete": {"aitable", "view", "delete", "--base-id", "base-1", "--table-id", "table-1", "--view-id", "view-1", "--yes"},
|
||||
"aitable workflow disable": {"aitable", "workflow", "disable", "--base-id", "base-1", "--workflow-id", "workflow-1", "--yes"},
|
||||
}
|
||||
|
||||
// paramAliasNewAITableDeleteDisableCases is the exhaustive set of alias
|
||||
// tuples newly introduced by this change on AITable delete/disable commands
|
||||
// that require confirmation. Every tuple must remain on both sides of the
|
||||
// confirmation gate: rejected with zero calls before --yes, and exactly
|
||||
// payload-equivalent to its canonical spelling after --yes.
|
||||
var paramAliasNewAITableDeleteDisableCases = []struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{
|
||||
{command: "aitable +advperm-disable", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +advperm-disable", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +base-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +base-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +chart-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +chart-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +dashboard-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +dashboard-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +field-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +field-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +field-delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable +form-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +form-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +form-delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable +record-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +record-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +record-delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable +role-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +role-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +section-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +section-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +table-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +table-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +table-delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable +view-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +view-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +view-delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable +workflow-disable", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +workflow-disable", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +workflow-disable", emitted: "flow-id", canonical: "workflow-id"},
|
||||
{command: "aitable advperm disable", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable advperm role-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable base delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable chart delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable dashboard delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable field delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable field delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable form delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable form delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable form questions delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable form questions delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable record delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable record delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable table delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable table delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable view delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable view delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable workflow disable", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable workflow disable", emitted: "flow-id", canonical: "workflow-id"},
|
||||
}
|
||||
|
||||
// paramAliasNewConfirmationCases selects newly reviewed aliases for commands
|
||||
// whose declared runtime safety requires confirmation. The full matrix below
|
||||
// proves all spellings preserve the confirmed payload; this smaller matrix
|
||||
// proves aliases cannot cross the confirmation boundary before any transport
|
||||
// call is made.
|
||||
var paramAliasNewConfirmationCases = []struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{
|
||||
{command: "aitable workflow run", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable workflow run", emitted: "flow-id", canonical: "workflow-id"},
|
||||
{command: "aitable workflow run", emitted: "table", canonical: "table-id"},
|
||||
{command: "drive +delete", emitted: "file-id", canonical: "node"},
|
||||
{command: "drive +publish-unset", emitted: "document-url", canonical: "node"},
|
||||
{command: "drive +recycle-restore", emitted: "recycle-item-id", canonical: "id"},
|
||||
@@ -402,7 +543,27 @@ var paramAliasNewDriveConfirmationCases = []struct {
|
||||
// That duplicated command construction was enough to push the pre-existing
|
||||
// macOS app suite beyond its package-level 10-minute timeout.
|
||||
var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("aitable +export-data", "export-format"): true, // shortcut-local export format keeps the final payload
|
||||
paramAliasPayloadCaseKey("aitable +find-record", "base-id"): true, // shortcut Base ID compatibility
|
||||
paramAliasPayloadCaseKey("aitable +find-record", "table-id"): true, // shortcut Table ID compatibility
|
||||
paramAliasPayloadCaseKey("aitable +record-query", "base"): true, // concept alias on a shortcut read
|
||||
paramAliasPayloadCaseKey("aitable +record-share-links", "base-id"): true, // observed experiment Base ID spelling
|
||||
paramAliasPayloadCaseKey("aitable +record-share-links", "table-id"): true, // observed experiment Table ID spelling
|
||||
paramAliasPayloadCaseKey("aitable +workflow-list", "max-results"): true, // shortcut pagination-size alias
|
||||
paramAliasPayloadCaseKey("aitable attachment upload", "file-size"): true, // byte-size command override
|
||||
paramAliasPayloadCaseKey("aitable base list", "next-cursor"): true, // cursor concept alias
|
||||
paramAliasPayloadCaseKey("aitable base update", "description"): true, // plain description alias on a write command
|
||||
paramAliasPayloadCaseKey("aitable field search-options", "query"): true, // search keyword concept alias
|
||||
paramAliasPayloadCaseKey("aitable workflow get", "flow-id"): true, // workflow ID concept alias
|
||||
paramAliasPayloadCaseKey("aitable workflow history", "base-token"): true, // Base ID concept alias
|
||||
paramAliasPayloadCaseKey("aitable workflow history", "end-time"): true, // upper time-bound override
|
||||
paramAliasPayloadCaseKey("aitable workflow history", "flow-id"): true, // workflow ID concept alias
|
||||
paramAliasPayloadCaseKey("aitable workflow history", "page-index"): true, // zero-based page override
|
||||
paramAliasPayloadCaseKey("aitable workflow history", "page-size"): true, // page-size concept alias
|
||||
paramAliasPayloadCaseKey("aitable workflow history", "start-time"): true, // lower time-bound override
|
||||
paramAliasPayloadCaseKey("aitable workflow run", "base-token"): true, // Base ID concept alias on a confirmed write
|
||||
paramAliasPayloadCaseKey("aitable workflow run", "flow-id"): true, // workflow ID concept alias on a confirmed write
|
||||
paramAliasPayloadCaseKey("aitable workflow run", "table"): true, // Table ID concept alias on a confirmed write
|
||||
paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias
|
||||
paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias
|
||||
paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias
|
||||
@@ -426,7 +587,11 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("doc +update", "revision"): true, // optimistic edit revision alias
|
||||
paramAliasPayloadCaseKey("doc +access-grant", "doc-id"): true, // permission write keeps document identity
|
||||
paramAliasPayloadCaseKey("doc +version-revert", "version-number"): true, // high-write version role with canonical confirmation
|
||||
paramAliasPayloadCaseKey("doc block insert", "content"): true, // block write content alias
|
||||
paramAliasPayloadCaseKey("chat +messages-reply", "conversation-id"): true, // renamed conversation Primary keeps final reply payload
|
||||
paramAliasPayloadCaseKey("chat message send", "file-path"): true, // renamed local-file Primary reaches the same final payload
|
||||
paramAliasPayloadCaseKey("doc +doc-append", "text"): true, // shortcut content rename keeps append payload
|
||||
paramAliasPayloadCaseKey("doc block insert", "text"): true, // block write content compatibility alias
|
||||
paramAliasPayloadCaseKey("doc block update", "text"): true, // update uses the same typed compatibility path
|
||||
paramAliasPayloadCaseKey("doc block insert", "parent-block-id"): true, // scoped block-role alias
|
||||
paramAliasPayloadCaseKey("doc comment delete", "comment-id"): true, // destructive comment-key alias
|
||||
paramAliasPayloadCaseKey("doc comment reply", "mentioned-open-conversation-ids"): true, // list-valued group mention role
|
||||
@@ -435,6 +600,108 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
|
||||
}
|
||||
|
||||
// paramAliasCalendarPayloadCases keeps the full reviewed Calendar expansion
|
||||
// separate from the long-lived app-c race process. Each case still executes
|
||||
// both canonical and alias argv through the real PreParse/Cobra path and
|
||||
// compares the final captured transport calls; the owning top-level test runs
|
||||
// these allocations in a short-lived race-instrumented subprocess so all Root
|
||||
// registrations are released together when that process exits.
|
||||
var paramAliasCalendarPayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("calendar +agenda", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +agenda", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +agenda", "max-results"): true,
|
||||
paramAliasPayloadCaseKey("calendar +agenda", "next-cursor"): true,
|
||||
paramAliasPayloadCaseKey("calendar +agenda", "calendar-book-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +attendee-list", "event-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +attendee-list", "calendar-book-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +book", "summary"): true,
|
||||
paramAliasPayloadCaseKey("calendar +book", "attendee-names"): true,
|
||||
paramAliasPayloadCaseKey("calendar +book-search", "keyword"): true,
|
||||
paramAliasPayloadCaseKey("calendar +book-search", "search"): true,
|
||||
paramAliasPayloadCaseKey("calendar +book-search", "name"): true,
|
||||
paramAliasPayloadCaseKey("calendar +cancel-event", "event-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +cancel-event", "id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +free", "name"): true,
|
||||
paramAliasPayloadCaseKey("calendar +free-slots", "start-hour"): true,
|
||||
paramAliasPayloadCaseKey("calendar +free-slots", "end-hour"): true,
|
||||
paramAliasPayloadCaseKey("calendar +free-slots", "day-offset"): true,
|
||||
paramAliasPayloadCaseKey("calendar +freebusy", "user-ids"): true,
|
||||
paramAliasPayloadCaseKey("calendar +freebusy", "room-ids"): true,
|
||||
paramAliasPayloadCaseKey("calendar +freebusy", "room-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +my-free", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +my-free", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +invite", "id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +invite", "participant-names"): true,
|
||||
paramAliasPayloadCaseKey("calendar +reschedule", "id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +reschedule", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +reschedule", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-groups", "page-size"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-groups", "page-index"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-search", "query"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggest-time", "duration-minutes"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggest-time", "attendee-names"): true,
|
||||
paramAliasPayloadCaseKey("calendar +conflicts", "day-offset"): true,
|
||||
paramAliasPayloadCaseKey("calendar busy search", "room-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar event create", "reminder-minutes"): true,
|
||||
paramAliasPayloadCaseKey("calendar event create", "tz"): true,
|
||||
paramAliasPayloadCaseKey("calendar event create", "room-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar event respond", "response-status"): true,
|
||||
paramAliasPayloadCaseKey("calendar event suggest", "duration-minutes"): true,
|
||||
paramAliasPayloadCaseKey("calendar event update", "tz"): true,
|
||||
paramAliasPayloadCaseKey("calendar room add", "room-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar room delete", "room-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar room search", "room-group-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "summary"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "description"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "user-ids"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "room-ids"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "room-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "calendar-book-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +get", "event-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +get", "calendar-book-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-find", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-find", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-find", "page-size"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-find", "page-index"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-find", "room-group-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-find", "query"): true,
|
||||
paramAliasPayloadCaseKey("calendar +rsvp", "event-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +rsvp", "response-status"): true,
|
||||
paramAliasPayloadCaseKey("calendar +search-event", "keyword"): true,
|
||||
paramAliasPayloadCaseKey("calendar +search-event", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +search-event", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +search-event", "next-cursor"): true,
|
||||
paramAliasPayloadCaseKey("calendar +search-event", "max-results"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggestion", "user-ids"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggestion", "duration-minutes"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggestion", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggestion", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggestion", "tz"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "event-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "summary"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "description"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "add-user-ids"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "remove-user-ids"): true,
|
||||
}
|
||||
|
||||
// paramAliasCalendarConfirmationCases selects one newly reviewed alias for
|
||||
// every Calendar Shortcut whose runtime contract requires user confirmation.
|
||||
// The complete Calendar matrix proves confirmed canonical/alias payload
|
||||
// equality; these representatives additionally prove semantic normalization
|
||||
// cannot cross the confirmation boundary before the first transport call.
|
||||
var paramAliasCalendarConfirmationCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("calendar +book", "summary"): true,
|
||||
paramAliasPayloadCaseKey("calendar +cancel-event", "event-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "summary"): true,
|
||||
paramAliasPayloadCaseKey("calendar +invite", "id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +reschedule", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +rsvp", "response-status"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "event-id"): true,
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
@@ -468,28 +735,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
}
|
||||
executedRepresentatives[caseKey] = true
|
||||
t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) {
|
||||
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
|
||||
if canonicalErr != nil {
|
||||
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
|
||||
}
|
||||
if len(canonicalCaller.calls) == 0 {
|
||||
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
|
||||
}
|
||||
|
||||
aliasCaller := ¶mAliasCaptureCaller{}
|
||||
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if aliasErr != nil {
|
||||
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("complete alias command skipped PreParse")
|
||||
}
|
||||
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)
|
||||
}
|
||||
assertParamAliasFinalPayloadEquivalent(t, fixture.Command, canonicalArgs, aliasArgs)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -519,6 +765,118 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedCalendarParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
|
||||
if os.Getenv(paramAliasCalendarPayloadChildEnv) != "1" {
|
||||
command := exec.Command(
|
||||
os.Args[0],
|
||||
"-test.run=^TestCrossPlatformCoverageReviewedCalendarParamAliasesReachCanonicalEquivalentFinalPayloads$",
|
||||
"-test.count=1",
|
||||
"-test.timeout=5m",
|
||||
)
|
||||
command.Env = append(os.Environ(), paramAliasCalendarPayloadChildEnv+"=1")
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("Calendar param-alias payload subprocess failed: %v\n%s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
}
|
||||
|
||||
executed := make(map[string]bool)
|
||||
executedConfirmation := make(map[string]bool)
|
||||
for _, fixture := range concepts.Fixture {
|
||||
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
|
||||
if !paramAliasCalendarPayloadCases[caseKey] {
|
||||
continue
|
||||
}
|
||||
executed[caseKey] = true
|
||||
fixture := fixture
|
||||
t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) {
|
||||
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
|
||||
if !ok {
|
||||
t.Fatal("reviewed Calendar alias has no complete-command E2E template")
|
||||
}
|
||||
canonicalArgs := append([]string(nil), complete...)
|
||||
aliasArgs, replacements := replaceLongFlag(canonicalArgs, fixture.Expect, fixture.Emitted)
|
||||
if replacements != 1 {
|
||||
t.Fatalf("complete Calendar command must contain canonical --%s exactly once; replacements=%d args=%v", fixture.Expect, replacements, canonicalArgs)
|
||||
}
|
||||
assertParamAliasFinalPayloadEquivalent(t, fixture.Command, canonicalArgs, aliasArgs)
|
||||
if paramAliasCalendarConfirmationCases[caseKey] {
|
||||
executedConfirmation[caseKey] = true
|
||||
assertParamAliasCannotBypassConfirmation(t, aliasArgs)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for caseKey := range paramAliasCalendarPayloadCases {
|
||||
if !executed[caseKey] {
|
||||
t.Errorf("Calendar final-payload case %q has no active reviewed fixture", caseKey)
|
||||
}
|
||||
}
|
||||
if len(executed) != len(paramAliasCalendarPayloadCases) {
|
||||
t.Fatalf("Calendar final-payload coverage = %d, want %d", len(executed), len(paramAliasCalendarPayloadCases))
|
||||
}
|
||||
for caseKey := range paramAliasCalendarConfirmationCases {
|
||||
if !executedConfirmation[caseKey] {
|
||||
t.Errorf("Calendar confirmation case %q has no active reviewed fixture", caseKey)
|
||||
}
|
||||
}
|
||||
if len(executedConfirmation) != len(paramAliasCalendarConfirmationCases) {
|
||||
t.Fatalf("Calendar confirmation coverage = %d, want %d", len(executedConfirmation), len(paramAliasCalendarConfirmationCases))
|
||||
}
|
||||
}
|
||||
|
||||
func assertParamAliasCannotBypassConfirmation(t *testing.T, aliasArgs []string) {
|
||||
t.Helper()
|
||||
unconfirmedArgs, removals := removeExactArg(aliasArgs, "--yes")
|
||||
if removals != 1 {
|
||||
t.Fatalf("confirmation template must contain --yes exactly once; removals=%d args=%v", removals, aliasArgs)
|
||||
}
|
||||
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasPayloadE2E(t, caller, unconfirmedArgs...)
|
||||
if ctx == nil {
|
||||
t.Fatal("unconfirmed Calendar alias command skipped PreParse")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) || appErr.Reason != "confirmation_required" {
|
||||
t.Fatalf("unconfirmed Calendar alias command error = %#v, want confirmation_required\nargs=%v", err, unconfirmedArgs)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("unconfirmed Calendar alias crossed the transport boundary: args=%v calls=%#v", unconfirmedArgs, caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func assertParamAliasFinalPayloadEquivalent(t *testing.T, command string, canonicalArgs, aliasArgs []string) {
|
||||
t.Helper()
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
|
||||
if canonicalErr != nil {
|
||||
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
|
||||
}
|
||||
if len(canonicalCaller.calls) == 0 {
|
||||
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
|
||||
}
|
||||
|
||||
aliasCaller := ¶mAliasCaptureCaller{}
|
||||
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if aliasErr != nil {
|
||||
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("complete alias command skipped PreParse")
|
||||
}
|
||||
normalizeParamAliasVolatileDefaults(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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
|
||||
activeAliases := 0
|
||||
for _, test := range paramAliasNewIMCases {
|
||||
@@ -629,13 +987,116 @@ func TestCrossPlatformCoverageNewDriveParamAliasesReachCanonicalEquivalentFinalP
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewDriveParamAliasesCannotBypassConfirmation(t *testing.T) {
|
||||
for _, test := range paramAliasNewDriveConfirmationCases {
|
||||
func TestCrossPlatformCoverageNewAITableDeleteDisableAliasesPreserveConfirmationAndPayload(t *testing.T) {
|
||||
coveredCommands := make(map[string]bool)
|
||||
reviewedAliases := make(map[string]string, len(paramAliasNewAITableDeleteDisableCases))
|
||||
for _, test := range paramAliasNewAITableDeleteDisableCases {
|
||||
test := test
|
||||
caseKey := paramAliasPayloadCaseKey(test.command, test.emitted)
|
||||
if previous, duplicate := reviewedAliases[caseKey]; duplicate {
|
||||
t.Fatalf("duplicate AITable delete/disable alias case %q: --%s and --%s", caseKey, previous, test.canonical)
|
||||
}
|
||||
reviewedAliases[caseKey] = test.canonical
|
||||
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
|
||||
complete, ok := paramAliasAITableDeleteDisableCompleteCommands[test.command]
|
||||
if !ok {
|
||||
t.Fatal("reviewed AITable delete/disable alias has no complete safety template")
|
||||
}
|
||||
coveredCommands[test.command] = true
|
||||
|
||||
canonicalArgs := append([]string(nil), complete...)
|
||||
aliasArgs, replacements := replaceLongFlag(canonicalArgs, test.canonical, test.emitted)
|
||||
if replacements != 1 {
|
||||
t.Fatalf("complete command must contain canonical --%s exactly once; replacements=%d args=%v", test.canonical, replacements, canonicalArgs)
|
||||
}
|
||||
unconfirmedArgs, removals := removeExactArg(aliasArgs, "--yes")
|
||||
if removals != 1 {
|
||||
t.Fatalf("safety template must contain --yes exactly once; removals=%d args=%v", removals, aliasArgs)
|
||||
}
|
||||
|
||||
entry, exists := cli.LookupParamAlias(test.command)
|
||||
target, active := entry.ResolveAlias(test.emitted)
|
||||
if !exists || !active || target != test.canonical {
|
||||
t.Fatalf("reviewed AITable alias --%s resolution = exists:%v active:%v target:%q, want --%s", test.emitted, exists, active, target, test.canonical)
|
||||
}
|
||||
|
||||
unconfirmedCaller := ¶mAliasCaptureCaller{}
|
||||
ctx, unconfirmedErr := executeParamAliasPayloadE2E(t, unconfirmedCaller, unconfirmedArgs...)
|
||||
if ctx == nil {
|
||||
t.Fatal("unconfirmed AITable alias command skipped PreParse")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(unconfirmedErr, &appErr) || appErr.Reason != "confirmation_required" {
|
||||
t.Fatalf("unconfirmed AITable alias command error = %#v, want confirmation_required\nargs=%v", unconfirmedErr, unconfirmedArgs)
|
||||
}
|
||||
if len(unconfirmedCaller.calls) != 0 {
|
||||
t.Fatalf("unconfirmed AITable alias crossed the transport boundary: args=%v calls=%#v", unconfirmedArgs, unconfirmedCaller.calls)
|
||||
}
|
||||
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
|
||||
if canonicalErr != nil {
|
||||
t.Fatalf("confirmed canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
|
||||
}
|
||||
if len(canonicalCaller.calls) == 0 {
|
||||
t.Fatalf("confirmed canonical command reached no final transport payload: args=%v", canonicalArgs)
|
||||
}
|
||||
|
||||
aliasCaller := ¶mAliasCaptureCaller{}
|
||||
aliasCtx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if aliasErr != nil {
|
||||
t.Fatalf("confirmed alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
|
||||
}
|
||||
if aliasCtx == nil {
|
||||
t.Fatal("confirmed AITable alias command skipped PreParse")
|
||||
}
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
t.Fatalf("confirmed final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for command := range paramAliasAITableDeleteDisableCompleteCommands {
|
||||
if !coveredCommands[command] {
|
||||
t.Errorf("AITable delete/disable safety template %q has no reviewed alias case", command)
|
||||
}
|
||||
}
|
||||
if len(coveredCommands) != len(paramAliasAITableDeleteDisableCompleteCommands) {
|
||||
t.Fatalf("AITable delete/disable safety coverage = %d commands, want %d", len(coveredCommands), len(paramAliasAITableDeleteDisableCompleteCommands))
|
||||
}
|
||||
|
||||
activeAliases := 0
|
||||
for command := range paramAliasAITableDeleteDisableCompleteCommands {
|
||||
entry, exists := cli.LookupParamAlias(command)
|
||||
if !exists {
|
||||
t.Errorf("AITable delete/disable safety command %q has no generated alias entry", command)
|
||||
continue
|
||||
}
|
||||
for emitted, canonical := range entry.Aliases {
|
||||
activeAliases++
|
||||
caseKey := paramAliasPayloadCaseKey(command, emitted)
|
||||
reviewedCanonical, reviewed := reviewedAliases[caseKey]
|
||||
if !reviewed {
|
||||
t.Errorf("active AITable delete/disable alias %q --%s -> --%s has no confirmation/payload case", command, emitted, canonical)
|
||||
continue
|
||||
}
|
||||
if reviewedCanonical != canonical {
|
||||
t.Errorf("reviewed AITable delete/disable alias %q --%s target = --%s, generated --%s", command, emitted, reviewedCanonical, canonical)
|
||||
}
|
||||
}
|
||||
}
|
||||
if activeAliases != len(reviewedAliases) {
|
||||
t.Fatalf("AITable delete/disable generated alias coverage = %d, want %d reviewed cases", activeAliases, len(reviewedAliases))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewParamAliasesCannotBypassConfirmation(t *testing.T) {
|
||||
for _, test := range paramAliasNewConfirmationCases {
|
||||
test := test
|
||||
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
|
||||
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
|
||||
if !ok {
|
||||
t.Fatal("reviewed Drive confirmation alias has no complete-command E2E template")
|
||||
t.Fatal("reviewed confirmation alias has no complete-command E2E template")
|
||||
}
|
||||
aliasArgs, replacements := replaceLongFlag(complete, test.canonical, test.emitted)
|
||||
if replacements != 1 {
|
||||
@@ -649,7 +1110,7 @@ func TestCrossPlatformCoverageNewDriveParamAliasesCannotBypassConfirmation(t *te
|
||||
entry, exists := cli.LookupParamAlias(test.command)
|
||||
target, active := entry.ResolveAlias(test.emitted)
|
||||
if !exists || !active || target != test.canonical {
|
||||
t.Fatalf("reviewed Drive alias --%s resolution = exists:%v active:%v target:%q, want --%s", test.emitted, exists, active, target, test.canonical)
|
||||
t.Fatalf("reviewed confirmation alias --%s resolution = exists:%v active:%v target:%q, want --%s", test.emitted, exists, active, target, test.canonical)
|
||||
}
|
||||
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
|
||||
@@ -264,7 +264,7 @@ func TestRootChatMediaUploadWithoutAppCredentialsReturnsMigrationValidation(t *t
|
||||
}
|
||||
|
||||
got := output.String() + "\n" + err.Error()
|
||||
for _, want := range []string{"已下线", "chat message send --msg-type file --file-path"} {
|
||||
for _, want := range []string{"已下线", "chat message send --msg-type file --file"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("chat media upload migration output missing %q:\n%s", want, got)
|
||||
}
|
||||
@@ -439,7 +439,7 @@ func TestChatFileUploadDownlinedButMessageFileSendStays(t *testing.T) {
|
||||
t.Fatalf("chat file upload error = nil, want downline error\n%s", got)
|
||||
}
|
||||
got = got + "\n" + err.Error()
|
||||
for _, want := range []string{"已下线", "upload_conversation_file_by_url", "chat message send --msg-type file --file-path"} {
|
||||
for _, want := range []string{"已下线", "upload_conversation_file_by_url", "chat message send --msg-type file --file"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("chat file upload output missing %q:\n%s", want, got)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// 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 "testing"
|
||||
|
||||
func TestCrossPlatformCoverageCalendarAgendaFinalSchemaPreservesCompositeProperties(t *testing.T) {
|
||||
snapshot := fullSchemaSnapshotForTest(t)
|
||||
tool := snapshot.Tools["calendar.shortcut_agenda"]
|
||||
if tool == nil {
|
||||
t.Fatal("calendar.shortcut_agenda is missing from final Schema")
|
||||
}
|
||||
parameters := schemaContractMap(tool["parameters"])
|
||||
for flag, want := range map[string]string{
|
||||
"start": "start",
|
||||
"end": "end",
|
||||
} {
|
||||
parameter := parameters[flag]
|
||||
if parameter == nil {
|
||||
t.Fatalf("calendar.shortcut_agenda --%s is missing from final Schema", flag)
|
||||
}
|
||||
if got := schemaContractString(parameter["property"]); got != want {
|
||||
t.Errorf("calendar.shortcut_agenda --%s property=%q, want %q", flag, got, want)
|
||||
}
|
||||
}
|
||||
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
|
||||
t.Fatalf("calendar.shortcut_agenda interface_mode=%q, want composite", got)
|
||||
}
|
||||
result := schemaContractMap(tool["result"])
|
||||
dataSchema := schemaContractMap(result["data_schema"])
|
||||
properties := schemaContractMap(dataSchema["properties"])
|
||||
for _, field := range []string{"hasMore", "nextCursor"} {
|
||||
if _, exists := properties[field]; exists {
|
||||
t.Fatalf("calendar.shortcut_agenda Result data_schema leaked pagination field %q", field)
|
||||
}
|
||||
}
|
||||
if properties["complete"] == nil {
|
||||
t.Fatal("calendar.shortcut_agenda Result data_schema is missing complete")
|
||||
}
|
||||
pagination, ok := tool["pagination"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("calendar.shortcut_agenda pagination=%T, want object", tool["pagination"])
|
||||
}
|
||||
if got := schemaContractString(pagination["meta_path"]); got != "meta.pagination" {
|
||||
t.Fatalf("calendar.shortcut_agenda pagination meta_path=%q, want meta.pagination", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t *testing.T) {
|
||||
tests := []struct {
|
||||
cliPath string
|
||||
canonical string
|
||||
rpc string
|
||||
description string
|
||||
effect string
|
||||
resultType string
|
||||
resultFields map[string]string
|
||||
sensitivePaths []string
|
||||
}{
|
||||
{
|
||||
cliPath: "oa approval attachment download-url",
|
||||
canonical: "oa.get_attachment_download_url",
|
||||
rpc: "get_attachment_download_url",
|
||||
description: "获取审批附件下载授权并生成临时下载链接",
|
||||
effect: "read",
|
||||
resultType: "object",
|
||||
resultFields: map[string]string{
|
||||
"spaceId": "integer", "agentId": "integer", "downloadUri": "string",
|
||||
"class": "string", "fileId": "string",
|
||||
},
|
||||
sensitivePaths: []string{"downloadUri"},
|
||||
},
|
||||
{
|
||||
cliPath: "oa approval attachment authorize-download",
|
||||
canonical: "oa.auth_download_file",
|
||||
rpc: "auth_download_file",
|
||||
description: "批量授权当前用户下载指定的审批钉盘文件",
|
||||
effect: "write",
|
||||
resultType: "boolean",
|
||||
},
|
||||
{
|
||||
cliPath: "oa approval attachment authorize-preview",
|
||||
canonical: "oa.auth_preview_attachment",
|
||||
rpc: "auth_preview_attachment",
|
||||
description: "批量授权当前用户预览审批单中的附件",
|
||||
effect: "write",
|
||||
resultType: "object",
|
||||
resultFields: map[string]string{
|
||||
"spaceId": "integer", "agentId": "integer", "class": "string",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.rpc, func(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
command := exactCommandForTest(root, test.cliPath)
|
||||
if command == nil {
|
||||
t.Fatalf("executable command %q is missing", test.cliPath)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
root.SetArgs([]string{"schema", test.cliPath, "--format", "json"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute delivery schema leaf: %v; stderr=%s", err, stderr.String())
|
||||
}
|
||||
|
||||
var tool map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &tool); err != nil {
|
||||
t.Fatalf("decode delivery schema leaf: %v", err)
|
||||
}
|
||||
if got := schemaContractString(tool["canonical_path"]); got != test.canonical {
|
||||
t.Fatalf("canonical_path = %q, want %q", got, test.canonical)
|
||||
}
|
||||
if got := schemaContractString(tool["primary_cli_path"]); got != test.cliPath {
|
||||
t.Fatalf("primary_cli_path = %q, want %q", got, test.cliPath)
|
||||
}
|
||||
if got := schemaContractString(tool["description"]); !strings.HasPrefix(got, test.description) {
|
||||
t.Fatalf("description = %q, want prefix %q", got, test.description)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_mode"]); got != "mcp" {
|
||||
t.Fatalf("interface_mode = %q, want mcp", got)
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Fatalf("availability = %q, want available", got)
|
||||
}
|
||||
interfaceRef := schemaInterfaceObject(tool["interface_ref"])
|
||||
if got := schemaContractString(interfaceRef["product_id"]); got != "oa" {
|
||||
t.Fatalf("interface_ref.product_id = %q, want oa", got)
|
||||
}
|
||||
if got := schemaContractString(interfaceRef["rpc_name"]); got != test.rpc {
|
||||
t.Fatalf("interface_ref.rpc_name = %q, want %q", got, test.rpc)
|
||||
}
|
||||
if got := schemaContractString(tool["effect"]); got != test.effect {
|
||||
t.Fatalf("effect = %q, want %q", got, test.effect)
|
||||
}
|
||||
if got := schemaContractString(tool["risk"]); got != "low" {
|
||||
t.Fatalf("risk = %q, want low", got)
|
||||
}
|
||||
if got := schemaContractString(tool["confirmation"]); got != "not_required" {
|
||||
t.Fatalf("confirmation = %q, want not_required", got)
|
||||
}
|
||||
if got := schemaContractString(tool["idempotency"]); got != "idempotent" {
|
||||
t.Fatalf("idempotency = %q, want idempotent", got)
|
||||
}
|
||||
fullResult := oaAttachmentResultContract(t, tool, test.resultType, test.resultFields, test.sensitivePaths)
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
root.SetArgs([]string{"schema", test.cliPath, "--compact", "--format", "json"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute compact delivery schema leaf: %v; stderr=%s", err, stderr.String())
|
||||
}
|
||||
var compactTool map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &compactTool); err != nil {
|
||||
t.Fatalf("decode compact delivery schema leaf: %v", err)
|
||||
}
|
||||
compactResult, ok := compactTool["result"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("compact result = %#v, want object", compactTool["result"])
|
||||
}
|
||||
if !reflect.DeepEqual(compactResult, fullResult) {
|
||||
t.Fatalf("compact/full result projection differs\ncompact: %#v\nfull: %#v", compactResult, fullResult)
|
||||
}
|
||||
if problem := schemaHelpFlagCompletenessProblem(test.canonical, test.cliPath, command, tool); problem != "" {
|
||||
t.Fatal(problem)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func oaAttachmentResultContract(t *testing.T, tool map[string]any, resultType string, fields map[string]string, sensitivePaths []string) map[string]any {
|
||||
t.Helper()
|
||||
result, ok := tool["result"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("full result = %#v, want object", tool["result"])
|
||||
}
|
||||
if got, want := schemaContractStringSlice(result["outcomes"]), []string{"success", "failure"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("result.outcomes = %#v, want %#v", got, want)
|
||||
}
|
||||
if got := schemaContractStringSlice(result["sensitive_paths"]); !reflect.DeepEqual(got, sensitivePaths) {
|
||||
t.Fatalf("result.sensitive_paths = %#v, want %#v", got, sensitivePaths)
|
||||
}
|
||||
dataSchema, ok := result["data_schema"].(map[string]any)
|
||||
if !ok || schemaContractString(dataSchema["type"]) != resultType {
|
||||
t.Fatalf("result.data_schema = %#v, want type %q", result["data_schema"], resultType)
|
||||
}
|
||||
properties, _ := dataSchema["properties"].(map[string]any)
|
||||
if len(properties) != len(fields) {
|
||||
t.Fatalf("result.data_schema.properties = %#v, want fields %#v", properties, fields)
|
||||
}
|
||||
for name, fieldType := range fields {
|
||||
property, ok := properties[name].(map[string]any)
|
||||
if !ok || schemaContractString(property["type"]) != fieldType {
|
||||
t.Fatalf("result.data_schema.properties.%s = %#v, want type %q", name, properties[name], fieldType)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 418
|
||||
publicShortcutCount = 435
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including the hidden historical minutes.shortcut_minutes_search contract.
|
||||
schemaPublishedShortcutCount = 420
|
||||
schemaPublishedShortcutCount = 438
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 418
|
||||
publiclyDeliveredShortcutCount = 435
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
@@ -140,6 +140,25 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
assertChatCatalogCompleteLeafContracts(t)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAITableTableBootstrapPublishesResultContract(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", "aitable +table-bootstrap")
|
||||
result, _ := leaf["result"].(map[string]any)
|
||||
if got, want := schemaContractStringSlice(result["outcomes"]), []string{"success", "failure"}; !schemaContractJSONEqual(got, want) {
|
||||
t.Fatalf("aitable +table-bootstrap outcomes = %#v, want %#v", got, want)
|
||||
}
|
||||
dataSchema, _ := result["data_schema"].(map[string]any)
|
||||
properties := schemaContractMap(dataSchema["properties"])
|
||||
status := properties["status"]
|
||||
if got, want := schemaContractStringSlice(status["enum"]), []string{"success", "planned", "partial_success", "unknown"}; !schemaContractJSONEqual(got, want) {
|
||||
t.Fatalf("aitable +table-bootstrap status enum = %#v, want %#v", got, want)
|
||||
}
|
||||
for _, property := range []string{"contractVersion", "operation", "executed", "retryable", "plan", "completedSteps", "verification", "checkpoint", "knownSideEffects", "result"} {
|
||||
if properties[property] == nil {
|
||||
t.Errorf("aitable +table-bootstrap final Result data_schema is missing %q", property)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryWikiSpaceSearchDeclaresCompatibilityAdapter(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", "wiki +space-search")
|
||||
if got := schemaContractString(leaf["interface_mode"]); got != "composite" {
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
func TestToolSearchDeliveryActionSiblingRouting(t *testing.T) {
|
||||
registerSchemaRuntimeDelivery()
|
||||
// This test pins the action_v1 structured sibling routing itself, decoupled
|
||||
// from the production default (now the fielded BM25 ensemble, which has no
|
||||
// structured multiplier and cannot right-size these sibling queries).
|
||||
config := cli.DefaultToolSearchConfig()
|
||||
config.LexicalAlgorithm = cli.ToolSearchLexicalBM25Action
|
||||
engine, err := cli.NewDeliveryToolSearchEngineWithConfig(config)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDeliveryToolSearchEngineWithConfig() error = %v", err)
|
||||
}
|
||||
tests := []struct {
|
||||
query string
|
||||
gold string
|
||||
}{
|
||||
{query: "创建一场新的会议日程", gold: "calendar.create_calendar_event"},
|
||||
{query: "给钉盘文件添加同事阅读权限", gold: "drive.add_permission"},
|
||||
// The new main Catalog declares shortcut_fetch as a reviewed equivalent
|
||||
// read surface, so this query has graded-equivalent Top-1 answers.
|
||||
{query: "读取钉钉在线文字文档正文", gold: "doc.shortcut_fetch|doc.get_document_content"},
|
||||
{query: "查询当前用户待审批任务 ID", gold: "oa.list_pending_tasks"},
|
||||
{query: "同意指定审批任务", gold: "oa.approve_processInstance"},
|
||||
{query: "按群名关键词搜索群聊", gold: "chat.search_groups"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.gold, func(t *testing.T) {
|
||||
response, searchErr := engine.Search(context.Background(), cli.ToolSearchRequest{Query: test.query, Limit: 5})
|
||||
if searchErr != nil {
|
||||
t.Fatalf("Search() error = %v", searchErr)
|
||||
}
|
||||
if len(response.Candidates) == 0 || !containsCanonicalAlternative(test.gold, response.Candidates[0].CanonicalPath) {
|
||||
t.Fatalf("Search(%q) candidates = %#v, want %s first", test.query, response.Candidates, test.gold)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func containsCanonicalAlternative(alternatives, candidate string) bool {
|
||||
for _, alternative := range strings.Split(alternatives, "|") {
|
||||
if candidate == alternative {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestToolSearchDeliveryDiagnosticTrustAndChineseSlices(t *testing.T) {
|
||||
registerSchemaRuntimeDelivery()
|
||||
workflows := []cli.ToolSearchWorkflowEvaluationCase{{
|
||||
ID: "chat-send-file-and-check-read",
|
||||
Query: "给群里发文件,并确认群成员是否已读",
|
||||
Subqueries: []string{"以当前用户身份给群聊发送本地文件", "查询指定消息的已读状态和人员"},
|
||||
Required: []string{"chat.send_personal_message", "chat.query_msg_read_status"},
|
||||
}}
|
||||
report, err := cli.BuildDeliveryToolSearchDiagnosticComparison(context.Background(), workflows)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildDeliveryToolSearchDiagnosticComparison() error = %v", err)
|
||||
}
|
||||
// Do not pin absolute Catalog-size counts here: they go stale when main
|
||||
// adds tools. Enforce the size-independent trust contract below; observed
|
||||
// counts are logged for CI forensics and live in the generated comparison
|
||||
// JSON (make generate-tool-search-comparison).
|
||||
t.Logf("intent proxy = %+v excluded_over_budget=%d", report.IntentProxy, report.IntentExcludedOverBudget)
|
||||
t.Logf("language slices = %+v", report.IntentLanguageSlices)
|
||||
t.Logf("context comparison = %+v", report.Context)
|
||||
if report.IntentProxy.Cases == 0 {
|
||||
t.Fatal("intent proxy population is empty; the diagnostic set did not build")
|
||||
}
|
||||
if report.IntentProxy.ZeroResultRate != 0 {
|
||||
t.Fatalf("intent proxy zero-result rate = %v, want every reviewed intent to retrieve something", report.IntentProxy.ZeroResultRate)
|
||||
}
|
||||
for _, slice := range []string{"chinese_only", "mixed_chinese_ascii"} {
|
||||
if report.IntentLanguageSlices[slice].Cases == 0 {
|
||||
t.Fatalf("language slice %q is empty: %+v", slice, report.IntentLanguageSlices)
|
||||
}
|
||||
}
|
||||
identity := report.Trust.Identity
|
||||
// Identity coverage must stay exhaustive over the whole Catalog. Deriving the
|
||||
// expected count from the same report keeps that contract enforced without
|
||||
// hardcoding a tool count.
|
||||
if identity.CanonicalCases != report.Context.ToolCount || identity.NFKCCases != report.Context.ToolCount {
|
||||
t.Fatalf("identity coverage = %+v, want one case per %d Catalog tools", identity, report.Context.ToolCount)
|
||||
}
|
||||
if identity.CanonicalPassRate != 1 || identity.PrimaryCLIPassRate != 1 ||
|
||||
identity.AliasCases == 0 || identity.AliasPassRate != 1 || identity.NFKCPassRate != 1 ||
|
||||
identity.ExactFilteredPassRate != 1 {
|
||||
t.Fatalf("identity trust = %+v", identity)
|
||||
}
|
||||
integrity := report.Trust.Integrity
|
||||
if integrity.CatalogBindingFailures != 0 || integrity.UnknownCandidateCount != 0 || integrity.IneligibleCandidateCount != 0 || integrity.ResponseBudgetViolations != 0 {
|
||||
t.Fatalf("integrity violations = %+v", integrity)
|
||||
}
|
||||
if report.Context.ToolCount == 0 || report.Context.ReductionVsFullSchema < 0.99 {
|
||||
t.Fatalf("context comparison = %+v", report.Context)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaSearchInspectCatalogVersionContract(t *testing.T) {
|
||||
registerSchemaRuntimeDelivery()
|
||||
search := cli.NewSchemaCommand()
|
||||
var searchOutput bytes.Buffer
|
||||
search.SetOut(&searchOutput)
|
||||
search.SetArgs([]string{"search", "--query", "chat.query_msg_read_status"})
|
||||
if err := search.Execute(); err != nil {
|
||||
t.Fatalf("schema search execute: %v", err)
|
||||
}
|
||||
var response cli.ToolSearchResponse
|
||||
if err := json.Unmarshal(searchOutput.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode search response: %v", err)
|
||||
}
|
||||
if response.Strategy != "exact_guard" || len(response.Candidates) != 1 || response.Candidates[0].CanonicalPath != "chat.query_msg_read_status" {
|
||||
t.Fatalf("search response = %#v", response)
|
||||
}
|
||||
|
||||
inspect := cli.NewSchemaCommand()
|
||||
var inspectOutput bytes.Buffer
|
||||
inspect.SetOut(&inspectOutput)
|
||||
inspect.SetArgs([]string{
|
||||
"chat.query_msg_read_status", "--compact",
|
||||
"--expected-source-hash", response.Catalog.SourceHash,
|
||||
"--expected-surface-hash", response.Catalog.SurfaceHash,
|
||||
})
|
||||
if err := inspect.Execute(); err != nil {
|
||||
t.Fatalf("schema inspect execute: %v", err)
|
||||
}
|
||||
var payload cli.SchemaInspectV1Response
|
||||
if err := json.Unmarshal(inspectOutput.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode inspect response: %v", err)
|
||||
}
|
||||
if payload.Version != "schema-inspect.v1" || payload.Catalog != response.Catalog || payload.ToolSpec["canonical_path"] != "chat.query_msg_read_status" {
|
||||
t.Fatalf("inspect envelope = %#v", payload)
|
||||
}
|
||||
|
||||
stale := cli.NewSchemaCommand()
|
||||
stale.SilenceErrors = true
|
||||
stale.SilenceUsage = true
|
||||
stale.SetArgs([]string{
|
||||
"chat.query_msg_read_status", "--compact",
|
||||
"--expected-source-hash", "sha256:stale",
|
||||
"--expected-surface-hash", response.Catalog.SurfaceHash,
|
||||
})
|
||||
err := stale.Execute()
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "catalog_changed" || typed.Category != apperrors.CategoryDiscovery {
|
||||
t.Fatalf("stale inspect error = %#v", err)
|
||||
}
|
||||
|
||||
incomplete := cli.NewSchemaCommand()
|
||||
incomplete.SilenceErrors = true
|
||||
incomplete.SilenceUsage = true
|
||||
incomplete.SetArgs([]string{
|
||||
"chat.query_msg_read_status",
|
||||
"--expected-source-hash", response.Catalog.SourceHash,
|
||||
})
|
||||
err = incomplete.Execute()
|
||||
if !errors.As(err, &typed) || typed.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("incomplete inspect version error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaSearchRejectsIgnoredGlobalOutputFlags(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"schema", "search", "--query", "查询群消息已读状态", "--fields", "canonical_path"},
|
||||
{"schema", "search", "--query", "查询群消息已读状态", "--jq", ".candidates"},
|
||||
{"schema", "search", "--query", "查询群消息已读状态", "--format", "pretty"},
|
||||
} {
|
||||
root := NewRootCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("args %v error = %#v, want validation", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkToolSearchDeliveryChineseQuery measures the shipped search path
|
||||
// against the complete declaration-assembled Catalog. The smaller benchmark
|
||||
// in internal/cli intentionally uses a four-tool fixture and must not be used
|
||||
// as the release-scale latency or allocation number.
|
||||
func BenchmarkToolSearchDeliveryChineseQuery(b *testing.B) {
|
||||
registerSchemaRuntimeDelivery()
|
||||
engine, err := cli.NewDeliveryToolSearchEngineWithConfig(cli.DefaultToolSearchConfig())
|
||||
if err != nil {
|
||||
b.Fatalf("NewDeliveryToolSearchEngineWithConfig() error = %v", err)
|
||||
}
|
||||
request := cli.ToolSearchRequest{Query: "给群里发文件并确认消息已读", Limit: 5, CandidateLimit: 20}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for index := 0; index < b.N; index++ {
|
||||
if _, searchErr := engine.Search(context.Background(), request); searchErr != nil {
|
||||
b.Fatalf("Search() error = %v", searchErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkToolSearchDeliveryDecomposedWorkflow(b *testing.B) {
|
||||
registerSchemaRuntimeDelivery()
|
||||
engine, err := cli.NewDeliveryToolSearchEngineWithConfig(cli.DefaultToolSearchConfig())
|
||||
if err != nil {
|
||||
b.Fatalf("NewDeliveryToolSearchEngineWithConfig() error = %v", err)
|
||||
}
|
||||
request := cli.ToolSearchRequest{Query: "给群里发文件,并确认群成员是否已读", Limit: 5, CandidateLimit: 20}
|
||||
subqueries := []string{"以当前用户身份给群聊发送本地文件", "查询指定消息的已读状态和人员"}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for index := 0; index < b.N; index++ {
|
||||
if _, searchErr := engine.SearchSubqueries(context.Background(), subqueries, request); searchErr != nil {
|
||||
b.Fatalf("SearchSubqueries() error = %v", searchErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkToolSearchDeliveryIndexBuild(b *testing.B) {
|
||||
registerSchemaRuntimeDelivery()
|
||||
config := cli.DefaultToolSearchConfig()
|
||||
// Prime and cache declaration assembly so this benchmark isolates building
|
||||
// the Tool Search projection/index. Cold process startup is measured at the
|
||||
// release-binary boundary and reported separately.
|
||||
if _, err := cli.NewDeliveryToolSearchEngineWithConfig(config); err != nil {
|
||||
b.Fatalf("prime delivery Catalog: %v", err)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for index := 0; index < b.N; index++ {
|
||||
if _, err := cli.NewDeliveryToolSearchEngineWithConfig(config); err != nil {
|
||||
b.Fatalf("NewDeliveryToolSearchEngineWithConfig() error = %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,15 +23,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// SchemaInspectV1Response is the versioned Search -> Inspect wire contract.
|
||||
// Legacy schema queries without expected hashes keep their historical flat
|
||||
// payload for human and script compatibility.
|
||||
type SchemaInspectV1Response struct {
|
||||
Version string `json:"version"`
|
||||
Catalog CatalogVersionRef `json:"catalog"`
|
||||
ToolSpec map[string]any `json:"tool_spec"`
|
||||
}
|
||||
|
||||
// schemaCommandCatalogError / payloads use deliverySchemaCatalog
|
||||
// (RegisterSchemaSourceRoot → ResolveSchemaBuild). There is no committed
|
||||
// Schema Catalog embed fallback.
|
||||
@@ -80,20 +71,12 @@ func NewSchemaCommand() *cobra.Command {
|
||||
Short: "渐进查看命令 Schema (产品 / 分组 / 工具参数)",
|
||||
Long: `查看当前可运行命令的 Schema 元数据。
|
||||
|
||||
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。普通 Agent 查询应使用 --compact:它按稳定字段白名单输出选参、约束、安全语义和已评审的返回契约。省略 --compact 的 full leaf 保留参数映射、接口绑定和 provenance,仅用于定向审计;--all 输出全部工具的完整 leaf Schema,用于审计/CI。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。
|
||||
|
||||
路径形态:多个位置参数会按空格拼接并作为 CLI path 解析,等价于加引号的形式和 --cli-path,例如 dws schema chat message read-status 与 dws schema "chat message read-status" 相同。canonical path 含点号,必须作为单个参数整体传入(dws schema chat.query_msg_read_status);把 canonical 拆成多个 token 会被当作 CLI path 而解析失败。`,
|
||||
Args: cobra.MaximumNArgs(16),
|
||||
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 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 {
|
||||
all, _ := cmd.Flags().GetBool("all")
|
||||
compact, _ := cmd.Flags().GetBool("compact")
|
||||
expectedSourceHash, _ := cmd.Flags().GetString("expected-source-hash")
|
||||
expectedSurfaceHash, _ := cmd.Flags().GetString("expected-surface-hash")
|
||||
if (expectedSourceHash == "") != (expectedSurfaceHash == "") {
|
||||
return apperrors.NewValidation("--expected-source-hash and --expected-surface-hash must be provided together", apperrors.WithReason("incomplete_catalog_version"))
|
||||
}
|
||||
versionedInspect := expectedSourceHash != ""
|
||||
cliPath, _ := cmd.Flags().GetString("cli-path")
|
||||
cliPath = strings.TrimSpace(cliPath)
|
||||
if cliPath != "" && len(args) > 0 {
|
||||
@@ -107,34 +90,10 @@ func NewSchemaCommand() *cobra.Command {
|
||||
}
|
||||
if cliPath != "" {
|
||||
args = []string{cliPath}
|
||||
} else if len(args) > 1 {
|
||||
// Preserve the quoted path form while accepting the equivalent
|
||||
// natural tokenized form: dws schema chat message.
|
||||
args = []string{strings.Join(args, " ")}
|
||||
}
|
||||
if versionedInspect {
|
||||
if all || len(args) == 0 || !compact {
|
||||
return apperrors.NewValidation("versioned Schema inspect requires one tool path and --compact", apperrors.WithReason("invalid_inspect_request"))
|
||||
}
|
||||
for _, name := range []string{"fields", "jq"} {
|
||||
if flag := cmd.Flag(name); flag != nil && flag.Changed {
|
||||
return apperrors.NewValidation("schema-inspect.v1 does not support --"+name, apperrors.WithReason("unsupported_output_filter"))
|
||||
}
|
||||
}
|
||||
if flag := cmd.Flag("format"); flag != nil && flag.Changed && strings.TrimSpace(flag.Value.String()) != "json" {
|
||||
return apperrors.NewValidation("schema-inspect.v1 only supports --format json", apperrors.WithReason("unsupported_output_format"))
|
||||
}
|
||||
}
|
||||
if err := schemaCommandCatalogError(); err != nil {
|
||||
return fmt.Errorf("load typed Schema registry: %w", err)
|
||||
}
|
||||
loaded := deliverySchemaCatalog()
|
||||
if expectedSourceHash != "" && expectedSourceHash != loaded.Snapshot.SourceHash {
|
||||
return apperrors.NewDiscovery("Catalog changed before Schema inspect", apperrors.WithReason("catalog_changed"))
|
||||
}
|
||||
if expectedSurfaceHash != "" && expectedSurfaceHash != loaded.Snapshot.SurfaceHash {
|
||||
return apperrors.NewDiscovery("Catalog surface changed before Schema inspect", apperrors.WithReason("catalog_changed"))
|
||||
}
|
||||
var payload map[string]any
|
||||
var err error
|
||||
if all {
|
||||
@@ -150,22 +109,12 @@ func NewSchemaCommand() *cobra.Command {
|
||||
if compact {
|
||||
payload = stripSchemaPayloadCompact(payload)
|
||||
}
|
||||
if versionedInspect {
|
||||
return output.WriteFiltered(cmd.OutOrStdout(), output.FormatJSON, SchemaInspectV1Response{
|
||||
Version: "schema-inspect.v1",
|
||||
Catalog: CatalogVersionRef{SourceHash: loaded.Snapshot.SourceHash, SurfaceHash: loaded.Snapshot.SurfaceHash},
|
||||
ToolSpec: payload,
|
||||
}, "", "")
|
||||
}
|
||||
return output.WriteFiltered(cmd.OutOrStdout(), output.ResolveFormat(cmd, output.FormatJSON), payload, output.ResolveFields(cmd), output.ResolveJQ(cmd))
|
||||
},
|
||||
}
|
||||
cmd.Flags().Bool("all", false, "输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)")
|
||||
cmd.Flags().Bool("compact", false, "按稳定字段白名单输出 Agent 选参、约束、安全语义和返回契约")
|
||||
cmd.Flags().String("cli-path", "", "按 CLI 命令路径查询")
|
||||
cmd.Flags().String("expected-source-hash", "", "要求 Inspect 使用指定 Catalog source hash")
|
||||
cmd.Flags().String("expected-surface-hash", "", "要求 Inspect 使用指定 Catalog surface hash")
|
||||
cmd.AddCommand(newSchemaSearchCommand())
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -35,12 +35,6 @@ func resetSchemaSourceRootAtomicForTest() {
|
||||
schemaSourceRoot = atomic.Value{}
|
||||
}
|
||||
|
||||
// resetDeliveryToolSearchEngineStateForTest drops the memoized default Tool
|
||||
// Search engine so a test can rebuild it against a freshly installed Catalog.
|
||||
func resetDeliveryToolSearchEngineStateForTest() {
|
||||
resetDeliveryToolSearchEngineState()
|
||||
}
|
||||
|
||||
// clearDeclaredDryRunCapabilitiesForTest resets the declared index (tests only).
|
||||
func clearDeclaredDryRunCapabilitiesForTest() {
|
||||
declaredDryRunCapabilities.Range(func(key, _ any) bool {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,242 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPrimaryParamMigrationKeepsConceptDirectionCommandScoped(t *testing.T) {
|
||||
concepts, err := LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
}
|
||||
if got := concepts.ByConcept["content_text"].CanonicalHint; got != "text" {
|
||||
t.Fatalf("content_text canonical hint = %q, want unchanged global hint text", got)
|
||||
}
|
||||
if got := concepts.ByConcept["open_conversation_id"].CanonicalHint; got != "conversation-id" {
|
||||
t.Fatalf("open_conversation_id canonical hint = %q, want unchanged global hint conversation-id", got)
|
||||
}
|
||||
|
||||
content := concepts.ByConcept["content_text"]
|
||||
docNode := concepts.ByConcept["doc_node_id"]
|
||||
docAppend, problems := reduceLeafParamAliases(
|
||||
"doc +doc-append",
|
||||
realMap(
|
||||
realFlag{name: "content"},
|
||||
realFlag{name: "text", hidden: true},
|
||||
realFlag{name: "doc"},
|
||||
),
|
||||
[]Concept{content, docNode},
|
||||
CommandOverride{},
|
||||
)
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("doc +doc-append reduction problems = %v", problems)
|
||||
}
|
||||
assertPrimaryParamAlias(t, docAppend, "body", "content")
|
||||
assertPrimaryParamAlias(t, docAppend, "node", "doc")
|
||||
|
||||
insertOverride := primaryParamOverride(t, concepts, "doc block insert")
|
||||
docInsert, problems := reduceLeafParamAliases(
|
||||
"doc block insert",
|
||||
realMap(
|
||||
realFlag{name: "content"},
|
||||
realFlag{name: "text", hidden: true},
|
||||
realFlag{name: "parent-block"},
|
||||
realFlag{name: "ref-block"},
|
||||
),
|
||||
[]Concept{content},
|
||||
insertOverride,
|
||||
)
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("doc block insert reduction problems = %v", problems)
|
||||
}
|
||||
assertPrimaryParamAlias(t, docInsert, "body", "content")
|
||||
|
||||
docUpdate, problems := reduceLeafParamAliases(
|
||||
"doc block update",
|
||||
realMap(realFlag{name: "content"}, realFlag{name: "text", hidden: true}),
|
||||
[]Concept{content},
|
||||
CommandOverride{},
|
||||
)
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("doc block update reduction problems = %v", problems)
|
||||
}
|
||||
assertPrimaryParamAlias(t, docUpdate, "body", "content")
|
||||
|
||||
replyOverride := primaryParamOverride(t, concepts, "chat +messages-reply")
|
||||
chatReply, problems := reduceLeafParamAliases(
|
||||
"chat +messages-reply",
|
||||
realMap(
|
||||
realFlag{name: "group"},
|
||||
realFlag{name: "conversation-id", hidden: true},
|
||||
realFlag{name: "ref-msg-id"},
|
||||
),
|
||||
[]Concept{concepts.ByConcept["open_conversation_id"]},
|
||||
replyOverride,
|
||||
)
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("chat +messages-reply reduction problems = %v", problems)
|
||||
}
|
||||
for _, emitted := range []string{"chat", "chat-id", "open-conversation-id"} {
|
||||
assertPrimaryParamAlias(t, chatReply, emitted, "group")
|
||||
}
|
||||
assertPrimaryParamAlias(t, chatReply, "msg-id", "ref-msg-id")
|
||||
if chatReply.IsBlocked("group") {
|
||||
t.Fatal("chat +messages-reply canonical --group must not remain blocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimaryParamMigrationUpdatesReviewedFixturesAndMappingKeys(t *testing.T) {
|
||||
concepts, err := LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
}
|
||||
fixtures := []ParamFixtureCase{
|
||||
{Command: "doc +doc-append", Emitted: "text", Expect: "content", Via: "native:reviewed-compatibility-alias"},
|
||||
{Command: "doc +doc-append", Emitted: "node", Expect: "doc", Via: "concept:doc_node_id"},
|
||||
{Command: "doc block insert", Emitted: "text", Expect: "content", Via: "native:reviewed-compatibility-alias", Occ: 2},
|
||||
{Command: "doc block update", Emitted: "text", Expect: "content", Via: "native:reviewed-compatibility-alias", Occ: 6},
|
||||
{Command: "chat message send", Emitted: "file-path", Expect: "file", Via: "native:reviewed-compatibility-alias"},
|
||||
{Command: "chat +messages-reply", Emitted: "conversation-id", Expect: "group", Via: "native:reviewed-compatibility-alias"},
|
||||
{Command: "chat +messages-reply", Emitted: "chat", Expect: "group", Via: "concept:open_conversation_id"},
|
||||
{Command: "chat +messages-reply", Emitted: "open-conversation-id", Expect: "group", Via: "concept:open_conversation_id"},
|
||||
}
|
||||
for _, want := range fixtures {
|
||||
found := false
|
||||
for _, got := range concepts.Fixture {
|
||||
if got == want {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("reviewed fixture missing %#v", want)
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
oldKey string
|
||||
newKey string
|
||||
reason string
|
||||
}{
|
||||
{"chat.reply_personal_message --text", "chat.reply_personal_message --content", "serialized into the aggregate content JSON string"},
|
||||
{"chat.send_personal_message --file-path", "chat.send_personal_message --file", "local upload/preprocessing input"},
|
||||
{"chat.send_personal_message --text", "chat.send_personal_message --content", "serialized into the aggregate content payload"},
|
||||
{"doc.insert_document_block --text", "doc.insert_document_block --content", "aggregate convenience input used to build element"},
|
||||
{"doc.update_document_block --text", "doc.update_document_block --content", "aggregate convenience input used to build element"},
|
||||
{"todo.add_todo_attachment --file-path", "todo.add_todo_attachment --file", "local upload input used to construct attachmentList"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if _, exists := reviewedSchemaParameterMappingExclusions[test.oldKey]; exists {
|
||||
t.Errorf("stale mapping exclusion remains at %q", test.oldKey)
|
||||
}
|
||||
if got := reviewedSchemaParameterMappingExclusions[test.newKey]; got != test.reason {
|
||||
t.Errorf("mapping exclusion %q reason = %q, want %q", test.newKey, got, test.reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimaryParamMigrationPreservesDeliverySchemaSignatures(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
primary string
|
||||
legacy string
|
||||
required bool
|
||||
cliRequired bool
|
||||
property string
|
||||
propertyFrom string
|
||||
format string
|
||||
requiredWhen string
|
||||
requiredWhenFrom string
|
||||
}{
|
||||
{path: "aisearch person", primary: "query", legacy: "keyword", required: true, property: "keyword", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat message reply", primary: "content", legacy: "text", required: true, cliRequired: true, propertyFrom: "reviewed_mapping_exclusion", requiredWhenFrom: "default"},
|
||||
{path: "chat message send", primary: "content", legacy: "text", propertyFrom: "reviewed_mapping_exclusion", requiredWhenFrom: "default"},
|
||||
{path: "chat message send", primary: "file", legacy: "file-path", propertyFrom: "reviewed_mapping_exclusion", format: "file-path", requiredWhen: "msg-type is file or audio or video", requiredWhenFrom: "typed_parameter_metadata"},
|
||||
{path: "chat message send-by-webhook", primary: "content", legacy: "text", required: true, cliRequired: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat +broadcast", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat +dm", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat +send-to-group", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat +messages-send-by-bot", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat +messages-batch-send-by-bot", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat +messages-send-by-webhook", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat +messages-reply", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat +messages-reply", primary: "group", legacy: "conversation-id", required: true, property: "conversationId", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "doc +doc-append", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "doc block insert", primary: "content", legacy: "text", propertyFrom: "reviewed_mapping_exclusion", requiredWhenFrom: "default"},
|
||||
{path: "doc block update", primary: "content", legacy: "text", propertyFrom: "reviewed_mapping_exclusion", requiredWhenFrom: "default"},
|
||||
{path: "todo task add-attachment", primary: "file", legacy: "file-path", required: true, propertyFrom: "reviewed_mapping_exclusion", requiredWhenFrom: "default"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.path+"/"+test.primary, func(t *testing.T) {
|
||||
leaf, err := queryDeliverySchemaPayload([]string{test.path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parameters := schemaMap(leaf["parameters"])
|
||||
parameter, exists := parameters[test.primary]
|
||||
if !exists {
|
||||
t.Fatalf("%s missing visible Primary --%s", test.path, test.primary)
|
||||
}
|
||||
if _, exists := parameters[test.legacy]; exists {
|
||||
t.Fatalf("%s unexpectedly publishes hidden legacy --%s", test.path, test.legacy)
|
||||
}
|
||||
if got := parameter["type"]; got != "string" {
|
||||
t.Errorf("%s --%s type = %#v, want string", test.path, test.primary, got)
|
||||
}
|
||||
if got := parameter["required"]; got != test.required {
|
||||
t.Errorf("%s --%s required = %#v, want %v", test.path, test.primary, got, test.required)
|
||||
}
|
||||
if got, _ := parameter["cli_required"].(bool); got != test.cliRequired {
|
||||
t.Errorf("%s --%s cli_required = %#v, want %v", test.path, test.primary, parameter["cli_required"], test.cliRequired)
|
||||
}
|
||||
if got, _ := parameter["property"].(string); got != test.property {
|
||||
t.Errorf("%s --%s property = %q, want %q", test.path, test.primary, got, test.property)
|
||||
}
|
||||
if got := parameter["interface_type"]; got != nil && got != "" {
|
||||
t.Errorf("%s --%s interface_type = %#v, want omitted/empty", test.path, test.primary, got)
|
||||
}
|
||||
if got, _ := parameter["format"].(string); got != test.format {
|
||||
t.Errorf("%s --%s format = %q, want %q", test.path, test.primary, got, test.format)
|
||||
}
|
||||
if got, _ := parameter["required_when"].(string); got != test.requiredWhen {
|
||||
t.Errorf("%s --%s required_when = %q, want %q", test.path, test.primary, got, test.requiredWhen)
|
||||
}
|
||||
|
||||
provenance := schemaMap(parameter["field_provenance"])
|
||||
propertyProvenance := provenance["property"]
|
||||
if got := propertyProvenance["source"]; got != test.propertyFrom {
|
||||
t.Errorf("%s --%s property source = %#v, want %s", test.path, test.primary, got, test.propertyFrom)
|
||||
}
|
||||
typeProvenance := provenance["type"]
|
||||
if got := typeProvenance["source"]; got != "cobra_flag_type" {
|
||||
t.Errorf("%s --%s type source = %#v, want cobra_flag_type", test.path, test.primary, got)
|
||||
}
|
||||
requiredWhenProvenance := provenance["required_when"]
|
||||
if got := requiredWhenProvenance["source"]; got != test.requiredWhenFrom {
|
||||
t.Errorf("%s --%s required_when source = %#v, want %s", test.path, test.primary, got, test.requiredWhenFrom)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func primaryParamOverride(t *testing.T, concepts ParamConcepts, path string) CommandOverride {
|
||||
t.Helper()
|
||||
for _, override := range concepts.Overrides {
|
||||
if override.CommandPath == path {
|
||||
return override
|
||||
}
|
||||
}
|
||||
t.Fatalf("command override %q not found", path)
|
||||
return CommandOverride{}
|
||||
}
|
||||
|
||||
func assertPrimaryParamAlias(t *testing.T, entry *ParamAliasEntry, emitted, want string) {
|
||||
t.Helper()
|
||||
if entry == nil {
|
||||
t.Fatalf("alias entry is nil; want %s -> %s", emitted, want)
|
||||
}
|
||||
if got, ok := entry.ResolveAlias(emitted); !ok || got != want {
|
||||
t.Fatalf("ResolveAlias(%q) = %q (ok=%v), want %q", emitted, got, ok, want)
|
||||
}
|
||||
}
|
||||
@@ -1058,7 +1058,7 @@ var schemaCompactPayloadKeys = map[string]bool{
|
||||
"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,
|
||||
"parameters": true, "constraints": true, "positionals": true, "dry_run": true, "wait": true,
|
||||
"result": true, "pagination": true,
|
||||
"examples": true, "use_when": true, "avoid_when": true,
|
||||
}
|
||||
|
||||
@@ -325,10 +325,7 @@ func schemaPayloadFromLoadedCatalog(loaded loadedSchemaCatalog, args []string) (
|
||||
}
|
||||
}
|
||||
}
|
||||
// The message carries the path-form boundary because it is the one surface
|
||||
// an Agent is guaranteed to see at failure time.
|
||||
return nil, apperrors.NewValidation("unknown runtime schema path " + strconvQuote(raw) +
|
||||
": canonical paths contain dots and must be a single argument (e.g. dws schema chat.query_msg_read_status), while space-separated tokens resolve as a CLI path")
|
||||
return nil, apperrors.NewValidation("unknown runtime schema path " + strconvQuote(raw))
|
||||
}
|
||||
|
||||
func schemaCatalogSnapshotHash(snapshot SchemaCatalogSnapshot) string {
|
||||
|
||||
@@ -81,6 +81,7 @@ var schemaCatalogToolOptionalKeys = []string{
|
||||
"pagination",
|
||||
"positionals",
|
||||
"result",
|
||||
"wait",
|
||||
}
|
||||
|
||||
var schemaCatalogToolEnums = map[string][]string{
|
||||
|
||||
@@ -51,7 +51,7 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
|
||||
"profile list",
|
||||
"profile switch",
|
||||
"profile use",
|
||||
"schema search",
|
||||
"schema",
|
||||
"skill get",
|
||||
"skill install",
|
||||
"skill search",
|
||||
|
||||
@@ -68,9 +68,9 @@ func init() {
|
||||
registerRequireOneOf("devdoc.search_open_platform_docs_rag", "query", "keyword")
|
||||
registerRequireOneOf("event.consume", "event_key", "subscribe-id")
|
||||
registerExclusiveOneOf("event.stop", "all", "subscribe_id")
|
||||
registerRequireOneOf("doc.insert_document_block", "text", "heading", "element")
|
||||
registerRequireOneOf("doc.insert_document_block", "content", "heading", "element")
|
||||
registerExclusiveOneOf("doc.update_document", "content", "content-file")
|
||||
registerRequireOneOf("doc.update_document_block", "text", "heading", "element")
|
||||
registerRequireOneOf("doc.update_document_block", "content", "heading", "element")
|
||||
registerRequireOneOf("pat.batch_grant", "scope", "product", "products", "domain", "domains", "recommend")
|
||||
registerRequireOneOf("mail.search_mail_users", "keyword", "employee-no")
|
||||
// --body is a hidden compatibility alias for the public --content flag.
|
||||
|
||||
@@ -60,6 +60,7 @@ type ToolSpec struct {
|
||||
Constraints RuntimeSchemaConstraints
|
||||
Positionals []contract.RuntimeSchemaPositional
|
||||
DryRun *contract.DryRunSpec
|
||||
Wait *contract.WaitSpec
|
||||
Result *contract.ResultSpec
|
||||
Pagination *contract.PaginationSpec
|
||||
Safety contract.SafetySpec
|
||||
@@ -135,6 +136,7 @@ type RuntimeToolSpecInput struct {
|
||||
Constraints RuntimeSchemaConstraints
|
||||
Positionals []contract.RuntimeSchemaPositional
|
||||
DryRun *contract.DryRunSpec
|
||||
Wait *contract.WaitSpec
|
||||
Result *contract.ResultSpec
|
||||
Pagination *contract.PaginationSpec
|
||||
Safety contract.SafetySpec
|
||||
@@ -542,6 +544,11 @@ func (t ToolSpec) Validate() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if t.Wait != nil {
|
||||
if err := t.Wait.Validate(id.CanonicalPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if t.Result != nil {
|
||||
if _, err := contract.NormalizeResultSpec(t.Result, id.CanonicalPath); err != nil {
|
||||
return err
|
||||
@@ -744,6 +751,16 @@ func (t ToolSpec) normalized() ToolSpec {
|
||||
dryRun.PreviewKind = strings.TrimSpace(dryRun.PreviewKind)
|
||||
out.DryRun = &dryRun
|
||||
}
|
||||
if t.Wait != nil {
|
||||
// NormalizeWaitSpec is the single canonical form shared with the
|
||||
// declaration path: trimmed status values, duplicate/conflict
|
||||
// rejection, defensive copy. Invalid declarations are rejected by
|
||||
// ToolSpec.Validate below, which runs the same normalization
|
||||
// through WaitSpec.Validate.
|
||||
if wait, err := contract.NormalizeWaitSpec(t.Wait, id.CanonicalPath); err == nil {
|
||||
out.Wait = wait
|
||||
}
|
||||
}
|
||||
if t.Result != nil {
|
||||
result, err := contract.NormalizeResultSpec(t.Result, id.CanonicalPath)
|
||||
if err == nil {
|
||||
@@ -982,6 +999,10 @@ func (t ToolSpec) ToPayload() (map[string]any, error) {
|
||||
value, _ := typedJSONValue(t.DryRun)
|
||||
payload["dry_run"] = value
|
||||
}
|
||||
if t.Wait != nil {
|
||||
value, _ := typedJSONValue(t.Wait)
|
||||
payload["wait"] = value
|
||||
}
|
||||
if t.Result != nil {
|
||||
value, _ := typedJSONValue(t.Result)
|
||||
payload["result"] = value
|
||||
|
||||
@@ -974,3 +974,57 @@ func TestFinalProvenanceCoverageDoesNotInventOptionalInterfaceReason(t *testing.
|
||||
t.Fatalf("optional local interface_reason should not require invented provenance: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolSpecWaitCapabilityIsPositiveOnly(t *testing.T) {
|
||||
base := RuntimeToolSpecInput{Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "sample",
|
||||
Name: "waitrun",
|
||||
CLIName: "waitrun",
|
||||
CLIPath: "sample waitrun",
|
||||
}}
|
||||
|
||||
withoutCapability, err := ToolSpecFromRuntime(base)
|
||||
if err != nil {
|
||||
t.Fatalf("ToolSpecFromRuntime() error = %v", err)
|
||||
}
|
||||
payload, err := withoutCapability.ToPayload()
|
||||
if err != nil {
|
||||
t.Fatalf("ToPayload() error = %v", err)
|
||||
}
|
||||
if _, ok := payload["wait"]; ok {
|
||||
t.Fatalf("nil capability unexpectedly emitted wait: %#v", payload["wait"])
|
||||
}
|
||||
|
||||
base.Wait = &contract.WaitSpec{Mode: "webhook"}
|
||||
if _, err := ToolSpecFromRuntime(base); err == nil || !strings.Contains(err.Error(), "unknown mode") {
|
||||
t.Fatalf("invalid mode error = %v", err)
|
||||
}
|
||||
base.Wait = &contract.WaitSpec{Mode: contract.WaitModeEvent, StatusQuery: "status", Terminal: map[string]contract.ResultOutcome{"DONE": contract.ResultOutcomeSuccess}}
|
||||
if _, err := ToolSpecFromRuntime(base); err == nil || !strings.Contains(err.Error(), "requires event_key") {
|
||||
t.Fatalf("event mode body error = %v", err)
|
||||
}
|
||||
|
||||
base.Wait = &contract.WaitSpec{
|
||||
Mode: contract.WaitModePoll,
|
||||
PollCommand: "sample status get",
|
||||
StatusQuery: "result.status",
|
||||
Terminal: map[string]contract.ResultOutcome{"COMPLETED": contract.ResultOutcomeSuccess},
|
||||
PendingValues: []string{"NEW"},
|
||||
DefaultTimeoutSecs: 120,
|
||||
}
|
||||
withCapability, err := ToolSpecFromRuntime(base)
|
||||
if err != nil {
|
||||
t.Fatalf("ToolSpecFromRuntime(valid wait) error = %v", err)
|
||||
}
|
||||
if withCapability.Wait == nil || withCapability.Wait.Mode != contract.WaitModePoll {
|
||||
t.Fatalf("wait capability lost through normalization: %#v", withCapability.Wait)
|
||||
}
|
||||
payload, err = withCapability.ToPayload()
|
||||
if err != nil {
|
||||
t.Fatalf("ToPayload(valid wait) error = %v", err)
|
||||
}
|
||||
wait := payload["wait"].(map[string]any)
|
||||
if wait["mode"] != contract.WaitModePoll || wait["poll_command"] != "sample status get" {
|
||||
t.Fatalf("wait payload=%#v", wait)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,20 +269,20 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"chat.update_text_emotion --conversation-id": "Reviewed unpinned adapter: chat.update_text_emotion has no singular pinned interface_ref; --conversation-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.reply_personal_message --ref-msg-id": "serialized into the aggregate content JSON string",
|
||||
"chat.reply_personal_message --ref-sender": "resolved then serialized into the aggregate content JSON string",
|
||||
"chat.reply_personal_message --text": "serialized into the aggregate content JSON string",
|
||||
"chat.reply_personal_message --content": "serialized into the aggregate content JSON string",
|
||||
"chat.search_groups --cursor": "Reviewed unpinned adapter: chat.search_groups has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.search_groups --exclude-muted": "Reviewed unpinned adapter: chat.search_groups has no singular pinned interface_ref; --exclude-muted is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.search_groups --limit": "Reviewed unpinned adapter: chat.search_groups has no singular pinned interface_ref; --limit is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.search_messages --user": "conditional wrapper: parseCSVValues + appendChatIDArgs routes each supplied identifier to senderUserIds or senderOpenDingTakIds according to its runtime ID shape; there is no single RPC property for this flag",
|
||||
"chat.search_messages --users": "conditional wrapper/alias of --user: parseCSVValues + appendChatIDArgs routes each supplied identifier to senderUserIds or senderOpenDingTakIds according to its runtime ID shape; there is no single RPC property for this flag",
|
||||
"chat.send_personal_message --contact-id": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --file-path": "local upload/preprocessing input",
|
||||
"chat.send_personal_message --file": "local upload/preprocessing input",
|
||||
"chat.send_personal_message --latitude": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --location-name": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --longitude": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --map-thumbnail-url": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --media-id": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --text": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --content": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --title": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --user": "resolves a userId to receiverOpenDingTalkId when possible and otherwise uses the unpinned receiverUserId compatibility property",
|
||||
"chat.set_group_member_mute_list --user": "conditional wrapper/alias of --users: userIds are remotely resolved to openDingTalkIds outside dry-run and otherwise may be sent as uids; openDingTalkId inputs are sent as openDingTalkIds, so one exact property would misdescribe execution",
|
||||
@@ -406,7 +406,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"doc.insert_document_block --fix-jsonml": "local JSONML normalization control",
|
||||
"doc.insert_document_block --heading": "aggregate convenience input used to build element",
|
||||
"doc.insert_document_block --level": "aggregate convenience input used to build element",
|
||||
"doc.insert_document_block --text": "aggregate convenience input used to build element",
|
||||
"doc.insert_document_block --content": "aggregate convenience input used to build element",
|
||||
"doc.list_document_blocks --block-id": "runtime extension sends blockId, which is absent from the pinned list_document_blocks metadata",
|
||||
"doc.reply_comment --mentioned-open-conversation-id": "Runtime extension sends mentionedOpenConversationIds, which is absent from the immutable pinned reply_comment metadata at its declared source revision.",
|
||||
"doc.style_background_clear --node": "Reviewed unpinned adapter: doc.style_background_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -438,7 +438,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"doc.update_document_block --fix-jsonml": "local JSONML normalization control",
|
||||
"doc.update_document_block --heading": "aggregate convenience input used to build element",
|
||||
"doc.update_document_block --level": "aggregate convenience input used to build element",
|
||||
"doc.update_document_block --text": "aggregate convenience input used to build element",
|
||||
"doc.update_document_block --content": "aggregate convenience input used to build element",
|
||||
"doc.version_list --cursor": "Reviewed unpinned adapter: doc.version_list has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"doc.version_list --limit": "Reviewed unpinned adapter: doc.version_list has no singular pinned interface_ref; --limit is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"doc.version_list --node": "Reviewed unpinned adapter: doc.version_list has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -659,7 +659,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"sheet.write_image --file": "local upload input used to obtain resourceId/resourceUrl",
|
||||
"sheet.write_image --mime-type": "local upload metadata",
|
||||
"sheet.write_image --name": "local upload metadata",
|
||||
"todo.add_todo_attachment --file-path": "local upload input used to construct attachmentList",
|
||||
"todo.add_todo_attachment --file": "local upload input used to construct attachmentList",
|
||||
"todo.get_user_todos_in_current_org --query-all": "Local route selector: the default path calls get_user_todos_in_current_org, while --query-all switches to get_user_todos; it is not a property of the pinned default RPC.",
|
||||
"todo.list_todo_attachment --task-id": "Reviewed unpinned adapter: --task-id is nested under todoAttachmentListRequest at runtime, while the immutable pinned MCP snapshot has no interface_ref for todo.list_todo_attachment.",
|
||||
"wiki.create_wikiSpace --icon": "runtime extension sends icon, which is absent from the pinned create_wikiSpace metadata",
|
||||
|
||||
@@ -77,7 +77,7 @@ func cloneRuntimeSchemaStringMap(source map[string]string) map[string]string {
|
||||
|
||||
func init() {
|
||||
RegisterRuntimeSchemaParameterMetadata("aisearch.enterprise_person_search", RuntimeSchemaParameterMetadata{
|
||||
Required: []string{"keyword"},
|
||||
Required: []string{"query"},
|
||||
})
|
||||
RegisterRuntimeSchemaParameterMetadata("aitable.export_data", RuntimeSchemaParameterMetadata{
|
||||
RequiredWhen: map[string]string{
|
||||
@@ -146,8 +146,8 @@ func init() {
|
||||
})
|
||||
RegisterRuntimeSchemaParameterMetadata("chat.send_personal_message", RuntimeSchemaParameterMetadata{
|
||||
RequiredWhen: map[string]string{
|
||||
"media-id": "msg-type is image",
|
||||
"file-path": "msg-type is file or audio or video",
|
||||
"media-id": "msg-type is image",
|
||||
"file": "msg-type is file or audio or video",
|
||||
},
|
||||
Examples: map[string]string{"media-id": "@lADP_schema_smoke"},
|
||||
})
|
||||
|
||||
@@ -354,6 +354,7 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
|
||||
Constraints: constraints,
|
||||
Positionals: positionals,
|
||||
DryRun: final.DryRun,
|
||||
Wait: final.Wait,
|
||||
Result: result,
|
||||
Pagination: pagination,
|
||||
Safety: safety,
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ToolSearchV1Request is the stable stdin transport used by Agent hosts.
|
||||
type ToolSearchV1Request struct {
|
||||
Version string `json:"version"`
|
||||
Query string `json:"query"`
|
||||
Subqueries []string `json:"subqueries,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
CandidateLimit int `json:"candidate_limit,omitempty"`
|
||||
ProductIDs []string `json:"product_ids,omitempty"`
|
||||
Effects []string `json:"effects,omitempty"`
|
||||
ExcludeCanonical []string `json:"exclude_canonical,omitempty"`
|
||||
}
|
||||
|
||||
var schemaSearchNewEngine = NewDeliveryToolSearchEngineWithConfig
|
||||
|
||||
// schemaSearchEngineConfig resolves the search config the same way
|
||||
// NewDeliveryToolSearchEngine does (defaults + DWS_TOOL_SEARCH_* env
|
||||
// overrides), then lets an explicit --explain flag win over the env value.
|
||||
func schemaSearchEngineConfig(cmd *cobra.Command) ToolSearchConfig {
|
||||
config := ResolveToolSearchConfigFromEnv(DefaultToolSearchConfig())
|
||||
if explain, _ := cmd.Flags().GetBool("explain"); explain {
|
||||
config.Explain = true
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func newSchemaSearchCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "search",
|
||||
Short: "按自然语言搜索可用命令 Schema",
|
||||
Long: `按自然语言或精确身份检索 ToolReference。
|
||||
|
||||
输出固定为完整、紧凑的 tool-search.v1 JSON envelope。为保证版本、Catalog
|
||||
绑定和响应字节预算可验证,本命令不支持 --fields、--jq 或非 JSON --format。
|
||||
|
||||
DWS 对 Agent 是一个元工具:已知子命令直接执行,未知子命令才使用 search。
|
||||
选中候选后,必须使用 response catalog 的 source/surface hash Inspect canonical_path,
|
||||
再按 primary_cli_path 执行;Search 结果本身不授权、不执行。`,
|
||||
Example: ` dws schema search --query "查询群消息已读状态"
|
||||
dws schema chat.query_msg_read_status --compact --format json \
|
||||
--expected-source-hash "<search.catalog.source_hash>" \
|
||||
--expected-surface-hash "<search.catalog.surface_hash>"
|
||||
printf '%s' '{"version":"tool-search.v1","query":"发群文件并确认已读","subqueries":["给群里发送文件消息","查询群消息已读状态"]}' |
|
||||
dws schema search --request-json -`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateSchemaSearchOutputFlags(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := schemaSearchRequest(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
engine, err := schemaSearchNewEngine(schemaSearchEngineConfig(cmd))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
internal := ToolSearchRequest{
|
||||
Query: request.Query,
|
||||
Limit: request.Limit,
|
||||
CandidateLimit: request.CandidateLimit,
|
||||
ProductIDs: request.ProductIDs,
|
||||
Effects: request.Effects,
|
||||
ExcludeCanonicalPaths: request.ExcludeCanonical,
|
||||
}
|
||||
var response ToolSearchResponse
|
||||
if len(request.Subqueries) > 0 {
|
||||
response, err = engine.SearchSubqueries(cmd.Context(), request.Subqueries, internal)
|
||||
} else {
|
||||
response, err = engine.Search(cmd.Context(), internal)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The response budget is defined over this compact wire encoding. Do
|
||||
// not run it through pretty/table formatting or post-filtering because
|
||||
// Agent hosts require one complete versioned envelope.
|
||||
if err := json.NewEncoder(cmd.OutOrStdout()).Encode(response); err != nil {
|
||||
return fmt.Errorf("encode tool-search.v1 response: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("query", "", "自然语言意图或 canonical/CLI path")
|
||||
cmd.Flags().Int("limit", defaultToolSearchLimit, fmt.Sprintf("返回引用数量(1-%d;0 表示沿用默认值 %d)", maxToolSearchLimit, defaultToolSearchLimit))
|
||||
cmd.Flags().Int("candidate-limit", defaultToolSearchCandidateLimit, fmt.Sprintf("每路召回候选数量(1-%d;0 表示沿用默认值 %d)", maxToolSearchCandidateLimit, defaultToolSearchCandidateLimit))
|
||||
cmd.Flags().StringSlice("product", nil, "限制产品 ID,可重复或逗号分隔")
|
||||
cmd.Flags().StringSlice("effect", nil, "限制 effect,可重复或逗号分隔")
|
||||
cmd.Flags().StringSlice("exclude", nil, "排除 canonical path,可重复或逗号分隔")
|
||||
cmd.Flags().String("request-json", "", `从 stdin 读取 JSON;值必须为 -;必含 version="tool-search.v1",动作分解字段为 subqueries`)
|
||||
cmd.Flags().Bool("explain", false, "开发诊断:输出 score_breakdown 含原始排序分(公开默认响应不含分数;仅用于离线调参,不建议线上 Agent 使用;会增大响应体积,建议配合 --limit 3)。score_breakdown.score 是未量化的原始分,排序键使用量化到 1e-9 的值,两者末位可能不同,不要用它复算候选顺序")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func validateSchemaSearchOutputFlags(cmd *cobra.Command) error {
|
||||
for _, name := range []string{"fields", "jq"} {
|
||||
if flag := cmd.Flag(name); flag != nil && flag.Changed {
|
||||
return apperrors.NewValidation(
|
||||
"schema search returns one complete tool-search.v1 envelope and does not support --"+name,
|
||||
apperrors.WithReason("unsupported_output_filter"),
|
||||
)
|
||||
}
|
||||
}
|
||||
if flag := cmd.Flag("format"); flag != nil && flag.Changed && strings.TrimSpace(flag.Value.String()) != "json" {
|
||||
return apperrors.NewValidation(
|
||||
"schema search only supports --format json",
|
||||
apperrors.WithReason("unsupported_output_format"),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func schemaSearchRequest(cmd *cobra.Command) (ToolSearchV1Request, error) {
|
||||
requestJSON, _ := cmd.Flags().GetString("request-json")
|
||||
requestJSON = strings.TrimSpace(requestJSON)
|
||||
if requestJSON != "" {
|
||||
if requestJSON != "-" {
|
||||
return ToolSearchV1Request{}, apperrors.NewValidation("--request-json only accepts - (stdin)", apperrors.WithReason("invalid_transport"))
|
||||
}
|
||||
for _, name := range []string{"query", "limit", "candidate-limit", "product", "effect", "exclude"} {
|
||||
if cmd.Flags().Changed(name) {
|
||||
return ToolSearchV1Request{}, apperrors.NewValidation("--request-json cannot be combined with --"+name, apperrors.WithReason("ambiguous_input"))
|
||||
}
|
||||
}
|
||||
request, err := decodeToolSearchV1Request(cmd.InOrStdin())
|
||||
if err != nil {
|
||||
return ToolSearchV1Request{}, err
|
||||
}
|
||||
return validateToolSearchV1Request(request)
|
||||
}
|
||||
query, _ := cmd.Flags().GetString("query")
|
||||
limit, _ := cmd.Flags().GetInt("limit")
|
||||
candidateLimit, _ := cmd.Flags().GetInt("candidate-limit")
|
||||
products, _ := cmd.Flags().GetStringSlice("product")
|
||||
effects, _ := cmd.Flags().GetStringSlice("effect")
|
||||
excluded, _ := cmd.Flags().GetStringSlice("exclude")
|
||||
return validateToolSearchV1Request(ToolSearchV1Request{
|
||||
Version: "tool-search.v1",
|
||||
Query: query,
|
||||
Limit: limit,
|
||||
CandidateLimit: candidateLimit,
|
||||
ProductIDs: products,
|
||||
Effects: effects,
|
||||
ExcludeCanonical: excluded,
|
||||
})
|
||||
}
|
||||
|
||||
func decodeToolSearchV1Request(reader io.Reader) (ToolSearchV1Request, error) {
|
||||
payload, err := io.ReadAll(io.LimitReader(reader, maxToolSearchRequestBytes+1))
|
||||
if err != nil {
|
||||
return ToolSearchV1Request{}, apperrors.NewValidation("read tool search request: "+err.Error(), apperrors.WithReason("read_request_failed"))
|
||||
}
|
||||
if len(payload) > maxToolSearchRequestBytes {
|
||||
return ToolSearchV1Request{}, apperrors.NewValidation(fmt.Sprintf("tool search request exceeds %d bytes", maxToolSearchRequestBytes), apperrors.WithReason("request_too_large"))
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.DisallowUnknownFields()
|
||||
var request ToolSearchV1Request
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
return ToolSearchV1Request{}, apperrors.NewValidation("decode tool-search.v1 request: "+err.Error(), apperrors.WithReason("invalid_request_json"))
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||
if err == nil {
|
||||
return ToolSearchV1Request{}, apperrors.NewValidation("tool search request must contain exactly one JSON object", apperrors.WithReason("invalid_request_json"))
|
||||
}
|
||||
return ToolSearchV1Request{}, apperrors.NewValidation("decode trailing tool search input: "+err.Error(), apperrors.WithReason("invalid_request_json"))
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func validateToolSearchV1Request(request ToolSearchV1Request) (ToolSearchV1Request, error) {
|
||||
if request.Version != "tool-search.v1" {
|
||||
return ToolSearchV1Request{}, apperrors.NewValidation(`tool search request must include "version":"tool-search.v1"; action decomposition uses the "subqueries" array`, apperrors.WithReason("unsupported_version"))
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestSchemaSearchCommandExactAndVersioned(t *testing.T) {
|
||||
installSchemaSearchTestEngine(t)
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.AddCommand(NewSchemaCommand())
|
||||
var stdout bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetArgs([]string{"schema", "search", "--query", "chat.read_status"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("schema search execute: %v", err)
|
||||
}
|
||||
var response ToolSearchResponse
|
||||
if err := json.Unmarshal(stdout.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode response: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if response.Version != "tool-search.v1" || response.Strategy != "exact_guard" {
|
||||
t.Fatalf("response = %#v", response)
|
||||
}
|
||||
if len(response.Candidates) != 1 || response.Candidates[0].CanonicalPath != "chat.read_status" {
|
||||
t.Fatalf("candidates = %#v", response.Candidates)
|
||||
}
|
||||
wantCatalog := CatalogVersionRef{SourceHash: "source-test", SurfaceHash: "surface-test"}
|
||||
if response.Catalog != wantCatalog {
|
||||
t.Fatalf("Catalog = %#v, want %#v", response.Catalog, wantCatalog)
|
||||
}
|
||||
for _, removed := range []string{"external_ranking", "degraded", "degraded_reason_code", "warning_codes", "_fallback"} {
|
||||
if strings.Contains(stdout.String(), removed) {
|
||||
t.Fatalf("removed fallback field %q leaked into response: %s", removed, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaSearchRequestRejectsUnboundedOrAmbiguousInput(t *testing.T) {
|
||||
if _, err := decodeToolSearchV1Request(strings.NewReader(`{"version":"tool-search.v1","query":"x","unknown":true}`)); validationReason(err) != "invalid_request_json" {
|
||||
t.Fatal("unknown field was accepted")
|
||||
}
|
||||
if _, err := decodeToolSearchV1Request(strings.NewReader(`{"version":"tool-search.v1","query":"x","external_ranking":{}}`)); validationReason(err) != "invalid_request_json" {
|
||||
t.Fatal("removed external_ranking field was accepted")
|
||||
}
|
||||
oversized := strings.NewReader(strings.Repeat("x", maxToolSearchRequestBytes+1))
|
||||
if _, err := decodeToolSearchV1Request(oversized); validationReason(err) != "request_too_large" {
|
||||
t.Fatal("oversized request was accepted")
|
||||
}
|
||||
_, err := validateToolSearchV1Request(ToolSearchV1Request{Query: "x", Subqueries: []string{"x"}})
|
||||
if validationReason(err) != "unsupported_version" || !strings.Contains(err.Error(), `"version":"tool-search.v1"`) || !strings.Contains(err.Error(), "subqueries") {
|
||||
t.Fatalf("missing-version guidance = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaSearchHelpDocumentsStructuredRequest(t *testing.T) {
|
||||
command := newSchemaSearchCommand()
|
||||
var output bytes.Buffer
|
||||
command.SetOut(&output)
|
||||
command.SetArgs([]string{"--help"})
|
||||
if err := command.Execute(); err != nil {
|
||||
t.Fatalf("schema search help: %v", err)
|
||||
}
|
||||
for _, required := range []string{`"version":"tool-search.v1"`, `"subqueries"`, "--request-json -", "不支持 --fields、--jq", "非 JSON --format"} {
|
||||
if !strings.Contains(output.String(), required) {
|
||||
t.Fatalf("schema search help omits %q:\n%s", required, output.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func installSchemaSearchTestEngine(t *testing.T) {
|
||||
t.Helper()
|
||||
testseam.Swap(t, &schemaSearchNewEngine, func(ToolSearchConfig) (*ToolSearchEngine, error) {
|
||||
return newToolSearchTestEngine(t), nil
|
||||
})
|
||||
}
|
||||
|
||||
func validationReason(err error) string {
|
||||
var typed *apperrors.Error
|
||||
if errors.As(err, &typed) && typed.Category == apperrors.CategoryValidation {
|
||||
return typed.Reason
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -65,6 +65,7 @@ type schemaToolWire struct {
|
||||
Constraints RuntimeSchemaConstraints `json:"constraints"`
|
||||
Positionals []contract.RuntimeSchemaPositional `json:"positionals"`
|
||||
DryRun *contract.DryRunSpec `json:"dry_run"`
|
||||
Wait *contract.WaitSpec `json:"wait"`
|
||||
Result *contract.ResultSpec `json:"result"`
|
||||
Pagination *contract.PaginationSpec `json:"pagination"`
|
||||
Effect string `json:"effect"`
|
||||
@@ -269,6 +270,7 @@ func schemaToolSpecFromWire(wire schemaToolWire) (ToolSpec, error) {
|
||||
Constraints: wire.Constraints,
|
||||
Positionals: wire.Positionals,
|
||||
DryRun: wire.DryRun,
|
||||
Wait: wire.Wait,
|
||||
Result: wire.Result,
|
||||
Pagination: wire.Pagination,
|
||||
Safety: contract.SafetySpec{
|
||||
|
||||
@@ -63,15 +63,12 @@ func storeSchemaSourceRootFn(fn func() *cobra.Command) {
|
||||
}
|
||||
|
||||
// resetDeliverySchemaCatalogState clears the lazy Catalog delivery Once/caches
|
||||
// so the next deliverySchemaCatalog() reassembles. The memoized Tool Search
|
||||
// engine is a Catalog derivative (it indexes one generation and caches its
|
||||
// source/surface hashes), so it is invalidated here as well.
|
||||
// so the next deliverySchemaCatalog() reassembles.
|
||||
func resetDeliverySchemaCatalogState() {
|
||||
runtimeDeliverySchemaCatalogOnce = sync.Once{}
|
||||
runtimeDeliverySchemaCatalog = loadedSchemaCatalog{}
|
||||
runtimeDeliverySchemaCatalogErr = nil
|
||||
runtimeDeliverySchemaCatalogLazyCount.Store(0)
|
||||
resetDeliveryToolSearchEngineState()
|
||||
}
|
||||
|
||||
// resetSchemaDeliveryState clears ResolveMeta lookup state and Catalog delivery
|
||||
|
||||
@@ -7,30 +7,11 @@ package cli
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestSchemaHelpDocumentsCanonicalAndCLIPathForms(t *testing.T) {
|
||||
// Positional args are joined with spaces and resolved as a CLI path, so the
|
||||
// tokenized form is not a canonical-path spelling. Help must keep saying so:
|
||||
// an Agent that splits a canonical path on the dot otherwise gets an
|
||||
// "unknown runtime schema path" error with no explanation of the boundary.
|
||||
long := NewSchemaCommand().Long
|
||||
for _, required := range []string{
|
||||
"CLI path",
|
||||
"canonical path",
|
||||
`dws schema "chat message read-status"`,
|
||||
"dws schema chat.query_msg_read_status",
|
||||
} {
|
||||
if !strings.Contains(long, required) {
|
||||
t.Fatalf("schema long help omits the path-form boundary %q:\n%s", required, long)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaUsesDeliveryCatalogWithoutRuntimeLoad(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.AddCommand(NewSchemaCommand())
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,490 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
ToolSearchAgentArmDirectSchema = "direct_schema"
|
||||
ToolSearchAgentArmSearchInspect = "search_inspect"
|
||||
)
|
||||
|
||||
// Bootstrap resampling uses fixed seeds so a report can be re-derived from the
|
||||
// same paired runs. The seeds are published in the report so an auditor never
|
||||
// has to read them out of the source to reproduce an interval. The recovery
|
||||
// slice uses a distinct seed because it resamples a different pair subset.
|
||||
const (
|
||||
toolSearchAgentABBootstrapSamples = 10000
|
||||
toolSearchAgentABBootstrapSeed = 927
|
||||
toolSearchAgentABRecoveryBootstrapSeed = 928
|
||||
)
|
||||
|
||||
// ToolSearchAgentABInput is populated by an Agent runner. Every case/trial
|
||||
// must have both arms so the comparison remains paired.
|
||||
type ToolSearchAgentABInput struct {
|
||||
Version string `json:"version"`
|
||||
Catalog CatalogVersionRef `json:"catalog"`
|
||||
Runs []ToolSearchAgentABRun `json:"runs"`
|
||||
}
|
||||
|
||||
// ToolSearchAgentABRun records end-to-end outcomes rather than retrieval
|
||||
// proxies. ContextTokens must come from the selected model's tokenizer.
|
||||
type ToolSearchAgentABRun struct {
|
||||
CaseID string `json:"case_id"`
|
||||
Trial int `json:"trial"`
|
||||
Arm string `json:"arm"`
|
||||
TaskCompleted bool `json:"task_completed"`
|
||||
CorrectToolPlan bool `json:"correct_tool_plan"`
|
||||
UnsafeAction bool `json:"unsafe_action"`
|
||||
RecoveryAttempted bool `json:"recovery_attempted"`
|
||||
RecoverySucceeded bool `json:"recovery_succeeded"`
|
||||
ContextTokens float64 `json:"context_tokens"`
|
||||
ToolCalls float64 `json:"tool_calls"`
|
||||
LatencyMS float64 `json:"latency_ms"`
|
||||
}
|
||||
|
||||
type ToolSearchAgentABArmMetrics struct {
|
||||
Runs int `json:"runs"`
|
||||
TaskSuccessRate float64 `json:"task_success_rate"`
|
||||
CorrectPlanRate float64 `json:"correct_plan_rate"`
|
||||
UnsafeActionRate float64 `json:"unsafe_action_rate"`
|
||||
RecoverySuccessRate float64 `json:"recovery_success_rate"`
|
||||
RecoveryAttempts int `json:"recovery_attempts"`
|
||||
AverageContextTokens float64 `json:"average_context_tokens"`
|
||||
AverageToolCalls float64 `json:"average_tool_calls"`
|
||||
AverageLatencyMS float64 `json:"average_latency_ms"`
|
||||
}
|
||||
|
||||
// ToolSearchAgentABDelta is search_inspect minus direct_schema. A positive
|
||||
// value is desirable for success/plan/recovery and undesirable for unsafe
|
||||
// actions, context, calls, and latency.
|
||||
type ToolSearchAgentABDelta struct {
|
||||
DirectSchema float64 `json:"direct_schema"`
|
||||
SearchInspect float64 `json:"search_inspect"`
|
||||
Delta float64 `json:"delta"`
|
||||
CI95Low float64 `json:"ci95_low"`
|
||||
CI95High float64 `json:"ci95_high"`
|
||||
}
|
||||
|
||||
type ToolSearchAgentABReport struct {
|
||||
Version string `json:"version"`
|
||||
Catalog CatalogVersionRef `json:"catalog"`
|
||||
Cases int `json:"cases"`
|
||||
PairedTrials int `json:"paired_trials"`
|
||||
DirectSchema ToolSearchAgentABArmMetrics `json:"direct_schema"`
|
||||
SearchInspect ToolSearchAgentABArmMetrics `json:"search_inspect"`
|
||||
Deltas map[string]ToolSearchAgentABDelta `json:"deltas"`
|
||||
// BootstrapSamples/BootstrapSeed/RecoveryBootstrapSeed are published so an
|
||||
// auditor can reproduce every interval without reading the source.
|
||||
BootstrapSamples int `json:"bootstrap_samples"`
|
||||
BootstrapSeed int64 `json:"bootstrap_seed"`
|
||||
RecoveryBootstrapSeed int64 `json:"recovery_bootstrap_seed"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
|
||||
// ToolSearchAgentPlanOutput is the strict final response of a non-executing
|
||||
// Agent planning run. Gold tools remain in the local workflow fixture and are
|
||||
// never sent to the evaluated model.
|
||||
type ToolSearchAgentPlanOutput struct {
|
||||
Results []ToolSearchAgentPlanResult `json:"results"`
|
||||
}
|
||||
|
||||
type ToolSearchAgentPlanResult struct {
|
||||
ID string `json:"id"`
|
||||
CanonicalPaths []string `json:"canonical_paths"`
|
||||
}
|
||||
|
||||
type ToolSearchAgentPlanMetrics struct {
|
||||
Cases int `json:"cases"`
|
||||
CompleteRate float64 `json:"complete_rate"`
|
||||
ExactMinimalRate float64 `json:"exact_minimal_rate"`
|
||||
RequiredToolRecall float64 `json:"required_tool_recall"`
|
||||
PlanPrecision float64 `json:"plan_precision"`
|
||||
RequiredTools int `json:"required_tools"`
|
||||
PredictedTools int `json:"predicted_tools"`
|
||||
UnnecessaryTools int `json:"unnecessary_tools"`
|
||||
}
|
||||
|
||||
type ToolSearchAgentPlanningABReport struct {
|
||||
Version string `json:"version"`
|
||||
Model string `json:"model"`
|
||||
DirectSchema ToolSearchAgentPlanMetrics `json:"direct_schema"`
|
||||
SearchInspect ToolSearchAgentPlanMetrics `json:"search_inspect"`
|
||||
Delta map[string]float64 `json:"delta_search_minus_direct"`
|
||||
Limitations []string `json:"limitations"`
|
||||
}
|
||||
|
||||
// ScoreToolSearchAgentPlanningAB scores an answer-free paired planning smoke
|
||||
// run against reviewed workflow requirements. It measures plan coverage and
|
||||
// minimality only; it does not claim that any business task was executed.
|
||||
func ScoreToolSearchAgentPlanningAB(model string, workflows []ToolSearchWorkflowEvaluationCase, direct, search ToolSearchAgentPlanOutput) (ToolSearchAgentPlanningABReport, error) {
|
||||
if err := deliverySchemaCatalogError(); err != nil {
|
||||
return ToolSearchAgentPlanningABReport{}, fmt.Errorf("assemble typed Schema registry for Agent planning score: %w", err)
|
||||
}
|
||||
return scoreToolSearchAgentPlanningAB(deliverySchemaCatalog().Index, model, workflows, direct, search)
|
||||
}
|
||||
|
||||
func scoreToolSearchAgentPlanningAB(index SchemaIndex, model string, workflows []ToolSearchWorkflowEvaluationCase, direct, search ToolSearchAgentPlanOutput) (ToolSearchAgentPlanningABReport, error) {
|
||||
if strings.TrimSpace(model) == "" {
|
||||
return ToolSearchAgentPlanningABReport{}, fmt.Errorf("Agent planning A/B requires a model identifier")
|
||||
}
|
||||
directMetrics, err := scoreToolSearchAgentPlans(index, workflows, direct)
|
||||
if err != nil {
|
||||
return ToolSearchAgentPlanningABReport{}, fmt.Errorf("score direct_schema plans: %w", err)
|
||||
}
|
||||
searchMetrics, err := scoreToolSearchAgentPlans(index, workflows, search)
|
||||
if err != nil {
|
||||
return ToolSearchAgentPlanningABReport{}, fmt.Errorf("score search_inspect plans: %w", err)
|
||||
}
|
||||
return ToolSearchAgentPlanningABReport{
|
||||
Version: "tool-search-agent-planning-ab.v1",
|
||||
Model: strings.TrimSpace(model),
|
||||
DirectSchema: directMetrics,
|
||||
SearchInspect: searchMetrics,
|
||||
Delta: map[string]float64{
|
||||
"complete_rate": searchMetrics.CompleteRate - directMetrics.CompleteRate,
|
||||
"exact_minimal_rate": searchMetrics.ExactMinimalRate - directMetrics.ExactMinimalRate,
|
||||
"required_tool_recall": searchMetrics.RequiredToolRecall - directMetrics.RequiredToolRecall,
|
||||
"plan_precision": searchMetrics.PlanPrecision - directMetrics.PlanPrecision,
|
||||
"unnecessary_tools": float64(searchMetrics.UnnecessaryTools - directMetrics.UnnecessaryTools),
|
||||
},
|
||||
Limitations: []string{
|
||||
"one batch and one model trial per arm; no confidence interval",
|
||||
"reviewed ten-workflow diagnostic fixture is not an independent release test",
|
||||
"planning only: no parameters, business execution, recovery, latency, or tokenizer usage is scored",
|
||||
"extra prerequisites can be operationally valid even when absent from the minimal reviewed gold",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func scoreToolSearchAgentPlans(index SchemaIndex, workflows []ToolSearchWorkflowEvaluationCase, output ToolSearchAgentPlanOutput) (ToolSearchAgentPlanMetrics, error) {
|
||||
byID := make(map[string]ToolSearchAgentPlanResult, len(output.Results))
|
||||
for _, result := range output.Results {
|
||||
if strings.TrimSpace(result.ID) == "" || len(result.CanonicalPaths) == 0 {
|
||||
return ToolSearchAgentPlanMetrics{}, fmt.Errorf("plan result requires id and at least one canonical path")
|
||||
}
|
||||
if _, exists := byID[result.ID]; exists {
|
||||
return ToolSearchAgentPlanMetrics{}, fmt.Errorf("duplicate plan result %q", result.ID)
|
||||
}
|
||||
seen := make(map[string]bool, len(result.CanonicalPaths))
|
||||
for _, canonical := range result.CanonicalPaths {
|
||||
if _, ok := index.Resolve(canonical); !ok {
|
||||
return ToolSearchAgentPlanMetrics{}, fmt.Errorf("plan result %q contains unknown canonical %q", result.ID, canonical)
|
||||
}
|
||||
if seen[canonical] {
|
||||
return ToolSearchAgentPlanMetrics{}, fmt.Errorf("plan result %q repeats canonical %q", result.ID, canonical)
|
||||
}
|
||||
seen[canonical] = true
|
||||
}
|
||||
byID[result.ID] = result
|
||||
}
|
||||
metrics := ToolSearchAgentPlanMetrics{Cases: len(workflows)}
|
||||
var completeCases, exactCases, hits int
|
||||
for _, workflow := range workflows {
|
||||
result, ok := byID[workflow.ID]
|
||||
if !ok {
|
||||
return ToolSearchAgentPlanMetrics{}, fmt.Errorf("missing plan result %q", workflow.ID)
|
||||
}
|
||||
delete(byID, workflow.ID)
|
||||
gold := stringSet(workflow.Required)
|
||||
caseHits := 0
|
||||
for _, canonical := range result.CanonicalPaths {
|
||||
if gold[canonical] {
|
||||
caseHits++
|
||||
}
|
||||
}
|
||||
hits += caseHits
|
||||
metrics.RequiredTools += len(workflow.Required)
|
||||
metrics.PredictedTools += len(result.CanonicalPaths)
|
||||
if caseHits == len(workflow.Required) {
|
||||
completeCases++
|
||||
}
|
||||
if stringSlicesEqual(result.CanonicalPaths, workflow.Required) {
|
||||
exactCases++
|
||||
}
|
||||
}
|
||||
if len(byID) > 0 {
|
||||
unexpected := make([]string, 0, len(byID))
|
||||
for id := range byID {
|
||||
unexpected = append(unexpected, id)
|
||||
}
|
||||
sort.Strings(unexpected)
|
||||
return ToolSearchAgentPlanMetrics{}, fmt.Errorf("unexpected plan results: %s", strings.Join(unexpected, ", "))
|
||||
}
|
||||
if metrics.Cases > 0 {
|
||||
metrics.CompleteRate = float64(completeCases) / float64(metrics.Cases)
|
||||
metrics.ExactMinimalRate = float64(exactCases) / float64(metrics.Cases)
|
||||
}
|
||||
if metrics.RequiredTools > 0 {
|
||||
metrics.RequiredToolRecall = float64(hits) / float64(metrics.RequiredTools)
|
||||
}
|
||||
if metrics.PredictedTools > 0 {
|
||||
metrics.PlanPrecision = float64(hits) / float64(metrics.PredictedTools)
|
||||
}
|
||||
metrics.UnnecessaryTools = metrics.PredictedTools - hits
|
||||
return metrics, nil
|
||||
}
|
||||
|
||||
func stringSlicesEqual(left, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type toolSearchAgentPair struct {
|
||||
caseID string
|
||||
direct ToolSearchAgentABRun
|
||||
search ToolSearchAgentABRun
|
||||
}
|
||||
|
||||
// AggregateToolSearchAgentAB validates a paired experiment and computes
|
||||
// case-cluster bootstrap confidence intervals with a fixed seed. Trials of the
|
||||
// same task are averaged inside a case before resampling so repeated model
|
||||
// seeds are not treated as independent tasks.
|
||||
func AggregateToolSearchAgentAB(input ToolSearchAgentABInput) (ToolSearchAgentABReport, error) {
|
||||
if input.Version != "tool-search-agent-ab.v1" {
|
||||
return ToolSearchAgentABReport{}, fmt.Errorf("unsupported Agent A/B input version %q", input.Version)
|
||||
}
|
||||
if strings.TrimSpace(input.Catalog.SourceHash) == "" || strings.TrimSpace(input.Catalog.SurfaceHash) == "" {
|
||||
return ToolSearchAgentABReport{}, fmt.Errorf("Agent A/B input requires both Catalog hashes")
|
||||
}
|
||||
type key struct {
|
||||
caseID string
|
||||
trial int
|
||||
}
|
||||
paired := make(map[key]*toolSearchAgentPair)
|
||||
for _, run := range input.Runs {
|
||||
if strings.TrimSpace(run.CaseID) == "" || run.Trial < 0 {
|
||||
return ToolSearchAgentABReport{}, fmt.Errorf("Agent A/B run requires case_id and non-negative trial")
|
||||
}
|
||||
if run.ContextTokens < 0 || run.ToolCalls < 0 || run.LatencyMS < 0 {
|
||||
return ToolSearchAgentABReport{}, fmt.Errorf("Agent A/B run %s/%d has a negative cost", run.CaseID, run.Trial)
|
||||
}
|
||||
if run.RecoverySucceeded && !run.RecoveryAttempted {
|
||||
return ToolSearchAgentABReport{}, fmt.Errorf("Agent A/B run %s/%d marks recovery success without an attempt", run.CaseID, run.Trial)
|
||||
}
|
||||
itemKey := key{caseID: run.CaseID, trial: run.Trial}
|
||||
pair := paired[itemKey]
|
||||
if pair == nil {
|
||||
pair = &toolSearchAgentPair{caseID: run.CaseID}
|
||||
paired[itemKey] = pair
|
||||
}
|
||||
switch run.Arm {
|
||||
case ToolSearchAgentArmDirectSchema:
|
||||
if pair.direct.Arm != "" {
|
||||
return ToolSearchAgentABReport{}, fmt.Errorf("duplicate direct_schema run for %s/%d", run.CaseID, run.Trial)
|
||||
}
|
||||
pair.direct = run
|
||||
case ToolSearchAgentArmSearchInspect:
|
||||
if pair.search.Arm != "" {
|
||||
return ToolSearchAgentABReport{}, fmt.Errorf("duplicate search_inspect run for %s/%d", run.CaseID, run.Trial)
|
||||
}
|
||||
pair.search = run
|
||||
default:
|
||||
return ToolSearchAgentABReport{}, fmt.Errorf("unknown Agent A/B arm %q", run.Arm)
|
||||
}
|
||||
}
|
||||
if len(paired) == 0 {
|
||||
return ToolSearchAgentABReport{}, fmt.Errorf("Agent A/B input contains no runs")
|
||||
}
|
||||
pairs := make([]toolSearchAgentPair, 0, len(paired))
|
||||
caseSet := make(map[string]bool)
|
||||
for itemKey, pair := range paired {
|
||||
if pair.direct.Arm == "" || pair.search.Arm == "" {
|
||||
return ToolSearchAgentABReport{}, fmt.Errorf("Agent A/B case %s/%d is not paired", itemKey.caseID, itemKey.trial)
|
||||
}
|
||||
pairs = append(pairs, *pair)
|
||||
caseSet[itemKey.caseID] = true
|
||||
}
|
||||
sort.Slice(pairs, func(i, j int) bool {
|
||||
if pairs[i].caseID == pairs[j].caseID {
|
||||
return pairs[i].direct.Trial < pairs[j].direct.Trial
|
||||
}
|
||||
return pairs[i].caseID < pairs[j].caseID
|
||||
})
|
||||
|
||||
directMetrics := aggregateToolSearchAgentArm(pairs, false)
|
||||
searchMetrics := aggregateToolSearchAgentArm(pairs, true)
|
||||
metrics := []struct {
|
||||
name string
|
||||
value func(ToolSearchAgentABRun) float64
|
||||
direct float64
|
||||
search float64
|
||||
}{
|
||||
{"task_success_rate", func(run ToolSearchAgentABRun) float64 { return boolFloat(run.TaskCompleted) }, directMetrics.TaskSuccessRate, searchMetrics.TaskSuccessRate},
|
||||
{"correct_plan_rate", func(run ToolSearchAgentABRun) float64 { return boolFloat(run.CorrectToolPlan) }, directMetrics.CorrectPlanRate, searchMetrics.CorrectPlanRate},
|
||||
{"unsafe_action_rate", func(run ToolSearchAgentABRun) float64 { return boolFloat(run.UnsafeAction) }, directMetrics.UnsafeActionRate, searchMetrics.UnsafeActionRate},
|
||||
{"context_tokens", func(run ToolSearchAgentABRun) float64 { return run.ContextTokens }, directMetrics.AverageContextTokens, searchMetrics.AverageContextTokens},
|
||||
{"tool_calls", func(run ToolSearchAgentABRun) float64 { return run.ToolCalls }, directMetrics.AverageToolCalls, searchMetrics.AverageToolCalls},
|
||||
{"latency_ms", func(run ToolSearchAgentABRun) float64 { return run.LatencyMS }, directMetrics.AverageLatencyMS, searchMetrics.AverageLatencyMS},
|
||||
}
|
||||
deltas := make(map[string]ToolSearchAgentABDelta, len(metrics)+1)
|
||||
for _, metric := range metrics {
|
||||
low, high := toolSearchCaseClusterBootstrap(pairs, metric.value, toolSearchAgentABBootstrapSamples, toolSearchAgentABBootstrapSeed)
|
||||
deltas[metric.name] = ToolSearchAgentABDelta{
|
||||
DirectSchema: metric.direct,
|
||||
SearchInspect: metric.search,
|
||||
Delta: metric.search - metric.direct,
|
||||
CI95Low: low,
|
||||
CI95High: high,
|
||||
}
|
||||
}
|
||||
if directMetrics.RecoveryAttempts > 0 && searchMetrics.RecoveryAttempts > 0 {
|
||||
// Recovery is conditionally defined over attempted recoveries, so its
|
||||
// interval uses the same paired tasks but maps non-attempts to zero only
|
||||
// after both arms attempted recovery for that trial.
|
||||
recoveryPairs := make([]toolSearchAgentPair, 0, len(pairs))
|
||||
for _, pair := range pairs {
|
||||
if pair.direct.RecoveryAttempted && pair.search.RecoveryAttempted {
|
||||
recoveryPairs = append(recoveryPairs, pair)
|
||||
}
|
||||
}
|
||||
if len(recoveryPairs) > 0 {
|
||||
value := func(run ToolSearchAgentABRun) float64 { return boolFloat(run.RecoverySucceeded) }
|
||||
low, high := toolSearchCaseClusterBootstrap(recoveryPairs, value, toolSearchAgentABBootstrapSamples, toolSearchAgentABRecoveryBootstrapSeed)
|
||||
directRecovery := meanAgentRunValue(recoveryPairs, false, value)
|
||||
searchRecovery := meanAgentRunValue(recoveryPairs, true, value)
|
||||
deltas["recovery_success_rate_paired_attempts"] = ToolSearchAgentABDelta{
|
||||
DirectSchema: directRecovery,
|
||||
SearchInspect: searchRecovery,
|
||||
Delta: searchRecovery - directRecovery,
|
||||
CI95Low: low,
|
||||
CI95High: high,
|
||||
}
|
||||
}
|
||||
}
|
||||
return ToolSearchAgentABReport{
|
||||
Version: "tool-search-agent-ab-report.v1",
|
||||
Catalog: input.Catalog,
|
||||
Cases: len(caseSet),
|
||||
PairedTrials: len(pairs),
|
||||
DirectSchema: directMetrics,
|
||||
SearchInspect: searchMetrics,
|
||||
Deltas: deltas,
|
||||
BootstrapSamples: toolSearchAgentABBootstrapSamples,
|
||||
BootstrapSeed: toolSearchAgentABBootstrapSeed,
|
||||
RecoveryBootstrapSeed: toolSearchAgentABRecoveryBootstrapSeed,
|
||||
Method: fmt.Sprintf(
|
||||
"paired by case_id/trial; 95%% percentile bootstrap clustered by case_id; %d resamples; fixed seed %d (recovery slice seed %d)",
|
||||
toolSearchAgentABBootstrapSamples,
|
||||
toolSearchAgentABBootstrapSeed,
|
||||
toolSearchAgentABRecoveryBootstrapSeed,
|
||||
),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func aggregateToolSearchAgentArm(pairs []toolSearchAgentPair, search bool) ToolSearchAgentABArmMetrics {
|
||||
metrics := ToolSearchAgentABArmMetrics{Runs: len(pairs)}
|
||||
for _, pair := range pairs {
|
||||
run := pair.direct
|
||||
if search {
|
||||
run = pair.search
|
||||
}
|
||||
metrics.TaskSuccessRate += boolFloat(run.TaskCompleted)
|
||||
metrics.CorrectPlanRate += boolFloat(run.CorrectToolPlan)
|
||||
metrics.UnsafeActionRate += boolFloat(run.UnsafeAction)
|
||||
metrics.AverageContextTokens += run.ContextTokens
|
||||
metrics.AverageToolCalls += run.ToolCalls
|
||||
metrics.AverageLatencyMS += run.LatencyMS
|
||||
if run.RecoveryAttempted {
|
||||
metrics.RecoveryAttempts++
|
||||
metrics.RecoverySuccessRate += boolFloat(run.RecoverySucceeded)
|
||||
}
|
||||
}
|
||||
if metrics.Runs > 0 {
|
||||
denominator := float64(metrics.Runs)
|
||||
metrics.TaskSuccessRate /= denominator
|
||||
metrics.CorrectPlanRate /= denominator
|
||||
metrics.UnsafeActionRate /= denominator
|
||||
metrics.AverageContextTokens /= denominator
|
||||
metrics.AverageToolCalls /= denominator
|
||||
metrics.AverageLatencyMS /= denominator
|
||||
}
|
||||
if metrics.RecoveryAttempts > 0 {
|
||||
metrics.RecoverySuccessRate /= float64(metrics.RecoveryAttempts)
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
func toolSearchCaseClusterBootstrap(pairs []toolSearchAgentPair, value func(ToolSearchAgentABRun) float64, samples int, seed int64) (float64, float64) {
|
||||
byCase := make(map[string][]float64)
|
||||
for _, pair := range pairs {
|
||||
byCase[pair.caseID] = append(byCase[pair.caseID], value(pair.search)-value(pair.direct))
|
||||
}
|
||||
caseIDs := make([]string, 0, len(byCase))
|
||||
caseMeans := make(map[string]float64, len(byCase))
|
||||
for caseID, values := range byCase {
|
||||
caseIDs = append(caseIDs, caseID)
|
||||
for _, value := range values {
|
||||
caseMeans[caseID] += value / float64(len(values))
|
||||
}
|
||||
}
|
||||
sort.Strings(caseIDs)
|
||||
if len(caseIDs) == 1 {
|
||||
value := caseMeans[caseIDs[0]]
|
||||
return value, value
|
||||
}
|
||||
random := rand.New(rand.NewSource(seed))
|
||||
distribution := make([]float64, samples)
|
||||
for sample := range samples {
|
||||
var total float64
|
||||
for range caseIDs {
|
||||
total += caseMeans[caseIDs[random.Intn(len(caseIDs))]]
|
||||
}
|
||||
distribution[sample] = total / float64(len(caseIDs))
|
||||
}
|
||||
sort.Float64s(distribution)
|
||||
return percentile(distribution, 0.025), percentile(distribution, 0.975)
|
||||
}
|
||||
|
||||
func percentile(sorted []float64, quantile float64) float64 {
|
||||
if len(sorted) == 0 {
|
||||
return 0
|
||||
}
|
||||
index := int(quantile * float64(len(sorted)-1))
|
||||
return sorted[index]
|
||||
}
|
||||
|
||||
func meanAgentRunValue(pairs []toolSearchAgentPair, search bool, value func(ToolSearchAgentABRun) float64) float64 {
|
||||
if len(pairs) == 0 {
|
||||
return 0
|
||||
}
|
||||
var total float64
|
||||
for _, pair := range pairs {
|
||||
run := pair.direct
|
||||
if search {
|
||||
run = pair.search
|
||||
}
|
||||
total += value(run)
|
||||
}
|
||||
return total / float64(len(pairs))
|
||||
}
|
||||
|
||||
func boolFloat(value bool) float64 {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -1,587 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// ToolSearchWorkflowEvaluationCase is a reviewed diagnostic workflow. It is
|
||||
// intentionally separate from release qrels: the small in-repository fixture
|
||||
// measures mechanics and cannot approve a ranking configuration.
|
||||
type ToolSearchWorkflowEvaluationCase struct {
|
||||
ID string `json:"id"`
|
||||
Query string `json:"query"`
|
||||
Subqueries []string `json:"subqueries"`
|
||||
Required []string `json:"required"`
|
||||
}
|
||||
|
||||
// ToolSearchRetrievalMetrics reports deterministic ranking metrics. Values are
|
||||
// fractions in [0,1], not percentages.
|
||||
type ToolSearchRetrievalMetrics struct {
|
||||
Cases int `json:"cases"`
|
||||
RecallAt1 float64 `json:"recall_at_1"`
|
||||
RecallAt5 float64 `json:"recall_at_5"`
|
||||
MeanReciprocalRankAt5 float64 `json:"mean_reciprocal_rank_at_5"`
|
||||
ZeroResultRate float64 `json:"zero_result_rate"`
|
||||
}
|
||||
|
||||
// ToolSearchWorkflowMetrics measures whether every required tool is present in
|
||||
// the shared Top-5 budget and the average required-tool recall.
|
||||
type ToolSearchWorkflowMetrics struct {
|
||||
Cases int `json:"cases"`
|
||||
CompleteAt5 float64 `json:"complete_at_5"`
|
||||
RequiredRecallAt5 float64 `json:"required_recall_at_5"`
|
||||
}
|
||||
|
||||
// ToolSearchIdentityTrustMetrics exercises the complete delivered identity
|
||||
// set. Exact filtered checks deliberately exclude each canonical path and
|
||||
// verify that search abstains instead of falling through to a fuzzy sibling.
|
||||
type ToolSearchIdentityTrustMetrics struct {
|
||||
CanonicalCases int `json:"canonical_cases"`
|
||||
CanonicalPassRate float64 `json:"canonical_pass_rate"`
|
||||
PrimaryCLICases int `json:"primary_cli_cases"`
|
||||
PrimaryCLIPassRate float64 `json:"primary_cli_pass_rate"`
|
||||
AliasCases int `json:"alias_cases"`
|
||||
AliasPassRate float64 `json:"alias_pass_rate"`
|
||||
NFKCCases int `json:"nfkc_cases"`
|
||||
NFKCPassRate float64 `json:"nfkc_pass_rate"`
|
||||
ExactFilteredCases int `json:"exact_filtered_cases"`
|
||||
ExactFilteredPassRate float64 `json:"exact_filtered_pass_rate"`
|
||||
}
|
||||
|
||||
// ToolSearchNegativeTrustMetrics is a same-source diagnostic only. A
|
||||
// forbidden exposure means the tool that owns an avoid_when sentence appears
|
||||
// in the ranking; it does not prove that a model would call that tool.
|
||||
type ToolSearchNegativeTrustMetrics struct {
|
||||
Cases int `json:"cases"`
|
||||
ExcludedOverBudget int `json:"excluded_over_budget"`
|
||||
ForbiddenAt1 float64 `json:"forbidden_at_1"`
|
||||
ForbiddenAt5 float64 `json:"forbidden_at_5"`
|
||||
}
|
||||
|
||||
// ToolSearchIntegrityMetrics verifies invariants that do not depend on
|
||||
// relevance labels.
|
||||
type ToolSearchIntegrityMetrics struct {
|
||||
ResponsesEvaluated int `json:"responses_evaluated"`
|
||||
CatalogBindingFailures int `json:"catalog_binding_failures"`
|
||||
UnknownCandidateCount int `json:"unknown_candidate_count"`
|
||||
IneligibleCandidateCount int `json:"ineligible_candidate_count"`
|
||||
ResponseBudgetViolations int `json:"response_budget_violations"`
|
||||
}
|
||||
|
||||
// ToolSearchTrustMetrics keeps contract integrity separate from ranking
|
||||
// relevance. Search rank is never presented as a probability of safe use.
|
||||
type ToolSearchTrustMetrics struct {
|
||||
Identity ToolSearchIdentityTrustMetrics `json:"identity"`
|
||||
Negative ToolSearchNegativeTrustMetrics `json:"negative"`
|
||||
Integrity ToolSearchIntegrityMetrics `json:"integrity"`
|
||||
}
|
||||
|
||||
// ToolSearchContextComparison compares byte-level JSON envelopes, not model
|
||||
// tokens. The ideal navigation path assumes an oracle already knows the right
|
||||
// product; its selection success is therefore deliberately not estimated.
|
||||
type ToolSearchContextComparison struct {
|
||||
ToolCount int `json:"tool_count"`
|
||||
FullSchemaAllBytes int `json:"full_schema_all_bytes"`
|
||||
OverviewBytes int `json:"overview_bytes"`
|
||||
AverageProductBytes float64 `json:"average_product_bytes"`
|
||||
AverageInspectBytes float64 `json:"average_inspect_bytes"`
|
||||
AverageIdealSchemaNavigationBytes float64 `json:"average_ideal_schema_navigation_bytes"`
|
||||
AverageSearchInspectBytes float64 `json:"average_search_inspect_bytes"`
|
||||
ReductionVsFullSchema float64 `json:"reduction_vs_full_schema"`
|
||||
ReductionVsIdealNavigation float64 `json:"reduction_vs_ideal_navigation"`
|
||||
}
|
||||
|
||||
// ToolSearchDiagnosticComparison is generated from the exact typed Catalog
|
||||
// embedded in the binary. It is a diagnostic proxy, not an Agent completion
|
||||
// benchmark: its intent queries come from reviewed use_when prose and therefore
|
||||
// are not independently authored qrels.
|
||||
type ToolSearchDiagnosticComparison struct {
|
||||
Version string `json:"version"`
|
||||
Catalog CatalogVersionRef `json:"catalog"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
TopK int `json:"top_k"`
|
||||
IntentProxy ToolSearchRetrievalMetrics `json:"intent_proxy"`
|
||||
IntentExcludedOverBudget int `json:"intent_excluded_over_budget"`
|
||||
IntentLanguageSlices map[string]ToolSearchRetrievalMetrics `json:"intent_language_slices"`
|
||||
WorkflowRaw ToolSearchWorkflowMetrics `json:"workflow_raw"`
|
||||
WorkflowDecomposed ToolSearchWorkflowMetrics `json:"workflow_decomposed"`
|
||||
Trust ToolSearchTrustMetrics `json:"trust"`
|
||||
Context ToolSearchContextComparison `json:"context"`
|
||||
AgentABStatus string `json:"agent_ab_status"`
|
||||
Limitations []string `json:"limitations"`
|
||||
}
|
||||
|
||||
// BuildDeliveryToolSearchDiagnosticComparison evaluates the shipped Go
|
||||
// retriever against the declaration-assembled Catalog without reading a
|
||||
// generated repository JSON artifact. Results belong under policy-tmp.
|
||||
func BuildDeliveryToolSearchDiagnosticComparison(ctx context.Context, workflows []ToolSearchWorkflowEvaluationCase) (ToolSearchDiagnosticComparison, error) {
|
||||
return BuildDeliveryToolSearchDiagnosticComparisonForAlgorithm(ctx, workflows, DefaultToolSearchConfig().LexicalAlgorithm)
|
||||
}
|
||||
|
||||
// BuildDeliveryToolSearchDiagnosticComparisonForAlgorithm runs one shipped Go
|
||||
// lexical implementation over exactly the same typed Catalog and fixtures.
|
||||
// This keeps shadow algorithm comparison out of the production default.
|
||||
func BuildDeliveryToolSearchDiagnosticComparisonForAlgorithm(ctx context.Context, workflows []ToolSearchWorkflowEvaluationCase, algorithm string) (ToolSearchDiagnosticComparison, error) {
|
||||
if err := deliverySchemaCatalogError(); err != nil {
|
||||
return ToolSearchDiagnosticComparison{}, fmt.Errorf("assemble typed Schema registry for evaluation: %w", err)
|
||||
}
|
||||
loaded := deliverySchemaCatalog()
|
||||
config := DefaultToolSearchConfig()
|
||||
config.LexicalAlgorithm = algorithm
|
||||
config.CatalogSourceHash = loaded.Snapshot.SourceHash
|
||||
config.CatalogSurfaceHash = loaded.Snapshot.SurfaceHash
|
||||
engine, err := NewToolSearchEngine(loaded.Registry, config)
|
||||
if err != nil {
|
||||
return ToolSearchDiagnosticComparison{}, err
|
||||
}
|
||||
allPayload, err := deliverySchemaAllPayload()
|
||||
if err != nil {
|
||||
return ToolSearchDiagnosticComparison{}, fmt.Errorf("render full Schema: %w", err)
|
||||
}
|
||||
overviewPayload, err := deliverySchemaOverviewPayload()
|
||||
if err != nil {
|
||||
return ToolSearchDiagnosticComparison{}, fmt.Errorf("render Schema overview: %w", err)
|
||||
}
|
||||
allBytes, err := compactJSONSize(allPayload)
|
||||
if err != nil {
|
||||
return ToolSearchDiagnosticComparison{}, err
|
||||
}
|
||||
overviewBytes, err := compactJSONSize(stripSchemaPayloadCompact(overviewPayload))
|
||||
if err != nil {
|
||||
return ToolSearchDiagnosticComparison{}, err
|
||||
}
|
||||
|
||||
type proxyCase struct {
|
||||
query string
|
||||
gold string
|
||||
product string
|
||||
}
|
||||
proxyCases := make([]proxyCase, 0)
|
||||
intentExcludedOverBudget := 0
|
||||
for _, product := range loaded.Registry.Products {
|
||||
for _, tool := range product.Tools {
|
||||
for _, query := range tool.Selection.UseWhen {
|
||||
if query != "" && toolSearchQueryWithinBudget(query) {
|
||||
proxyCases = append(proxyCases, proxyCase{query: query, gold: tool.Identity.CanonicalPath, product: product.ID})
|
||||
} else if query != "" {
|
||||
intentExcludedOverBudget++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(proxyCases, func(i, j int) bool {
|
||||
if proxyCases[i].gold == proxyCases[j].gold {
|
||||
return proxyCases[i].query < proxyCases[j].query
|
||||
}
|
||||
return proxyCases[i].gold < proxyCases[j].gold
|
||||
})
|
||||
|
||||
intentAccumulator := &toolSearchRetrievalAccumulator{}
|
||||
languageAccumulators := map[string]*toolSearchRetrievalAccumulator{}
|
||||
var totalProductBytes, totalInspectBytes, totalSearchBytes float64
|
||||
productSizes := make(map[string]int)
|
||||
inspectSizes := make(map[string]int)
|
||||
for _, item := range proxyCases {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ToolSearchDiagnosticComparison{}, err
|
||||
}
|
||||
response, searchErr := engine.Search(ctx, ToolSearchRequest{Query: item.query, Limit: 5, CandidateLimit: 20})
|
||||
if searchErr != nil {
|
||||
return ToolSearchDiagnosticComparison{}, fmt.Errorf("search proxy case %s: %w", item.gold, searchErr)
|
||||
}
|
||||
rank := 0
|
||||
for index, candidate := range response.Candidates {
|
||||
if candidate.CanonicalPath != item.gold {
|
||||
continue
|
||||
}
|
||||
rank = index + 1
|
||||
break
|
||||
}
|
||||
intentAccumulator.observe(rank, len(response.Candidates) == 0)
|
||||
slice := toolSearchQueryLanguageSlice(item.query)
|
||||
if languageAccumulators[slice] == nil {
|
||||
languageAccumulators[slice] = &toolSearchRetrievalAccumulator{}
|
||||
}
|
||||
languageAccumulators[slice].observe(rank, len(response.Candidates) == 0)
|
||||
encodedResponse, marshalErr := json.Marshal(response)
|
||||
if marshalErr != nil {
|
||||
return ToolSearchDiagnosticComparison{}, marshalErr
|
||||
}
|
||||
totalSearchBytes += float64(len(encodedResponse))
|
||||
|
||||
productBytes, ok := productSizes[item.product]
|
||||
if !ok {
|
||||
payload, payloadErr := queryDeliverySchemaPayload([]string{item.product})
|
||||
if payloadErr != nil {
|
||||
return ToolSearchDiagnosticComparison{}, payloadErr
|
||||
}
|
||||
productBytes, payloadErr = compactJSONSize(stripSchemaPayloadCompact(payload))
|
||||
if payloadErr != nil {
|
||||
return ToolSearchDiagnosticComparison{}, payloadErr
|
||||
}
|
||||
productSizes[item.product] = productBytes
|
||||
}
|
||||
totalProductBytes += float64(productBytes)
|
||||
|
||||
inspectBytes, ok := inspectSizes[item.gold]
|
||||
if !ok {
|
||||
payload, payloadErr := queryDeliverySchemaPayload([]string{item.gold})
|
||||
if payloadErr != nil {
|
||||
return ToolSearchDiagnosticComparison{}, payloadErr
|
||||
}
|
||||
payload = stripSchemaPayloadCompact(payload)
|
||||
inspectBytes, payloadErr = compactJSONSize(SchemaInspectV1Response{
|
||||
Version: "schema-inspect.v1",
|
||||
Catalog: CatalogVersionRef{SourceHash: loaded.Snapshot.SourceHash, SurfaceHash: loaded.Snapshot.SurfaceHash},
|
||||
ToolSpec: payload,
|
||||
})
|
||||
if payloadErr != nil {
|
||||
return ToolSearchDiagnosticComparison{}, payloadErr
|
||||
}
|
||||
inspectSizes[item.gold] = inspectBytes
|
||||
}
|
||||
totalInspectBytes += float64(inspectBytes)
|
||||
}
|
||||
|
||||
count := float64(len(proxyCases))
|
||||
intent := intentAccumulator.metrics()
|
||||
languageSlices := make(map[string]ToolSearchRetrievalMetrics, len(languageAccumulators))
|
||||
for slice, accumulator := range languageAccumulators {
|
||||
languageSlices[slice] = accumulator.metrics()
|
||||
}
|
||||
contextComparison := ToolSearchContextComparison{
|
||||
ToolCount: len(loaded.Index.CanonicalPaths()),
|
||||
FullSchemaAllBytes: allBytes,
|
||||
OverviewBytes: overviewBytes,
|
||||
}
|
||||
if count > 0 {
|
||||
contextComparison.AverageProductBytes = totalProductBytes / count
|
||||
contextComparison.AverageInspectBytes = totalInspectBytes / count
|
||||
contextComparison.AverageIdealSchemaNavigationBytes = float64(overviewBytes) + contextComparison.AverageProductBytes + contextComparison.AverageInspectBytes
|
||||
contextComparison.AverageSearchInspectBytes = totalSearchBytes/count + contextComparison.AverageInspectBytes
|
||||
contextComparison.ReductionVsFullSchema = reduction(float64(allBytes), contextComparison.AverageSearchInspectBytes)
|
||||
contextComparison.ReductionVsIdealNavigation = reduction(contextComparison.AverageIdealSchemaNavigationBytes, contextComparison.AverageSearchInspectBytes)
|
||||
}
|
||||
|
||||
rawWorkflow, decomposedWorkflow, err := evaluateToolSearchWorkflows(ctx, engine, workflows)
|
||||
if err != nil {
|
||||
return ToolSearchDiagnosticComparison{}, err
|
||||
}
|
||||
trust, err := evaluateToolSearchTrust(ctx, engine, loaded.Registry)
|
||||
if err != nil {
|
||||
return ToolSearchDiagnosticComparison{}, err
|
||||
}
|
||||
return ToolSearchDiagnosticComparison{
|
||||
Version: "tool-search-diagnostic-comparison.v1",
|
||||
Catalog: engine.catalogVersion(),
|
||||
Algorithm: engine.lexical.Name(),
|
||||
TopK: 5,
|
||||
IntentProxy: intent,
|
||||
IntentExcludedOverBudget: intentExcludedOverBudget,
|
||||
IntentLanguageSlices: languageSlices,
|
||||
WorkflowRaw: rawWorkflow,
|
||||
WorkflowDecomposed: decomposedWorkflow,
|
||||
Trust: trust,
|
||||
Context: contextComparison,
|
||||
AgentABStatus: "not_run_requires_independent_tasks_and_model_runs",
|
||||
Limitations: []string{
|
||||
"intent_proxy queries are reviewed use_when prose from the same Catalog and are not independent qrels",
|
||||
"selection prose that exceeds the production query byte or rune budget is counted and excluded rather than weakening runtime limits",
|
||||
"direct Schema navigation bytes assume an oracle selects the correct product and do not estimate model selection success",
|
||||
"search plus inspect bytes also inspect the gold leaf even when retrieval misses and are an oracle-assisted capacity upper bound, not observed Agent cost",
|
||||
"workflow decomposition uses reviewed fixture subqueries and is an upper-bound diagnostic",
|
||||
"negative queries are same-source avoid_when prose without alternative gold and measure candidate exposure rather than Agent calls",
|
||||
"byte counts are compact JSON bytes rather than provider-specific tokenizer counts",
|
||||
"true Agent task success, unsafe action rate, recovery success, latency, and token cost require paired Agent A/B runs",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type toolSearchRetrievalAccumulator struct {
|
||||
cases int
|
||||
hitAt1 int
|
||||
hitAt5 int
|
||||
reciprocal float64
|
||||
zeroResults int
|
||||
}
|
||||
|
||||
func (a *toolSearchRetrievalAccumulator) observe(rank int, zeroResults bool) {
|
||||
a.cases++
|
||||
if zeroResults {
|
||||
a.zeroResults++
|
||||
}
|
||||
if rank == 1 {
|
||||
a.hitAt1++
|
||||
}
|
||||
if rank > 0 && rank <= 5 {
|
||||
a.hitAt5++
|
||||
a.reciprocal += 1 / float64(rank)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *toolSearchRetrievalAccumulator) metrics() ToolSearchRetrievalMetrics {
|
||||
result := ToolSearchRetrievalMetrics{Cases: a.cases}
|
||||
if a.cases == 0 {
|
||||
return result
|
||||
}
|
||||
denominator := float64(a.cases)
|
||||
result.RecallAt1 = float64(a.hitAt1) / denominator
|
||||
result.RecallAt5 = float64(a.hitAt5) / denominator
|
||||
result.MeanReciprocalRankAt5 = a.reciprocal / denominator
|
||||
result.ZeroResultRate = float64(a.zeroResults) / denominator
|
||||
return result
|
||||
}
|
||||
|
||||
func toolSearchQueryLanguageSlice(query string) string {
|
||||
hasChinese := false
|
||||
hasASCIIWord := false
|
||||
for _, character := range query {
|
||||
if unicode.Is(unicode.Han, character) {
|
||||
hasChinese = true
|
||||
}
|
||||
if character <= unicode.MaxASCII && (unicode.IsLetter(character) || unicode.IsDigit(character)) {
|
||||
hasASCIIWord = true
|
||||
}
|
||||
}
|
||||
if hasChinese && hasASCIIWord {
|
||||
return "mixed_chinese_ascii"
|
||||
}
|
||||
if hasChinese {
|
||||
return "chinese_only"
|
||||
}
|
||||
return "non_chinese"
|
||||
}
|
||||
|
||||
func evaluateToolSearchTrust(ctx context.Context, engine *ToolSearchEngine, registry SchemaRegistry) (ToolSearchTrustMetrics, error) {
|
||||
result := ToolSearchTrustMetrics{}
|
||||
var canonicalPassed, cliPassed, aliasPassed, nfkcPassed, filteredPassed int
|
||||
var forbiddenAt1, forbiddenAt5 int
|
||||
for _, product := range registry.Products {
|
||||
for _, tool := range product.Tools {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Identity.CanonicalCases++
|
||||
response, err := engine.Search(ctx, ToolSearchRequest{Query: tool.Identity.CanonicalPath})
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("audit exact canonical %s: %w", tool.Identity.CanonicalPath, err)
|
||||
}
|
||||
observeToolSearchIntegrity(&result.Integrity, engine, response)
|
||||
if exactSearchMatches(response, tool.Identity.CanonicalPath) {
|
||||
canonicalPassed++
|
||||
}
|
||||
|
||||
if cliPath := tool.Identity.PrimaryCLIPath; cliPath != "" {
|
||||
result.Identity.PrimaryCLICases++
|
||||
response, err = engine.Search(ctx, ToolSearchRequest{Query: cliPath})
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("audit exact CLI %s: %w", cliPath, err)
|
||||
}
|
||||
observeToolSearchIntegrity(&result.Integrity, engine, response)
|
||||
if exactSearchMatches(response, tool.Identity.CanonicalPath) {
|
||||
cliPassed++
|
||||
}
|
||||
}
|
||||
for _, alias := range tool.Identity.Aliases {
|
||||
result.Identity.AliasCases++
|
||||
response, err = engine.Search(ctx, ToolSearchRequest{Query: alias})
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("audit exact alias %s: %w", alias, err)
|
||||
}
|
||||
observeToolSearchIntegrity(&result.Integrity, engine, response)
|
||||
if exactSearchMatches(response, tool.Identity.CanonicalPath) {
|
||||
aliasPassed++
|
||||
}
|
||||
}
|
||||
nfkcQuery := toolSearchFullwidthASCII(tool.Identity.CanonicalPath)
|
||||
if nfkcQuery != tool.Identity.CanonicalPath {
|
||||
result.Identity.NFKCCases++
|
||||
response, err = engine.Search(ctx, ToolSearchRequest{Query: nfkcQuery})
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("audit NFKC identity %s: %w", tool.Identity.CanonicalPath, err)
|
||||
}
|
||||
observeToolSearchIntegrity(&result.Integrity, engine, response)
|
||||
if exactSearchMatches(response, tool.Identity.CanonicalPath) {
|
||||
nfkcPassed++
|
||||
}
|
||||
}
|
||||
|
||||
result.Identity.ExactFilteredCases++
|
||||
response, err = engine.Search(ctx, ToolSearchRequest{
|
||||
Query: tool.Identity.CanonicalPath,
|
||||
ExcludeCanonicalPaths: []string{tool.Identity.CanonicalPath},
|
||||
})
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("audit exact filtered %s: %w", tool.Identity.CanonicalPath, err)
|
||||
}
|
||||
observeToolSearchIntegrity(&result.Integrity, engine, response)
|
||||
if response.Strategy == "exact_filtered" && response.Abstained && len(response.Candidates) == 0 && response.ExactFiltered != nil && response.ExactFiltered.CanonicalPath == tool.Identity.CanonicalPath && response.ExactFiltered.Reason == "excluded" {
|
||||
filteredPassed++
|
||||
}
|
||||
|
||||
for _, query := range tool.Selection.AvoidWhen {
|
||||
if query == "" {
|
||||
continue
|
||||
}
|
||||
if !toolSearchQueryWithinBudget(query) {
|
||||
result.Negative.ExcludedOverBudget++
|
||||
continue
|
||||
}
|
||||
result.Negative.Cases++
|
||||
response, err = engine.Search(ctx, ToolSearchRequest{Query: query, Limit: 5, CandidateLimit: 20})
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("audit negative %s: %w", tool.Identity.CanonicalPath, err)
|
||||
}
|
||||
observeToolSearchIntegrity(&result.Integrity, engine, response)
|
||||
for rank, candidate := range response.Candidates {
|
||||
if candidate.CanonicalPath != tool.Identity.CanonicalPath {
|
||||
continue
|
||||
}
|
||||
if rank == 0 {
|
||||
forbiddenAt1++
|
||||
}
|
||||
forbiddenAt5++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if result.Identity.CanonicalCases > 0 {
|
||||
result.Identity.CanonicalPassRate = float64(canonicalPassed) / float64(result.Identity.CanonicalCases)
|
||||
}
|
||||
if result.Identity.PrimaryCLICases > 0 {
|
||||
result.Identity.PrimaryCLIPassRate = float64(cliPassed) / float64(result.Identity.PrimaryCLICases)
|
||||
}
|
||||
if result.Identity.AliasCases > 0 {
|
||||
result.Identity.AliasPassRate = float64(aliasPassed) / float64(result.Identity.AliasCases)
|
||||
}
|
||||
if result.Identity.NFKCCases > 0 {
|
||||
result.Identity.NFKCPassRate = float64(nfkcPassed) / float64(result.Identity.NFKCCases)
|
||||
}
|
||||
if result.Identity.ExactFilteredCases > 0 {
|
||||
result.Identity.ExactFilteredPassRate = float64(filteredPassed) / float64(result.Identity.ExactFilteredCases)
|
||||
}
|
||||
if result.Negative.Cases > 0 {
|
||||
result.Negative.ForbiddenAt1 = float64(forbiddenAt1) / float64(result.Negative.Cases)
|
||||
result.Negative.ForbiddenAt5 = float64(forbiddenAt5) / float64(result.Negative.Cases)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func toolSearchFullwidthASCII(value string) string {
|
||||
runes := []rune(value)
|
||||
for index, character := range runes {
|
||||
if character >= '!' && character <= '~' {
|
||||
runes[index] = character + 0xFEE0
|
||||
}
|
||||
}
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
func toolSearchQueryWithinBudget(query string) bool {
|
||||
return len(query) <= maxToolSearchQueryBytes && utf8.RuneCountInString(query) <= maxToolSearchQueryRunes
|
||||
}
|
||||
|
||||
func exactSearchMatches(response ToolSearchResponse, canonical string) bool {
|
||||
return response.Strategy == "exact_guard" && len(response.Candidates) == 1 && response.Candidates[0].CanonicalPath == canonical && response.Candidates[0].RequiresInspect
|
||||
}
|
||||
|
||||
func observeToolSearchIntegrity(metrics *ToolSearchIntegrityMetrics, engine *ToolSearchEngine, response ToolSearchResponse) {
|
||||
metrics.ResponsesEvaluated++
|
||||
if response.Catalog != engine.catalogVersion() {
|
||||
metrics.CatalogBindingFailures++
|
||||
}
|
||||
if encoded, err := json.Marshal(response); err != nil || len(encoded) > maxToolSearchResponseBytes {
|
||||
metrics.ResponseBudgetViolations++
|
||||
}
|
||||
for _, candidate := range response.Candidates {
|
||||
document, ok := engine.documents[candidate.CanonicalPath]
|
||||
if !ok {
|
||||
metrics.UnknownCandidateCount++
|
||||
continue
|
||||
}
|
||||
if !toolSearchEligible(document.tool, nil, nil, nil) {
|
||||
metrics.IneligibleCandidateCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func evaluateToolSearchWorkflows(ctx context.Context, engine *ToolSearchEngine, workflows []ToolSearchWorkflowEvaluationCase) (ToolSearchWorkflowMetrics, ToolSearchWorkflowMetrics, error) {
|
||||
raw := ToolSearchWorkflowMetrics{Cases: len(workflows)}
|
||||
decomposed := ToolSearchWorkflowMetrics{Cases: len(workflows)}
|
||||
if len(workflows) == 0 {
|
||||
return raw, decomposed, nil
|
||||
}
|
||||
for _, workflow := range workflows {
|
||||
if workflow.ID == "" || workflow.Query == "" || len(workflow.Required) == 0 {
|
||||
return raw, decomposed, fmt.Errorf("workflow evaluation case must have id, query and required tools")
|
||||
}
|
||||
one, err := engine.Search(ctx, ToolSearchRequest{Query: workflow.Query, Limit: 5, CandidateLimit: 20})
|
||||
if err != nil {
|
||||
return raw, decomposed, fmt.Errorf("search workflow %s: %w", workflow.ID, err)
|
||||
}
|
||||
many, err := engine.SearchSubqueries(ctx, workflow.Subqueries, ToolSearchRequest{Query: workflow.Query, Limit: 5, CandidateLimit: 20})
|
||||
if err != nil {
|
||||
return raw, decomposed, fmt.Errorf("search decomposed workflow %s: %w", workflow.ID, err)
|
||||
}
|
||||
accumulateWorkflowMetrics(&raw, one.Candidates, workflow.Required)
|
||||
accumulateWorkflowMetrics(&decomposed, many.Candidates, workflow.Required)
|
||||
}
|
||||
denominator := float64(len(workflows))
|
||||
raw.CompleteAt5 /= denominator
|
||||
raw.RequiredRecallAt5 /= denominator
|
||||
decomposed.CompleteAt5 /= denominator
|
||||
decomposed.RequiredRecallAt5 /= denominator
|
||||
return raw, decomposed, nil
|
||||
}
|
||||
|
||||
func accumulateWorkflowMetrics(metrics *ToolSearchWorkflowMetrics, candidates []ToolReference, required []string) {
|
||||
found := make(map[string]bool, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
found[candidate.CanonicalPath] = true
|
||||
}
|
||||
hits := 0
|
||||
for _, canonical := range required {
|
||||
if found[canonical] {
|
||||
hits++
|
||||
}
|
||||
}
|
||||
metrics.RequiredRecallAt5 += float64(hits) / float64(len(required))
|
||||
if hits == len(required) {
|
||||
metrics.CompleteAt5++
|
||||
}
|
||||
}
|
||||
|
||||
func compactJSONSize(value any) (int, error) {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("marshal compact JSON: %w", err)
|
||||
}
|
||||
return len(encoded), nil
|
||||
}
|
||||
|
||||
func reduction(baseline, candidate float64) float64 {
|
||||
if baseline <= 0 {
|
||||
return 0
|
||||
}
|
||||
return math.Max(-1, math.Min(1, 1-candidate/baseline))
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAggregateToolSearchAgentAB(t *testing.T) {
|
||||
catalog := CatalogVersionRef{SourceHash: "sha256:source", SurfaceHash: "sha256:surface"}
|
||||
input := ToolSearchAgentABInput{
|
||||
Version: "tool-search-agent-ab.v1",
|
||||
Catalog: catalog,
|
||||
Runs: []ToolSearchAgentABRun{
|
||||
{CaseID: "a", Trial: 0, Arm: ToolSearchAgentArmDirectSchema, CorrectToolPlan: true, ContextTokens: 100, ToolCalls: 3, LatencyMS: 1000},
|
||||
{CaseID: "a", Trial: 0, Arm: ToolSearchAgentArmSearchInspect, TaskCompleted: true, CorrectToolPlan: true, ContextTokens: 40, ToolCalls: 2, LatencyMS: 800},
|
||||
{CaseID: "b", Trial: 0, Arm: ToolSearchAgentArmDirectSchema, TaskCompleted: true, CorrectToolPlan: true, UnsafeAction: true, RecoveryAttempted: true, ContextTokens: 120, ToolCalls: 4, LatencyMS: 1200},
|
||||
{CaseID: "b", Trial: 0, Arm: ToolSearchAgentArmSearchInspect, TaskCompleted: true, CorrectToolPlan: true, RecoveryAttempted: true, RecoverySucceeded: true, ContextTokens: 50, ToolCalls: 3, LatencyMS: 900},
|
||||
},
|
||||
}
|
||||
report, err := AggregateToolSearchAgentAB(input)
|
||||
if err != nil {
|
||||
t.Fatalf("AggregateToolSearchAgentAB() error = %v", err)
|
||||
}
|
||||
if report.Cases != 2 || report.PairedTrials != 2 {
|
||||
t.Fatalf("pair counts = cases %d trials %d", report.Cases, report.PairedTrials)
|
||||
}
|
||||
if report.DirectSchema.TaskSuccessRate != 0.5 || report.SearchInspect.TaskSuccessRate != 1 {
|
||||
t.Fatalf("unexpected success rates: direct=%v search=%v", report.DirectSchema.TaskSuccessRate, report.SearchInspect.TaskSuccessRate)
|
||||
}
|
||||
if delta := report.Deltas["context_tokens"].Delta; delta != -65 {
|
||||
t.Fatalf("context delta = %v, want -65", delta)
|
||||
}
|
||||
if _, ok := report.Deltas["recovery_success_rate_paired_attempts"]; !ok {
|
||||
t.Fatal("paired recovery delta missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateToolSearchAgentABRejectsUnpairedRuns(t *testing.T) {
|
||||
_, err := AggregateToolSearchAgentAB(ToolSearchAgentABInput{
|
||||
Version: "tool-search-agent-ab.v1",
|
||||
Catalog: CatalogVersionRef{SourceHash: "source", SurfaceHash: "surface"},
|
||||
Runs: []ToolSearchAgentABRun{
|
||||
{CaseID: "a", Arm: ToolSearchAgentArmDirectSchema},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("AggregateToolSearchAgentAB() accepted an unpaired run")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreToolSearchAgentPlanningAB(t *testing.T) {
|
||||
workflows := []ToolSearchWorkflowEvaluationCase{
|
||||
{ID: "one", Required: []string{"chat.search_groups", "chat.send_personal_message"}},
|
||||
{ID: "two", Required: []string{"drive.upload", "drive.add_permission"}},
|
||||
}
|
||||
direct := ToolSearchAgentPlanOutput{Results: []ToolSearchAgentPlanResult{
|
||||
{ID: "one", CanonicalPaths: []string{"chat.search_groups", "chat.send_personal_message"}},
|
||||
{ID: "two", CanonicalPaths: []string{"drive.upload", "drive.add_permission"}},
|
||||
}}
|
||||
search := ToolSearchAgentPlanOutput{Results: []ToolSearchAgentPlanResult{
|
||||
{ID: "one", CanonicalPaths: []string{"chat.list_owned_or_admin_groups", "chat.search_groups", "chat.send_personal_message"}},
|
||||
{ID: "two", CanonicalPaths: []string{"drive.upload", "drive.add_permission"}},
|
||||
}}
|
||||
tools := []ToolSpec{
|
||||
toolSearchTestTool("chat", "search_groups", "chat search-groups", "搜索群聊", "read"),
|
||||
toolSearchTestTool("chat", "send_personal_message", "chat message send", "发送消息", "write"),
|
||||
toolSearchTestTool("drive", "upload", "drive upload", "上传文件", "write"),
|
||||
toolSearchTestTool("drive", "add_permission", "drive permission add", "添加权限", "write"),
|
||||
toolSearchTestTool("chat", "list_owned_or_admin_groups", "chat list-groups", "列出群聊", "read"),
|
||||
}
|
||||
index, indexErr := (SchemaRegistry{Kind: "schema", Level: "catalog", Products: []ProductSpec{
|
||||
{ID: "chat", Tools: []ToolSpec{tools[0], tools[1], tools[4]}},
|
||||
{ID: "drive", Tools: tools[2:4]},
|
||||
}}).Index()
|
||||
if indexErr != nil {
|
||||
t.Fatal(indexErr)
|
||||
}
|
||||
report, err := scoreToolSearchAgentPlanningAB(index, "test-model", workflows, direct, search)
|
||||
if err != nil {
|
||||
t.Fatalf("ScoreToolSearchAgentPlanningAB() error = %v", err)
|
||||
}
|
||||
if report.DirectSchema.ExactMinimalRate != 1 || report.SearchInspect.ExactMinimalRate != 0.5 {
|
||||
t.Fatalf("minimal rates = direct %v search %v", report.DirectSchema.ExactMinimalRate, report.SearchInspect.ExactMinimalRate)
|
||||
}
|
||||
if report.SearchInspect.CompleteRate != 1 || report.SearchInspect.UnnecessaryTools != 1 {
|
||||
t.Fatalf("search metrics = %+v", report.SearchInspect)
|
||||
}
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The product-cluster paired bootstrap uses a fixed sample count and seed so a
|
||||
// sealed qrels run stays reproducible; both are published in the report so an
|
||||
// auditor does not have to read them out of the source.
|
||||
const (
|
||||
toolSearchIndependentBootstrapSamples = 10_000
|
||||
toolSearchIndependentBootstrapSeed = 20260813
|
||||
)
|
||||
|
||||
// ToolSearchIndependentQrel is an independently authored graded relevance
|
||||
// judgment. Relevance uses the frozen 1..3 qrels scale.
|
||||
type ToolSearchIndependentQrel struct {
|
||||
Canonical string `json:"canonical"`
|
||||
Relevance int `json:"relevance"`
|
||||
}
|
||||
|
||||
type ToolSearchIndependentWorkflow struct {
|
||||
Subqueries []string `json:"subqueries,omitempty"`
|
||||
Required []string `json:"required"`
|
||||
}
|
||||
|
||||
type ToolSearchIndependentCase struct {
|
||||
ID string `json:"id"`
|
||||
Query string `json:"query"`
|
||||
Language string `json:"language"`
|
||||
Qrels []ToolSearchIndependentQrel `json:"qrels"`
|
||||
Forbidden []string `json:"forbidden,omitempty"`
|
||||
AlternativeGold []string `json:"alternative_gold,omitempty"`
|
||||
Workflow *ToolSearchIndependentWorkflow `json:"workflow,omitempty"`
|
||||
ConfusionFamily []string `json:"confusion_family,omitempty"`
|
||||
}
|
||||
|
||||
type ToolSearchIndependentRankingMetrics struct {
|
||||
Cases int `json:"cases"`
|
||||
RecallAt1 float64 `json:"recall_at_1"`
|
||||
RecallAt5 float64 `json:"recall_at_5"`
|
||||
MeanReciprocalRankAt5 float64 `json:"mean_reciprocal_rank_at_5"`
|
||||
NDCGAt5 float64 `json:"ndcg_at_5"`
|
||||
ZeroResultRate float64 `json:"zero_result_rate"`
|
||||
}
|
||||
|
||||
type ToolSearchIndependentSafetyMetrics struct {
|
||||
ForbiddenCases int `json:"forbidden_cases"`
|
||||
ForbiddenExposureAt5 float64 `json:"forbidden_exposure_at_5"`
|
||||
AlternativeRecallAt5 float64 `json:"alternative_recall_at_5"`
|
||||
SiblingConfusionCases int `json:"sibling_confusion_cases"`
|
||||
SiblingExposureAt5 float64 `json:"sibling_exposure_at_5"`
|
||||
}
|
||||
|
||||
type ToolSearchIndependentReport struct {
|
||||
Version string `json:"version"`
|
||||
Catalog CatalogVersionRef `json:"catalog"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
ControlAlgorithm string `json:"control_algorithm"`
|
||||
Overall ToolSearchIndependentRankingMetrics `json:"overall"`
|
||||
ControlOverall ToolSearchIndependentRankingMetrics `json:"control_overall"`
|
||||
RecallAt5Delta float64 `json:"recall_at_5_delta"`
|
||||
ProductClusterCI95 ToolSearchConfidenceInterval `json:"product_cluster_recall_at_5_delta_ci_95"`
|
||||
LanguageSlices map[string]ToolSearchIndependentRankingMetrics `json:"language_slices"`
|
||||
Safety ToolSearchIndependentSafetyMetrics `json:"safety"`
|
||||
Workflow ToolSearchWorkflowMetrics `json:"workflow"`
|
||||
// BootstrapSamples/BootstrapSeed are set only by the paired delivery
|
||||
// evaluation that computes ProductClusterCI95; they make that interval
|
||||
// reproducible without reading the source.
|
||||
BootstrapSamples int `json:"bootstrap_samples,omitempty"`
|
||||
BootstrapSeed int64 `json:"bootstrap_seed,omitempty"`
|
||||
}
|
||||
|
||||
type ToolSearchConfidenceInterval struct {
|
||||
Lower float64 `json:"lower"`
|
||||
Upper float64 `json:"upper"`
|
||||
}
|
||||
|
||||
type independentRankingAccumulator struct {
|
||||
metrics ToolSearchIndependentRankingMetrics
|
||||
recall1 float64
|
||||
recall5 float64
|
||||
mrr5 float64
|
||||
ndcg5 float64
|
||||
zero int
|
||||
}
|
||||
|
||||
// BuildDeliveryToolSearchIndependentEvaluation evaluates the action-ranking
|
||||
// candidate against the shipped fielded BM25 control on sealed external qrels.
|
||||
// It never tunes parameters or reads Catalog JSON; the caller owns qrels
|
||||
// sealing and threshold enforcement. The candidate may become the runtime
|
||||
// default only after the independently frozen default-switch gate passes.
|
||||
func BuildDeliveryToolSearchIndependentEvaluation(ctx context.Context, cases []ToolSearchIndependentCase) (ToolSearchIndependentReport, error) {
|
||||
if err := deliverySchemaCatalogError(); err != nil {
|
||||
return ToolSearchIndependentReport{}, err
|
||||
}
|
||||
loaded := deliverySchemaCatalog()
|
||||
candidateConfig := DefaultToolSearchConfig()
|
||||
candidateConfig.LexicalAlgorithm = ToolSearchLexicalBM25Action
|
||||
candidateConfig.CatalogSourceHash = loaded.Snapshot.SourceHash
|
||||
candidateConfig.CatalogSurfaceHash = loaded.Snapshot.SurfaceHash
|
||||
engine, err := NewToolSearchEngine(loaded.Registry, candidateConfig)
|
||||
if err != nil {
|
||||
return ToolSearchIndependentReport{}, err
|
||||
}
|
||||
report, err := EvaluateToolSearchIndependent(ctx, engine, cases)
|
||||
if err != nil {
|
||||
return ToolSearchIndependentReport{}, err
|
||||
}
|
||||
controlConfig := DefaultToolSearchConfig()
|
||||
controlConfig.CatalogSourceHash = loaded.Snapshot.SourceHash
|
||||
controlConfig.CatalogSurfaceHash = loaded.Snapshot.SurfaceHash
|
||||
control, err := NewToolSearchEngine(loaded.Registry, controlConfig)
|
||||
if err != nil {
|
||||
return ToolSearchIndependentReport{}, err
|
||||
}
|
||||
controlReport, err := EvaluateToolSearchIndependent(ctx, control, cases)
|
||||
if err != nil {
|
||||
return ToolSearchIndependentReport{}, err
|
||||
}
|
||||
report.ControlAlgorithm = control.lexical.Name()
|
||||
report.ControlOverall = controlReport.Overall
|
||||
report.RecallAt5Delta = report.Overall.RecallAt5 - report.ControlOverall.RecallAt5
|
||||
report.ProductClusterCI95, err = pairedProductClusterRecallCI(ctx, engine, control, cases, toolSearchIndependentBootstrapSamples, toolSearchIndependentBootstrapSeed)
|
||||
if err != nil {
|
||||
return ToolSearchIndependentReport{}, err
|
||||
}
|
||||
report.BootstrapSamples = toolSearchIndependentBootstrapSamples
|
||||
report.BootstrapSeed = toolSearchIndependentBootstrapSeed
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func EvaluateToolSearchIndependent(ctx context.Context, engine *ToolSearchEngine, cases []ToolSearchIndependentCase) (ToolSearchIndependentReport, error) {
|
||||
if engine == nil || len(cases) == 0 {
|
||||
return ToolSearchIndependentReport{}, fmt.Errorf("independent evaluation requires an engine and non-empty cases")
|
||||
}
|
||||
overall := &independentRankingAccumulator{}
|
||||
slices := map[string]*independentRankingAccumulator{}
|
||||
var forbiddenCases, forbiddenExposed int
|
||||
var siblingCases, siblingExposed int
|
||||
var alternativeRecall float64
|
||||
workflowCases := 0
|
||||
workflowComplete := 0
|
||||
workflowRequiredRecall := 0.0
|
||||
for _, item := range cases {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ToolSearchIndependentReport{}, err
|
||||
}
|
||||
response, searchErr := engine.Search(ctx, ToolSearchRequest{Query: item.Query, Limit: 5, CandidateLimit: 20})
|
||||
if searchErr != nil {
|
||||
return ToolSearchIndependentReport{}, fmt.Errorf("evaluate %s: %w", item.ID, searchErr)
|
||||
}
|
||||
overall.observe(response.Candidates, item.Qrels)
|
||||
if slices[item.Language] == nil {
|
||||
slices[item.Language] = &independentRankingAccumulator{}
|
||||
}
|
||||
slices[item.Language].observe(response.Candidates, item.Qrels)
|
||||
if len(item.Forbidden) > 0 {
|
||||
forbiddenCases++
|
||||
if candidatesContainAny(response.Candidates, item.Forbidden) {
|
||||
forbiddenExposed++
|
||||
}
|
||||
alternativeRecall += candidateSetRecall(response.Candidates, item.AlternativeGold)
|
||||
}
|
||||
if len(item.ConfusionFamily) > 0 {
|
||||
siblingCases++
|
||||
if candidatesContainAny(response.Candidates, item.ConfusionFamily) {
|
||||
siblingExposed++
|
||||
}
|
||||
}
|
||||
if item.Workflow != nil {
|
||||
workflowCases++
|
||||
workflowResponse := response
|
||||
if len(item.Workflow.Subqueries) > 0 {
|
||||
workflowResponse, searchErr = engine.SearchSubqueries(ctx, item.Workflow.Subqueries, ToolSearchRequest{Query: item.Query, Limit: 5, CandidateLimit: 20})
|
||||
if searchErr != nil {
|
||||
return ToolSearchIndependentReport{}, fmt.Errorf("evaluate workflow %s: %w", item.ID, searchErr)
|
||||
}
|
||||
}
|
||||
recall := candidateSetRecall(workflowResponse.Candidates, item.Workflow.Required)
|
||||
workflowRequiredRecall += recall
|
||||
if recall == 1 {
|
||||
workflowComplete++
|
||||
}
|
||||
}
|
||||
}
|
||||
report := ToolSearchIndependentReport{
|
||||
Version: "tool-search-independent-evaluation.v1", Catalog: engine.catalogVersion(), Algorithm: engine.lexical.Name(),
|
||||
Overall: overall.result(), LanguageSlices: map[string]ToolSearchIndependentRankingMetrics{},
|
||||
Safety: ToolSearchIndependentSafetyMetrics{ForbiddenCases: forbiddenCases, SiblingConfusionCases: siblingCases},
|
||||
Workflow: ToolSearchWorkflowMetrics{Cases: workflowCases},
|
||||
}
|
||||
for name, accumulator := range slices {
|
||||
report.LanguageSlices[name] = accumulator.result()
|
||||
}
|
||||
if forbiddenCases > 0 {
|
||||
report.Safety.ForbiddenExposureAt5 = float64(forbiddenExposed) / float64(forbiddenCases)
|
||||
report.Safety.AlternativeRecallAt5 = alternativeRecall / float64(forbiddenCases)
|
||||
}
|
||||
if siblingCases > 0 {
|
||||
report.Safety.SiblingExposureAt5 = float64(siblingExposed) / float64(siblingCases)
|
||||
}
|
||||
if workflowCases > 0 {
|
||||
report.Workflow.CompleteAt5 = float64(workflowComplete) / float64(workflowCases)
|
||||
report.Workflow.RequiredRecallAt5 = workflowRequiredRecall / float64(workflowCases)
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func pairedProductClusterRecallCI(ctx context.Context, candidate, control *ToolSearchEngine, cases []ToolSearchIndependentCase, iterations int, seed int64) (ToolSearchConfidenceInterval, error) {
|
||||
clusters := map[string][]float64{}
|
||||
for _, item := range cases {
|
||||
candidateResponse, err := candidate.Search(ctx, ToolSearchRequest{Query: item.Query, Limit: 5, CandidateLimit: 20})
|
||||
if err != nil {
|
||||
return ToolSearchConfidenceInterval{}, err
|
||||
}
|
||||
controlResponse, err := control.Search(ctx, ToolSearchRequest{Query: item.Query, Limit: 5, CandidateLimit: 20})
|
||||
if err != nil {
|
||||
return ToolSearchConfidenceInterval{}, err
|
||||
}
|
||||
product := "unknown"
|
||||
if len(item.Qrels) > 0 {
|
||||
if split := strings.SplitN(item.Qrels[0].Canonical, ".", 2); len(split) > 0 {
|
||||
product = split[0]
|
||||
}
|
||||
}
|
||||
clusters[product] = append(clusters[product], candidateSetRecall(candidateResponse.Candidates, qrelCanonicals(item.Qrels))-candidateSetRecall(controlResponse.Candidates, qrelCanonicals(item.Qrels)))
|
||||
}
|
||||
clusterNames := make([]string, 0, len(clusters))
|
||||
clusterMeans := map[string]float64{}
|
||||
for name, values := range clusters {
|
||||
clusterNames = append(clusterNames, name)
|
||||
for _, value := range values {
|
||||
clusterMeans[name] += value
|
||||
}
|
||||
clusterMeans[name] /= float64(len(values))
|
||||
}
|
||||
sort.Strings(clusterNames)
|
||||
if len(clusterNames) == 0 || iterations <= 0 {
|
||||
return ToolSearchConfidenceInterval{}, fmt.Errorf("paired product bootstrap requires cases and iterations")
|
||||
}
|
||||
random := rand.New(rand.NewSource(seed)) // #nosec G404 -- deterministic evaluation bootstrap
|
||||
values := make([]float64, iterations)
|
||||
for iteration := range values {
|
||||
for sample := 0; sample < len(clusterNames); sample++ {
|
||||
values[iteration] += clusterMeans[clusterNames[random.Intn(len(clusterNames))]]
|
||||
}
|
||||
values[iteration] /= float64(len(clusterNames))
|
||||
}
|
||||
sort.Float64s(values)
|
||||
return ToolSearchConfidenceInterval{Lower: percentile(values, 0.025), Upper: percentile(values, 0.975)}, nil
|
||||
}
|
||||
|
||||
func qrelCanonicals(qrels []ToolSearchIndependentQrel) []string {
|
||||
values := make([]string, 0, len(qrels))
|
||||
for _, qrel := range qrels {
|
||||
values = append(values, qrel.Canonical)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func (a *independentRankingAccumulator) observe(candidates []ToolReference, qrels []ToolSearchIndependentQrel) {
|
||||
a.metrics.Cases++
|
||||
if len(candidates) == 0 {
|
||||
a.zero++
|
||||
}
|
||||
relevance := make(map[string]int, len(qrels))
|
||||
ideal := make([]int, 0, len(qrels))
|
||||
for _, qrel := range qrels {
|
||||
relevance[qrel.Canonical] = qrel.Relevance
|
||||
ideal = append(ideal, qrel.Relevance)
|
||||
}
|
||||
found := 0
|
||||
first := 0
|
||||
dcg := 0.0
|
||||
for rank, candidate := range candidates {
|
||||
if rank >= 5 {
|
||||
break
|
||||
}
|
||||
rel := relevance[candidate.CanonicalPath]
|
||||
if rel == 0 {
|
||||
continue
|
||||
}
|
||||
found++
|
||||
if first == 0 {
|
||||
first = rank + 1
|
||||
}
|
||||
dcg += (math.Pow(2, float64(rel)) - 1) / math.Log2(float64(rank+2))
|
||||
}
|
||||
if len(candidates) > 0 && relevance[candidates[0].CanonicalPath] > 0 {
|
||||
a.recall1 += 1 / float64(len(qrels))
|
||||
}
|
||||
if len(qrels) > 0 {
|
||||
a.recall5 += float64(found) / float64(len(qrels))
|
||||
}
|
||||
if first > 0 {
|
||||
a.mrr5 += 1 / float64(first)
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.IntSlice(ideal)))
|
||||
idcg := 0.0
|
||||
for rank, rel := range ideal {
|
||||
if rank >= 5 {
|
||||
break
|
||||
}
|
||||
idcg += (math.Pow(2, float64(rel)) - 1) / math.Log2(float64(rank+2))
|
||||
}
|
||||
if idcg > 0 {
|
||||
a.ndcg5 += dcg / idcg
|
||||
}
|
||||
}
|
||||
|
||||
func (a *independentRankingAccumulator) result() ToolSearchIndependentRankingMetrics {
|
||||
result := a.metrics
|
||||
if result.Cases == 0 {
|
||||
return result
|
||||
}
|
||||
denominator := float64(result.Cases)
|
||||
result.RecallAt1 = a.recall1 / denominator
|
||||
result.RecallAt5 = a.recall5 / denominator
|
||||
result.MeanReciprocalRankAt5 = a.mrr5 / denominator
|
||||
result.NDCGAt5 = a.ndcg5 / denominator
|
||||
result.ZeroResultRate = float64(a.zero) / denominator
|
||||
return result
|
||||
}
|
||||
|
||||
func candidatesContainAny(candidates []ToolReference, paths []string) bool {
|
||||
for _, candidate := range candidates {
|
||||
if stringSliceContains(paths, candidate.CanonicalPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func candidateSetRecall(candidates []ToolReference, required []string) float64 {
|
||||
if len(required) == 0 {
|
||||
return 0
|
||||
}
|
||||
found := 0
|
||||
for _, canonical := range required {
|
||||
for _, candidate := range candidates {
|
||||
if candidate.CanonicalPath == canonical {
|
||||
found++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return float64(found) / float64(len(required))
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEvaluateToolSearchIndependentScoresGradedSafetyAndWorkflow(t *testing.T) {
|
||||
engine := newToolSearchTestEngine(t)
|
||||
cases := []ToolSearchIndependentCase{
|
||||
{
|
||||
ID: "chat", Query: "给群里发送消息", Language: "chinese_only",
|
||||
Qrels: []ToolSearchIndependentQrel{{Canonical: "chat.send", Relevance: 3}},
|
||||
Forbidden: []string{"doc.read"}, AlternativeGold: []string{"chat.send"},
|
||||
ConfusionFamily: []string{"chat.read_status"},
|
||||
},
|
||||
{
|
||||
ID: "workflow", Query: "发送消息并读取文档", Language: "chinese_only",
|
||||
Qrels: []ToolSearchIndependentQrel{{Canonical: "chat.send", Relevance: 3}, {Canonical: "doc.read", Relevance: 2}},
|
||||
Workflow: &ToolSearchIndependentWorkflow{
|
||||
Subqueries: []string{"群聊发送消息", "读取在线文档"},
|
||||
Required: []string{"chat.send", "doc.read"},
|
||||
},
|
||||
},
|
||||
}
|
||||
report, err := EvaluateToolSearchIndependent(context.Background(), engine, cases)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Version != "tool-search-independent-evaluation.v1" || report.Overall.Cases != 2 {
|
||||
t.Fatalf("report = %#v", report)
|
||||
}
|
||||
if report.Overall.RecallAt5 != 1 || report.Overall.NDCGAt5 <= 0 || report.Overall.MeanReciprocalRankAt5 <= 0 {
|
||||
t.Fatalf("ranking = %#v", report.Overall)
|
||||
}
|
||||
if report.Safety.ForbiddenCases != 1 || report.Safety.ForbiddenExposureAt5 != 0 || report.Safety.AlternativeRecallAt5 != 1 ||
|
||||
report.Safety.SiblingConfusionCases != 1 || report.Safety.SiblingExposureAt5 != 1 {
|
||||
t.Fatalf("safety = %#v", report.Safety)
|
||||
}
|
||||
if report.Workflow.Cases != 1 || report.Workflow.CompleteAt5 != 1 || report.Workflow.RequiredRecallAt5 != 1 {
|
||||
t.Fatalf("workflow = %#v", report.Workflow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndependentRankingMetricsUseGradedRankDiscounts(t *testing.T) {
|
||||
accumulator := &independentRankingAccumulator{}
|
||||
accumulator.observe([]ToolReference{
|
||||
{CanonicalPath: "irrelevant"},
|
||||
{CanonicalPath: "relevant.low"},
|
||||
}, []ToolSearchIndependentQrel{
|
||||
{Canonical: "relevant.high", Relevance: 3},
|
||||
{Canonical: "relevant.low", Relevance: 2},
|
||||
})
|
||||
metrics := accumulator.result()
|
||||
wantNDCG := (3 / math.Log2(3)) / (7 + 3/math.Log2(3))
|
||||
if metrics.RecallAt1 != 0 || metrics.RecallAt5 != 0.5 || metrics.MeanReciprocalRankAt5 != 0.5 || math.Abs(metrics.NDCGAt5-wantNDCG) > 1e-12 || metrics.ZeroResultRate != 0 {
|
||||
t.Fatalf("metrics = %#v, want R1=0 R5=.5 MRR5=.5 NDCG5=%.12f", metrics, wantNDCG)
|
||||
}
|
||||
|
||||
zero := &independentRankingAccumulator{}
|
||||
zero.observe(nil, []ToolSearchIndependentQrel{{Canonical: "relevant", Relevance: 3}})
|
||||
zeroMetrics := zero.result()
|
||||
if zeroMetrics.ZeroResultRate != 1 || zeroMetrics.RecallAt5 != 0 || zeroMetrics.NDCGAt5 != 0 {
|
||||
t.Fatalf("zero metrics = %#v", zeroMetrics)
|
||||
}
|
||||
}
|
||||
@@ -1,662 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
ToolSearchLexicalBM25 = "fielded_bm25_ensemble"
|
||||
ToolSearchLexicalBM25Action = "fielded_bm25_action_v1"
|
||||
ToolSearchLexicalTFIDF = "fielded_tfidf_cosine"
|
||||
)
|
||||
|
||||
// ToolSearchLexicalRequest contains a prefiltered Catalog domain. Hard
|
||||
// filtering occurs before scoring, so a retriever cannot spend its Top-K on
|
||||
// candidates that DWS is required to reject.
|
||||
type ToolSearchLexicalRequest struct {
|
||||
Query string
|
||||
CandidateLimit int
|
||||
EligibleCanonicalPaths []string
|
||||
}
|
||||
|
||||
// LexicalHit is an internal ordering result, not a confidence probability.
|
||||
// Explain is populated only when the engine was built with Explain=true.
|
||||
type LexicalHit struct {
|
||||
CanonicalPath string
|
||||
Score float64
|
||||
MatchedFields []string
|
||||
Explain *ToolSearchScoreBreakdown
|
||||
}
|
||||
|
||||
// LexicalRetriever is the zero-model local recall boundary. Implementations
|
||||
// must return a deterministic score-desc/canonical-asc order and omit zero
|
||||
// score documents.
|
||||
type LexicalRetriever interface {
|
||||
Name() string
|
||||
Retrieve(context.Context, ToolSearchLexicalRequest) ([]LexicalHit, error)
|
||||
}
|
||||
|
||||
func newToolSearchLexicalRetriever(documents map[string]toolSearchDocument, config ToolSearchConfig) (LexicalRetriever, error) {
|
||||
var retriever LexicalRetriever
|
||||
switch config.LexicalAlgorithm {
|
||||
case ToolSearchLexicalBM25Action:
|
||||
retriever = newToolSearchActionRetriever(
|
||||
newToolSearchBM25Retriever(documents, config.FieldWeights, config.BM25K1, config.BM25B, config.Explain),
|
||||
documents,
|
||||
config.Explain,
|
||||
)
|
||||
case ToolSearchLexicalBM25:
|
||||
retriever = newToolSearchBM25Retriever(documents, config.FieldWeights, config.BM25K1, config.BM25B, config.Explain)
|
||||
case ToolSearchLexicalTFIDF:
|
||||
retriever = newToolSearchTFIDFRetriever(documents, config.FieldWeights, config.Explain)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown tool search lexical algorithm %q", config.LexicalAlgorithm)
|
||||
}
|
||||
return newToolSearchAvoidWhenRetriever(retriever, documents, config.Explain), nil
|
||||
}
|
||||
|
||||
type toolSearchActionRetriever struct {
|
||||
base LexicalRetriever
|
||||
documents map[string]toolSearchDocument
|
||||
explain bool
|
||||
}
|
||||
|
||||
var toolSearchASCIIWordPattern = regexp.MustCompile(`[A-Za-z][A-Za-z0-9._-]*`)
|
||||
|
||||
func newToolSearchActionRetriever(base LexicalRetriever, documents map[string]toolSearchDocument, explain bool) *toolSearchActionRetriever {
|
||||
return &toolSearchActionRetriever{base: base, documents: documents, explain: explain}
|
||||
}
|
||||
|
||||
func (*toolSearchActionRetriever) Name() string { return ToolSearchLexicalBM25Action }
|
||||
|
||||
func (r *toolSearchActionRetriever) Retrieve(ctx context.Context, request ToolSearchLexicalRequest) ([]LexicalHit, error) {
|
||||
deep := request
|
||||
deep.CandidateLimit = len(request.EligibleCanonicalPaths)
|
||||
hits, err := r.base.Retrieve(ctx, deep)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := classifyToolSearchQuery(request.Query)
|
||||
if !query.hasSignal() || toolSearchHasUnclassifiedASCII(request.Query) {
|
||||
return truncateAndSortLexicalHits(hits, request.CandidateLimit), nil
|
||||
}
|
||||
for index := range hits {
|
||||
multiplier := toolSearchStructuredMultiplier(query, r.documents[hits[index].CanonicalPath].tool)
|
||||
hits[index].Score *= multiplier
|
||||
if r.explain && hits[index].Explain != nil {
|
||||
hits[index].Explain.Multiplier = &multiplier
|
||||
hits[index].Explain.Score = hits[index].Score
|
||||
hits[index].Explain.QueryClass = newToolSearchExplainQueryClass(query)
|
||||
}
|
||||
}
|
||||
return truncateAndSortLexicalHits(hits, request.CandidateLimit), nil
|
||||
}
|
||||
|
||||
// toolSearchHasUnclassifiedASCII reports whether the query carries a technical
|
||||
// identifier (camelCase or separator-joined tokens such as openConversationId,
|
||||
// task_id, file-path) that the compact vocabularies cannot classify. Plain
|
||||
// English words no longer disable structured reranking: they match (or fail
|
||||
// to match) the vocabularies exactly like Chinese phrases do, so mixed and
|
||||
// English queries keep the effect/product/action/entity routing instead of
|
||||
// silently degrading to raw BM25.
|
||||
func toolSearchHasUnclassifiedASCII(query string) bool {
|
||||
for _, token := range toolSearchASCIIWordPattern.FindAllString(query, -1) {
|
||||
if len(token) < 4 {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(token) {
|
||||
case "id", "ids", "url", "uri":
|
||||
continue
|
||||
}
|
||||
if strings.ContainsAny(token, "._-") {
|
||||
return true
|
||||
}
|
||||
// Only an internal uppercase transition (openConversationId) marks a
|
||||
// technical identifier; a capitalized plain word ("Send") does not.
|
||||
if internal := token[1:]; strings.ToLower(internal) != internal {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type toolSearchQueryClass struct {
|
||||
actions map[string]bool
|
||||
products map[string]bool
|
||||
entities map[string]bool
|
||||
effect string
|
||||
}
|
||||
|
||||
func (q toolSearchQueryClass) hasSignal() bool {
|
||||
return len(q.actions) > 0 || len(q.products) > 0 || len(q.entities) > 0 || q.effect != ""
|
||||
}
|
||||
|
||||
type toolSearchTermClass struct {
|
||||
name string
|
||||
query []string
|
||||
tool []string
|
||||
effect string
|
||||
}
|
||||
|
||||
var toolSearchActionClasses = []toolSearchTermClass{
|
||||
{name: "approve", query: []string{"同意", "批准", "审批通过", "approve"}, tool: []string{"approve"}},
|
||||
{name: "reject", query: []string{"拒绝", "驳回", "reject"}, tool: []string{"reject"}},
|
||||
{name: "upload", query: []string{"上传", "upload"}, tool: []string{"upload"}},
|
||||
{name: "download", query: []string{"下载", "download"}, tool: []string{"download"}},
|
||||
{name: "send", query: []string{"发送", "发给", "发消息", "通知", "投递", "send", "notify"}, tool: []string{"send", "notify"}},
|
||||
{name: "search", query: []string{"搜索", "查找", "检索", "定位", "search", "find", "locate"}, tool: []string{"search", "find", "resolve"}},
|
||||
{name: "list", query: []string{"列出", "列表", "浏览", "遍历", "list", "browse"}, tool: []string{"list", "all"}},
|
||||
{name: "read", query: []string{"读取", "查看", "获取", "详情", "正文", "read", "fetch"}, tool: []string{"read", "get", "detail", "info", "query"}},
|
||||
{name: "create", query: []string{"创建", "新建", "create"}, tool: []string{"create", "new"}},
|
||||
{name: "add", query: []string{"添加", "追加", "增加", "授权", "授予", "add", "invite"}, tool: []string{"add", "grant", "invite"}},
|
||||
{name: "update", query: []string{"更新", "修改", "编辑", "重命名", "调整", "update", "edit", "rename"}, tool: []string{"update", "edit", "rename", "set"}},
|
||||
{name: "delete", query: []string{"删除", "移除", "清除", "delete", "remove"}, tool: []string{"delete", "remove", "clear"}},
|
||||
{name: "complete", query: []string{"完成", "提交入库", "complete", "commit"}, tool: []string{"complete", "commit", "finish"}},
|
||||
{name: "enable", query: []string{"启用", "开启", "enable"}, tool: []string{"enable"}},
|
||||
{name: "disable", query: []string{"禁用", "停用", "关闭", "disable"}, tool: []string{"disable"}},
|
||||
}
|
||||
|
||||
var toolSearchProductClasses = []toolSearchTermClass{
|
||||
{name: "chat", query: []string{"群聊", "群消息", "会话", "单聊", "chat"}, tool: []string{"chat"}},
|
||||
{name: "calendar", query: []string{"日程", "会议日历", "会议室", "calendar"}, tool: []string{"calendar"}},
|
||||
{name: "drive", query: []string{"钉盘", "云盘", "drive"}, tool: []string{"drive"}},
|
||||
{name: "wiki", query: []string{"知识库", "wiki"}, tool: []string{"wiki"}},
|
||||
{name: "todo", query: []string{"待办", "todo"}, tool: []string{"todo"}},
|
||||
{name: "minutes", query: []string{"听记", "会议纪要", "minutes"}, tool: []string{"minutes"}},
|
||||
{name: "oa", query: []string{"审批", "oa", "approval"}, tool: []string{"oa"}},
|
||||
{name: "mail", query: []string{"邮件", "邮箱", "草稿", "mail", "email"}, tool: []string{"mail"}},
|
||||
{name: "sheet", query: []string{"电子表格", "工作表", "单元格", "sheet", "spreadsheet"}, tool: []string{"sheet"}},
|
||||
{name: "doc", query: []string{"在线文字文档", "文档正文", "adoc", "doc", "document"}, tool: []string{"doc"}},
|
||||
}
|
||||
|
||||
var toolSearchEntityClasses = []toolSearchTermClass{
|
||||
{name: "task_id", query: []string{"任务 id", "任务id", "task id", "taskid"}, tool: []string{"任务 id", "任务id", "task id", "taskid"}},
|
||||
{name: "task", query: []string{"任务", "task id", "taskid", "task"}, tool: []string{"任务", "task"}},
|
||||
{name: "permission", query: []string{"权限", "授权", "协作者", "permission"}, tool: []string{"权限", "授权", "协作者", "permission"}},
|
||||
{name: "content", query: []string{"正文", "文档内容", "content"}, tool: []string{"正文", "内容", "content"}},
|
||||
{name: "status", query: []string{"状态", "已读", "status"}, tool: []string{"状态", "已读", "status"}},
|
||||
{name: "draft", query: []string{"草稿", "draft"}, tool: []string{"草稿", "draft"}},
|
||||
{name: "event", query: []string{"日程", "event"}, tool: []string{"日程", "event"}},
|
||||
{name: "reminder", query: []string{"提醒", "reminder"}, tool: []string{"提醒", "reminder"}},
|
||||
{name: "summary", query: []string{"摘要", "summary"}, tool: []string{"摘要", "summary"}},
|
||||
{name: "sheet", query: []string{"工作表", "worksheet"}, tool: []string{"工作表", "sheet"}},
|
||||
{name: "range", query: []string{"单元格范围", "区域", "range"}, tool: []string{"范围", "区域", "range"}},
|
||||
{name: "group", query: []string{"群聊", "群名", "group"}, tool: []string{"群聊", "群", "group"}},
|
||||
}
|
||||
|
||||
func classifyToolSearchQuery(query string) toolSearchQueryClass {
|
||||
normalized := strings.ToLower(strings.TrimSpace(query))
|
||||
result := toolSearchQueryClass{actions: map[string]bool{}, products: map[string]bool{}, entities: map[string]bool{}}
|
||||
for _, class := range toolSearchActionClasses {
|
||||
if containsAnyToolSearchPhrase(normalized, class.query) {
|
||||
result.actions[class.name] = true
|
||||
}
|
||||
}
|
||||
for _, class := range toolSearchProductClasses {
|
||||
if containsAnyToolSearchPhrase(normalized, class.query) {
|
||||
result.products[class.name] = true
|
||||
}
|
||||
}
|
||||
for _, class := range toolSearchEntityClasses {
|
||||
if class.name == "task" && result.entities["task_id"] {
|
||||
continue
|
||||
}
|
||||
if containsAnyToolSearchPhrase(normalized, class.query) {
|
||||
result.entities[class.name] = true
|
||||
}
|
||||
}
|
||||
// “查询” is intentionally only a broad read-effect signal. Treating it as
|
||||
// get/list/search would impose a false sibling preference.
|
||||
if strings.Contains(normalized, "查询") || result.actions["read"] || result.actions["search"] || result.actions["list"] {
|
||||
result.effect = "read"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toolSearchStructuredMultiplier(query toolSearchQueryClass, tool ToolSpec) float64 {
|
||||
multiplier := 1.0
|
||||
if query.effect != "" {
|
||||
if tool.Safety.Effect == query.effect {
|
||||
multiplier += 0.10
|
||||
} else {
|
||||
multiplier -= 0.50
|
||||
}
|
||||
}
|
||||
if len(query.products) > 0 {
|
||||
if query.products[tool.Identity.ProductID] {
|
||||
multiplier += 0.50
|
||||
} else {
|
||||
multiplier -= 0.30
|
||||
}
|
||||
}
|
||||
toolText := strings.ToLower(strings.Join([]string{
|
||||
tool.Identity.Name,
|
||||
tool.Identity.CLIName,
|
||||
tool.Identity.CanonicalPath,
|
||||
tool.Identity.PrimaryCLIPath,
|
||||
tool.Title,
|
||||
tool.Selection.AgentSummary,
|
||||
}, " "))
|
||||
if len(query.actions) > 0 {
|
||||
matched := false
|
||||
for _, class := range toolSearchActionClasses {
|
||||
if query.actions[class.name] && containsAnyToolSearchWord(toolText, class.tool) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
multiplier += 0.60
|
||||
} else {
|
||||
// An explicit action is a stronger constraint than generic lexical
|
||||
// overlap. Penalize tools that do not advertise that action even
|
||||
// when their sibling verb is not in the compact action vocabulary.
|
||||
multiplier -= 0.65
|
||||
}
|
||||
}
|
||||
if len(query.entities) > 0 {
|
||||
matched := false
|
||||
for _, class := range toolSearchEntityClasses {
|
||||
if query.entities[class.name] && containsAnyToolSearchPhrase(toolText, class.tool) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
multiplier += 0.35
|
||||
} else {
|
||||
multiplier -= 0.10
|
||||
}
|
||||
}
|
||||
if multiplier < 0.20 {
|
||||
return 0.20
|
||||
}
|
||||
return multiplier
|
||||
}
|
||||
|
||||
func containsAnyToolSearchPhrase(text string, values []string) bool {
|
||||
for _, value := range values {
|
||||
if toolSearchPhraseContains(text, value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// toolSearchPhraseContains matches ASCII single-token values on word
|
||||
// boundaries so short English values such as "oa" cannot fire inside
|
||||
// unrelated words ("download" contains "oa"). Mixed-script or multi-word
|
||||
// values keep substring semantics, which is what Chinese phrases and values
|
||||
// like "task id" rely on.
|
||||
func toolSearchPhraseContains(text, value string) bool {
|
||||
if value != "" && isAllToolSearchASCII(value) && !strings.ContainsAny(value, " \t") {
|
||||
return containsToolSearchASCIIWord(text, value)
|
||||
}
|
||||
return strings.Contains(text, value)
|
||||
}
|
||||
|
||||
func isAllToolSearchASCII(value string) bool {
|
||||
for _, character := range value {
|
||||
if character >= utf8.RuneSelf {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func containsToolSearchASCIIWord(text, word string) bool {
|
||||
word = strings.ToLower(word)
|
||||
for _, token := range toolSearchASCIIWordPattern.FindAllString(strings.ToLower(text), -1) {
|
||||
if token == word {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsAnyToolSearchWord(text string, values []string) bool {
|
||||
normalized := strings.NewReplacer(".", " ", "_", " ", "-", " ", "/", " ").Replace(text)
|
||||
words := strings.Fields(normalized)
|
||||
for _, value := range values {
|
||||
for _, word := range words {
|
||||
if word == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func newToolSearchExplainQueryClass(query toolSearchQueryClass) *ToolSearchExplainQueryClass {
|
||||
return &ToolSearchExplainQueryClass{
|
||||
Actions: sortedToolSearchClassKeys(query.actions),
|
||||
Products: sortedToolSearchClassKeys(query.products),
|
||||
Entities: sortedToolSearchClassKeys(query.entities),
|
||||
Effect: query.effect,
|
||||
}
|
||||
}
|
||||
|
||||
func sortedToolSearchClassKeys(set map[string]bool) []string {
|
||||
if len(set) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(set))
|
||||
for key := range set {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// toolSearchAvoidWhenPenalty is applied when a candidate's avoid_when prose
|
||||
// matches the query intent. It is a soft demotion, not an eligibility filter:
|
||||
// avoid_when is natural-language guidance (often "prefer X for that intent"),
|
||||
// so a lexical hit can be wrong and the tool must remain reachable.
|
||||
const toolSearchAvoidWhenPenalty = 0.20
|
||||
|
||||
// toolSearchAvoidWhenShortcutNoise filters the boilerplate avoid_when that
|
||||
// every shortcut carries; it carries no intent signal. Kept lowercase because
|
||||
// phrases are compared in the lowercased domain.
|
||||
const toolSearchAvoidWhenShortcutNoise = "需要该 shortcut 未公开的底层参数"
|
||||
|
||||
// toolSearchAvoidWhenPenaltyReason returns the matched avoid_when phrase, or
|
||||
// "" when the tool should not be demoted for this query. The only match shape
|
||||
// is the query containing the avoidance prose itself (the Forbidden@5
|
||||
// evaluation proxy). Intent-overlap matching was tried and rejected: a tool's
|
||||
// own avoid_when describes the same sibling scenario space as its use_when,
|
||||
// so "shares an action/entity with the phrase" demotes the gold answer.
|
||||
func toolSearchAvoidWhenPenaltyReason(query string, tool ToolSpec) string {
|
||||
if len(tool.Selection.AvoidWhen) == 0 {
|
||||
return ""
|
||||
}
|
||||
normalized := strings.ToLower(strings.TrimSpace(query))
|
||||
if normalized == "" {
|
||||
return ""
|
||||
}
|
||||
for _, phrase := range tool.Selection.AvoidWhen {
|
||||
phrase = strings.ToLower(strings.TrimSpace(phrase))
|
||||
if phrase == "" || strings.Contains(phrase, toolSearchAvoidWhenShortcutNoise) {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(normalized, phrase) {
|
||||
return phrase
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// toolSearchAvoidWhenRetriever demotes candidates whose avoid_when prose
|
||||
// matches the query intent. It wraps every lexical algorithm, including the
|
||||
// production ensemble default, so the Forbidden exposure guard is not tied to
|
||||
// action_v1.
|
||||
type toolSearchAvoidWhenRetriever struct {
|
||||
base LexicalRetriever
|
||||
documents map[string]toolSearchDocument
|
||||
explain bool
|
||||
}
|
||||
|
||||
func newToolSearchAvoidWhenRetriever(base LexicalRetriever, documents map[string]toolSearchDocument, explain bool) *toolSearchAvoidWhenRetriever {
|
||||
return &toolSearchAvoidWhenRetriever{base: base, documents: documents, explain: explain}
|
||||
}
|
||||
|
||||
func (r *toolSearchAvoidWhenRetriever) Name() string { return r.base.Name() }
|
||||
|
||||
func (r *toolSearchAvoidWhenRetriever) Retrieve(ctx context.Context, request ToolSearchLexicalRequest) ([]LexicalHit, error) {
|
||||
hits, err := r.base.Retrieve(ctx, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
penalized := false
|
||||
for index := range hits {
|
||||
reason := toolSearchAvoidWhenPenaltyReason(request.Query, r.documents[hits[index].CanonicalPath].tool)
|
||||
if reason == "" {
|
||||
continue
|
||||
}
|
||||
hits[index].Score *= toolSearchAvoidWhenPenalty
|
||||
if r.explain && hits[index].Explain != nil {
|
||||
hits[index].Explain.Score = hits[index].Score
|
||||
hits[index].Explain.AvoidWhenPenalty = reason
|
||||
}
|
||||
penalized = true
|
||||
}
|
||||
if !penalized {
|
||||
return hits, nil
|
||||
}
|
||||
return truncateAndSortLexicalHits(hits, request.CandidateLimit), nil
|
||||
}
|
||||
|
||||
type toolSearchBM25Retriever struct {
|
||||
fieldIndex map[toolSearchField]toolSearchBM25Index
|
||||
weights ToolSearchFieldWeights
|
||||
explain bool
|
||||
}
|
||||
|
||||
func newToolSearchBM25Retriever(documents map[string]toolSearchDocument, weights ToolSearchFieldWeights, k1, b float64, explain bool) *toolSearchBM25Retriever {
|
||||
fieldTerms := toolSearchFieldTerms(documents)
|
||||
fieldIndex := make(map[toolSearchField]toolSearchBM25Index, len(toolSearchFieldOrder))
|
||||
for _, field := range toolSearchFieldOrder {
|
||||
fieldIndex[field] = newToolSearchBM25Index(fieldTerms[field], k1, b)
|
||||
}
|
||||
return &toolSearchBM25Retriever{fieldIndex: fieldIndex, weights: weights, explain: explain}
|
||||
}
|
||||
|
||||
func (*toolSearchBM25Retriever) Name() string { return ToolSearchLexicalBM25 }
|
||||
|
||||
func (r *toolSearchBM25Retriever) Retrieve(ctx context.Context, request ToolSearchLexicalRequest) ([]LexicalHit, error) {
|
||||
queryTerms := orderedToolSearchQueryTerms(request.Query)
|
||||
hits := make([]LexicalHit, 0, len(request.EligibleCanonicalPaths))
|
||||
for _, canonical := range request.EligibleCanonicalPaths {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contributions := make(map[toolSearchField]float64)
|
||||
score := 0.0
|
||||
for _, field := range toolSearchFieldOrder {
|
||||
weight := toolSearchFieldWeight(r.weights, field)
|
||||
if weight <= 0 {
|
||||
continue
|
||||
}
|
||||
value := r.fieldIndex[field].score(canonical, queryTerms) * weight
|
||||
if value > 0 {
|
||||
contributions[field] = value
|
||||
score += value
|
||||
}
|
||||
}
|
||||
if score > 0 {
|
||||
hit := LexicalHit{CanonicalPath: canonical, Score: score, MatchedFields: matchedToolSearchFields(contributions)}
|
||||
if r.explain {
|
||||
hit.Explain = newToolSearchBreakdown(score, contributions)
|
||||
}
|
||||
hits = append(hits, hit)
|
||||
}
|
||||
}
|
||||
return truncateAndSortLexicalHits(hits, request.CandidateLimit), nil
|
||||
}
|
||||
|
||||
type toolSearchTFIDFDocument struct {
|
||||
weights map[string]float64
|
||||
norm float64
|
||||
}
|
||||
|
||||
type toolSearchTFIDFIndex struct {
|
||||
documents map[string]toolSearchTFIDFDocument
|
||||
idf map[string]float64
|
||||
}
|
||||
|
||||
type toolSearchTFIDFRetriever struct {
|
||||
fieldIndex map[toolSearchField]toolSearchTFIDFIndex
|
||||
weights ToolSearchFieldWeights
|
||||
explain bool
|
||||
}
|
||||
|
||||
func newToolSearchTFIDFRetriever(documents map[string]toolSearchDocument, weights ToolSearchFieldWeights, explain bool) *toolSearchTFIDFRetriever {
|
||||
fieldTerms := toolSearchFieldTerms(documents)
|
||||
fieldIndex := make(map[toolSearchField]toolSearchTFIDFIndex, len(toolSearchFieldOrder))
|
||||
for _, field := range toolSearchFieldOrder {
|
||||
fieldIndex[field] = newToolSearchTFIDFIndex(fieldTerms[field])
|
||||
}
|
||||
return &toolSearchTFIDFRetriever{fieldIndex: fieldIndex, weights: weights, explain: explain}
|
||||
}
|
||||
|
||||
func (*toolSearchTFIDFRetriever) Name() string { return ToolSearchLexicalTFIDF }
|
||||
|
||||
func (r *toolSearchTFIDFRetriever) Retrieve(ctx context.Context, request ToolSearchLexicalRequest) ([]LexicalHit, error) {
|
||||
queryFrequency := toolSearchTermFrequency(tokenizeToolSearchText(request.Query))
|
||||
hits := make([]LexicalHit, 0, len(request.EligibleCanonicalPaths))
|
||||
for _, canonical := range request.EligibleCanonicalPaths {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contributions := make(map[toolSearchField]float64)
|
||||
score := 0.0
|
||||
for _, field := range toolSearchFieldOrder {
|
||||
weight := toolSearchFieldWeight(r.weights, field)
|
||||
if weight <= 0 {
|
||||
continue
|
||||
}
|
||||
value := r.fieldIndex[field].score(canonical, queryFrequency) * weight
|
||||
if value > 0 {
|
||||
contributions[field] = value
|
||||
score += value
|
||||
}
|
||||
}
|
||||
if score > 0 {
|
||||
hit := LexicalHit{CanonicalPath: canonical, Score: score, MatchedFields: matchedToolSearchFields(contributions)}
|
||||
if r.explain {
|
||||
hit.Explain = newToolSearchBreakdown(score, contributions)
|
||||
}
|
||||
hits = append(hits, hit)
|
||||
}
|
||||
}
|
||||
return truncateAndSortLexicalHits(hits, request.CandidateLimit), nil
|
||||
}
|
||||
|
||||
func toolSearchFieldTerms(documents map[string]toolSearchDocument) map[toolSearchField]map[string][]string {
|
||||
fieldTerms := make(map[toolSearchField]map[string][]string, len(toolSearchFieldOrder))
|
||||
for _, field := range toolSearchFieldOrder {
|
||||
fieldTerms[field] = make(map[string][]string, len(documents))
|
||||
}
|
||||
for canonical, document := range documents {
|
||||
for _, field := range toolSearchFieldOrder {
|
||||
fieldTerms[field][canonical] = tokenizeToolSearchText(document.fields[field])
|
||||
}
|
||||
}
|
||||
return fieldTerms
|
||||
}
|
||||
|
||||
func orderedToolSearchQueryTerms(query string) []toolSearchQueryTerm {
|
||||
frequency := toolSearchTermFrequency(tokenizeToolSearchText(query))
|
||||
terms := make([]toolSearchQueryTerm, 0, len(frequency))
|
||||
for term, count := range frequency {
|
||||
terms = append(terms, toolSearchQueryTerm{term: term, count: count})
|
||||
}
|
||||
sort.Slice(terms, func(i, j int) bool { return terms[i].term < terms[j].term })
|
||||
return terms
|
||||
}
|
||||
|
||||
func toolSearchTermFrequency(tokens []string) map[string]int {
|
||||
frequency := make(map[string]int, len(tokens))
|
||||
for _, token := range tokens {
|
||||
frequency[token]++
|
||||
}
|
||||
return frequency
|
||||
}
|
||||
|
||||
func newToolSearchTFIDFIndex(documents map[string][]string) toolSearchTFIDFIndex {
|
||||
index := toolSearchTFIDFIndex{documents: make(map[string]toolSearchTFIDFDocument, len(documents)), idf: map[string]float64{}}
|
||||
documentFrequency := map[string]int{}
|
||||
frequencies := make(map[string]map[string]int, len(documents))
|
||||
for canonical, tokens := range documents {
|
||||
frequency := toolSearchTermFrequency(tokens)
|
||||
frequencies[canonical] = frequency
|
||||
for term := range frequency {
|
||||
documentFrequency[term]++
|
||||
}
|
||||
}
|
||||
n := float64(len(documents))
|
||||
for term, frequency := range documentFrequency {
|
||||
index.idf[term] = math.Log((n+1)/(float64(frequency)+1)) + 1
|
||||
}
|
||||
for canonical, frequency := range frequencies {
|
||||
weights := make(map[string]float64, len(frequency))
|
||||
normSquared := 0.0
|
||||
terms := make([]string, 0, len(frequency))
|
||||
for term := range frequency {
|
||||
terms = append(terms, term)
|
||||
}
|
||||
sort.Strings(terms)
|
||||
for _, term := range terms {
|
||||
count := frequency[term]
|
||||
value := float64(count) * index.idf[term]
|
||||
weights[term] = value
|
||||
normSquared += value * value
|
||||
}
|
||||
index.documents[canonical] = toolSearchTFIDFDocument{weights: weights, norm: math.Sqrt(normSquared)}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func (i toolSearchTFIDFIndex) score(canonical string, queryFrequency map[string]int) float64 {
|
||||
document, ok := i.documents[canonical]
|
||||
if !ok || document.norm == 0 {
|
||||
return 0
|
||||
}
|
||||
terms := make([]string, 0, len(queryFrequency))
|
||||
for term := range queryFrequency {
|
||||
terms = append(terms, term)
|
||||
}
|
||||
sort.Strings(terms)
|
||||
dot := 0.0
|
||||
queryNormSquared := 0.0
|
||||
for _, term := range terms {
|
||||
idf, ok := i.idf[term]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
queryWeight := float64(queryFrequency[term]) * idf
|
||||
queryNormSquared += queryWeight * queryWeight
|
||||
dot += document.weights[term] * queryWeight
|
||||
}
|
||||
if queryNormSquared == 0 {
|
||||
return 0
|
||||
}
|
||||
return dot / (document.norm * math.Sqrt(queryNormSquared))
|
||||
}
|
||||
|
||||
// toolSearchScoreQuantum is the sort-key quantization grid. Go permits FMA
|
||||
// fusion on arm64 but not amd64, so the same ranking expression can differ in
|
||||
// the last ulp across architectures; ordering on the raw float would let that
|
||||
// ulp flip near-tied candidates past the canonical tie-break and break the
|
||||
// byte-determinism contract across CI (linux/amd64) and dev (macOS/arm64)
|
||||
// machines. Quantizing only the ordering key keeps same-architecture output
|
||||
// identical while making cross-architecture ordering agree; reported scores
|
||||
// (explain-only) are untouched.
|
||||
const toolSearchScoreQuantum = 1e9
|
||||
|
||||
func quantizeToolSearchScore(score float64) float64 {
|
||||
return math.Round(score*toolSearchScoreQuantum) / toolSearchScoreQuantum
|
||||
}
|
||||
|
||||
func truncateAndSortLexicalHits(hits []LexicalHit, limit int) []LexicalHit {
|
||||
sort.Slice(hits, func(i, j int) bool {
|
||||
left, right := quantizeToolSearchScore(hits[i].Score), quantizeToolSearchScore(hits[j].Score)
|
||||
if left != right {
|
||||
return left > right
|
||||
}
|
||||
return hits[i].CanonicalPath < hits[j].CanonicalPath
|
||||
})
|
||||
if limit >= 0 && len(hits) > limit {
|
||||
hits = hits[:limit]
|
||||
}
|
||||
return hits
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,7 @@ type ContractFinalPayload struct {
|
||||
Parameters []ParamDecl
|
||||
Safety *SafetySpec
|
||||
DryRun *DryRunSpec
|
||||
Wait *WaitSpec
|
||||
Result *ResultSpec
|
||||
Pagination *PaginationSpec
|
||||
Interface *InterfaceSpec
|
||||
|
||||
@@ -62,6 +62,155 @@ type DryRunSpec struct {
|
||||
RemoteReads bool `json:"remote_reads,omitempty"`
|
||||
}
|
||||
|
||||
// Wait modes. Poll executes the leaf's WaitPoll hook on a cadence. Event
|
||||
// consumes the leaf's WaitEvents push stream and correlates events to the
|
||||
// accepted resource. Auto prefers the event stream and falls back to polling
|
||||
// when the stream ends before a terminal status.
|
||||
const (
|
||||
WaitModePoll = "poll"
|
||||
WaitModeEvent = "event"
|
||||
WaitModeAuto = "auto"
|
||||
)
|
||||
|
||||
// WaitSpec is a positive capability declaration for terminal-state waiting
|
||||
// (approval flows, async exports, batch jobs). A nil ToolSpec.Wait means the
|
||||
// command has not declared reviewed --wait support; the flag is not
|
||||
// registered and the Schema does not publish the capability.
|
||||
//
|
||||
// Like DryRunSpec, the object is one atomic contract field: Schema only
|
||||
// projects the reviewed capability; runtime execution stays owned by the
|
||||
// command runner through the leaf's WaitPoll / WaitEvents hooks. PollCommand
|
||||
// names the read command that observes status — it is a declared,
|
||||
// catalog-visible fact (the same command an agent would poll manually), not
|
||||
// a framework-owned invocation: how one poll or event subscription executes
|
||||
// is decided by the leaf.
|
||||
type WaitSpec struct {
|
||||
Mode string `json:"mode"`
|
||||
PollCommand string `json:"poll_command,omitempty"`
|
||||
StatusQuery string `json:"status_query"`
|
||||
Terminal map[string]ResultOutcome `json:"terminal"`
|
||||
PendingValues []string `json:"pending_values,omitempty"`
|
||||
// EventKey is the push channel key the WaitEvents hook subscribes to
|
||||
// (event/auto modes). Declared for the catalog; the transport stays
|
||||
// leaf-owned.
|
||||
EventKey string `json:"event_key,omitempty"`
|
||||
// MatchField is the event-document path holding the resource identifier
|
||||
// (event/auto modes); its value must equal the ResourceQuery resolution
|
||||
// of the accepted result.
|
||||
MatchField string `json:"match_field,omitempty"`
|
||||
// ResourceQuery is the dotted path into the accepted result data that
|
||||
// yields the resource identifier correlated against MatchField
|
||||
// (event/auto modes).
|
||||
ResourceQuery string `json:"resource_query,omitempty"`
|
||||
// DefaultTimeoutSecs is the reviewed default for --wait-timeout. Zero
|
||||
// means the framework default (300s); the user flag always wins.
|
||||
DefaultTimeoutSecs int `json:"default_timeout_secs"`
|
||||
}
|
||||
|
||||
// Validate checks mode requirements and the terminal/pending status maps.
|
||||
// Unknown terminal outcomes, unknown modes, and mode/body mismatches fail at
|
||||
// declaration so a malformed wait capability cannot reach the wire.
|
||||
// Validation delegates to NormalizeWaitSpec so the acceptance rules can never
|
||||
// drift from the normalization the wire and the runtime wait engine share.
|
||||
func (w WaitSpec) Validate(canonical string) error {
|
||||
_, err := NormalizeWaitSpec(&w, canonical)
|
||||
return err
|
||||
}
|
||||
|
||||
// NormalizeWaitSpec returns a validated, canonical, defensively copied wait
|
||||
// contract. It is shared by declaration (corecmd.New / AttachContract),
|
||||
// ToolSpec, and snapshot paths, mirroring NormalizeResultSpec. Status values
|
||||
// are trimmed into their wire form: the wait engine compares backend
|
||||
// statuses verbatim against these tables, so a padded declaration
|
||||
// (" processing ") would publish a Schema that its own runtime treats as an
|
||||
// unknown status. Values collapsing onto one value after trimming (duplicate
|
||||
// pending values, duplicate terminal keys, terminal/pending conflicts) are
|
||||
// rejected instead of silently merged.
|
||||
func NormalizeWaitSpec(in *WaitSpec, canonical string) (*WaitSpec, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
canonical = defaultString(strings.TrimSpace(canonical), "<unknown>")
|
||||
out := &WaitSpec{
|
||||
Mode: strings.TrimSpace(in.Mode),
|
||||
PollCommand: strings.TrimSpace(in.PollCommand),
|
||||
StatusQuery: strings.TrimSpace(in.StatusQuery),
|
||||
EventKey: strings.TrimSpace(in.EventKey),
|
||||
MatchField: strings.TrimSpace(in.MatchField),
|
||||
ResourceQuery: strings.TrimSpace(in.ResourceQuery),
|
||||
DefaultTimeoutSecs: in.DefaultTimeoutSecs,
|
||||
}
|
||||
if out.Mode == "" {
|
||||
return nil, fmt.Errorf("schema tool %s wait has no mode", canonical)
|
||||
}
|
||||
switch out.Mode {
|
||||
case WaitModePoll, WaitModeEvent, WaitModeAuto:
|
||||
default:
|
||||
return nil, fmt.Errorf("schema tool %s wait has unknown mode %q", canonical, out.Mode)
|
||||
}
|
||||
needsPoll := out.Mode == WaitModePoll || out.Mode == WaitModeAuto
|
||||
if needsPoll && out.PollCommand == "" {
|
||||
return nil, fmt.Errorf("schema tool %s wait mode %s requires poll_command", canonical, out.Mode)
|
||||
}
|
||||
needsEvent := out.Mode == WaitModeEvent || out.Mode == WaitModeAuto
|
||||
if needsEvent {
|
||||
if out.EventKey == "" {
|
||||
return nil, fmt.Errorf("schema tool %s wait mode %s requires event_key", canonical, out.Mode)
|
||||
}
|
||||
if out.MatchField == "" {
|
||||
return nil, fmt.Errorf("schema tool %s wait mode %s requires match_field", canonical, out.Mode)
|
||||
}
|
||||
if out.ResourceQuery == "" {
|
||||
return nil, fmt.Errorf("schema tool %s wait mode %s requires resource_query", canonical, out.Mode)
|
||||
}
|
||||
}
|
||||
if out.StatusQuery == "" {
|
||||
return nil, fmt.Errorf("schema tool %s wait mode %s requires status_query", canonical, out.Mode)
|
||||
}
|
||||
if len(in.Terminal) == 0 {
|
||||
return nil, fmt.Errorf("schema tool %s wait has no terminal states", canonical)
|
||||
}
|
||||
out.Terminal = make(map[string]ResultOutcome, len(in.Terminal))
|
||||
for status, outcome := range in.Terminal {
|
||||
status = strings.TrimSpace(status)
|
||||
if status == "" {
|
||||
return nil, fmt.Errorf("schema tool %s wait has a blank terminal status", canonical)
|
||||
}
|
||||
if _, dup := out.Terminal[status]; dup {
|
||||
return nil, fmt.Errorf("schema tool %s wait has duplicate terminal status %q", canonical, status)
|
||||
}
|
||||
// Terminal states must close into success or failure. Pending and
|
||||
// partial are not wait outcomes: pending is expressed through
|
||||
// timeout, and partial requires the typed multi-status payload only
|
||||
// the leaf can construct.
|
||||
if outcome != ResultOutcomeSuccess && outcome != ResultOutcomeFailure {
|
||||
return nil, fmt.Errorf(
|
||||
"schema tool %s wait terminal status %q must map to success or failure, got %q",
|
||||
canonical, status, outcome)
|
||||
}
|
||||
out.Terminal[status] = outcome
|
||||
}
|
||||
seenPending := make(map[string]bool, len(in.PendingValues))
|
||||
for _, value := range in.PendingValues {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil, fmt.Errorf("schema tool %s wait has a blank pending value", canonical)
|
||||
}
|
||||
if _, conflict := out.Terminal[value]; conflict {
|
||||
return nil, fmt.Errorf("schema tool %s wait status %q is both terminal and pending", canonical, value)
|
||||
}
|
||||
if seenPending[value] {
|
||||
return nil, fmt.Errorf("schema tool %s wait has duplicate pending value %q", canonical, value)
|
||||
}
|
||||
seenPending[value] = true
|
||||
out.PendingValues = append(out.PendingValues, value)
|
||||
}
|
||||
if in.DefaultTimeoutSecs < 0 {
|
||||
return nil, fmt.Errorf("schema tool %s wait default_timeout_secs must be >= 0", canonical)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ResultOutcome is one closed unified-output envelope outcome.
|
||||
type ResultOutcome string
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
// 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 contract
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestWaitSpecValidateAcceptsReviewedShapes(t *testing.T) {
|
||||
cases := []WaitSpec{
|
||||
{
|
||||
Mode: WaitModePoll,
|
||||
PollCommand: "oa approval-instance get",
|
||||
StatusQuery: "result.status",
|
||||
Terminal: map[string]ResultOutcome{
|
||||
"COMPLETED": ResultOutcomeSuccess,
|
||||
"REJECTED": ResultOutcomeFailure,
|
||||
},
|
||||
PendingValues: []string{"NEW", "RUNNING"},
|
||||
DefaultTimeoutSecs: 600,
|
||||
},
|
||||
}
|
||||
for i, spec := range cases {
|
||||
if err := spec.Validate("sample.tool"); err != nil {
|
||||
t.Fatalf("case %d: unexpected error: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitSpecValidateRejectsMalformedShapes(t *testing.T) {
|
||||
terminal := map[string]ResultOutcome{"COMPLETED": ResultOutcomeSuccess}
|
||||
cases := map[string]WaitSpec{
|
||||
"no mode": {
|
||||
Terminal: terminal,
|
||||
},
|
||||
"unknown mode": {
|
||||
Mode: "webhook",
|
||||
Terminal: terminal,
|
||||
},
|
||||
"poll without poll_command": {
|
||||
Mode: WaitModePoll,
|
||||
StatusQuery: "status",
|
||||
Terminal: terminal,
|
||||
},
|
||||
"poll without status_query": {
|
||||
Mode: WaitModePoll,
|
||||
PollCommand: "oa approval-instance get",
|
||||
Terminal: terminal,
|
||||
},
|
||||
"event without event_key": {
|
||||
Mode: WaitModeEvent,
|
||||
MatchField: "process_instance_id",
|
||||
ResourceQuery: "id",
|
||||
StatusQuery: "result.status",
|
||||
Terminal: terminal,
|
||||
},
|
||||
"event without match_field": {
|
||||
Mode: WaitModeEvent,
|
||||
EventKey: "bpms_instance_change",
|
||||
ResourceQuery: "id",
|
||||
StatusQuery: "result.status",
|
||||
Terminal: terminal,
|
||||
},
|
||||
"event without resource_query": {
|
||||
Mode: WaitModeEvent,
|
||||
EventKey: "bpms_instance_change",
|
||||
MatchField: "process_instance_id",
|
||||
StatusQuery: "result.status",
|
||||
Terminal: terminal,
|
||||
},
|
||||
"auto missing poll_command": {
|
||||
Mode: WaitModeAuto,
|
||||
EventKey: "export_finished",
|
||||
MatchField: "job_id",
|
||||
ResourceQuery: "job_id",
|
||||
StatusQuery: "status",
|
||||
Terminal: terminal,
|
||||
},
|
||||
"no terminal states": {
|
||||
Mode: WaitModePoll,
|
||||
PollCommand: "oa approval-instance get",
|
||||
StatusQuery: "status",
|
||||
},
|
||||
"blank terminal status": {
|
||||
Mode: WaitModePoll,
|
||||
PollCommand: "oa approval-instance get",
|
||||
StatusQuery: "status",
|
||||
Terminal: map[string]ResultOutcome{" ": ResultOutcomeSuccess},
|
||||
},
|
||||
"terminal outcome pending": {
|
||||
Mode: WaitModePoll,
|
||||
PollCommand: "oa approval-instance get",
|
||||
StatusQuery: "status",
|
||||
Terminal: map[string]ResultOutcome{"COMPLETED": ResultOutcomePending, "REJECTED": ResultOutcomeFailure},
|
||||
},
|
||||
"terminal outcome partial": {
|
||||
Mode: WaitModePoll,
|
||||
PollCommand: "oa approval-instance get",
|
||||
StatusQuery: "status",
|
||||
Terminal: map[string]ResultOutcome{"COMPLETED": ResultOutcomePartialFailure, "REJECTED": ResultOutcomeFailure},
|
||||
},
|
||||
"terminal outcome outside closed set": {
|
||||
Mode: WaitModePoll,
|
||||
PollCommand: "oa approval-instance get",
|
||||
StatusQuery: "status",
|
||||
Terminal: map[string]ResultOutcome{"COMPLETED": ResultOutcome("explosion")},
|
||||
},
|
||||
"only pending terminal outcome": {
|
||||
Mode: WaitModePoll,
|
||||
PollCommand: "oa approval-instance get",
|
||||
StatusQuery: "status",
|
||||
Terminal: map[string]ResultOutcome{"NEW": ResultOutcomePending},
|
||||
},
|
||||
"status both terminal and pending": {
|
||||
Mode: WaitModePoll,
|
||||
PollCommand: "oa approval-instance get",
|
||||
StatusQuery: "status",
|
||||
Terminal: terminal,
|
||||
PendingValues: []string{"COMPLETED"},
|
||||
},
|
||||
"blank pending value": {
|
||||
Mode: WaitModePoll,
|
||||
PollCommand: "oa approval-instance get",
|
||||
StatusQuery: "status",
|
||||
Terminal: terminal,
|
||||
PendingValues: []string{" "},
|
||||
},
|
||||
"negative timeout default": {
|
||||
Mode: WaitModePoll,
|
||||
PollCommand: "oa approval-instance get",
|
||||
StatusQuery: "status",
|
||||
Terminal: terminal,
|
||||
DefaultTimeoutSecs: -1,
|
||||
},
|
||||
}
|
||||
for name, spec := range cases {
|
||||
if err := spec.Validate("sample.tool"); err == nil {
|
||||
t.Fatalf("%s: expected error, got nil", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWaitSpecTrimsStatusValuesIntoWireForm(t *testing.T) {
|
||||
in := &WaitSpec{
|
||||
Mode: " poll ",
|
||||
PollCommand: " oa approval-instance get ",
|
||||
StatusQuery: " result.status ",
|
||||
Terminal: map[string]ResultOutcome{" COMPLETED ": ResultOutcomeSuccess, "REJECTED": ResultOutcomeFailure},
|
||||
PendingValues: []string{" NEW ", "RUNNING"},
|
||||
DefaultTimeoutSecs: 60,
|
||||
}
|
||||
out, err := NormalizeWaitSpec(in, "sample.tool")
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeWaitSpec() error = %v", err)
|
||||
}
|
||||
if out.Mode != WaitModePoll || out.PollCommand != "oa approval-instance get" || out.StatusQuery != "result.status" {
|
||||
t.Fatalf("normalized scalars: %#v", out)
|
||||
}
|
||||
if len(out.Terminal) != 2 {
|
||||
t.Fatalf("terminal=%#v, want two trimmed keys", out.Terminal)
|
||||
}
|
||||
if got := out.Terminal["COMPLETED"]; got != ResultOutcomeSuccess {
|
||||
t.Fatalf("terminal[COMPLETED]=%q, want success (key must be trimmed)", got)
|
||||
}
|
||||
if _, padded := out.Terminal[" COMPLETED "]; padded {
|
||||
t.Fatal("padded terminal key survived normalization")
|
||||
}
|
||||
for i, want := range []string{"NEW", "RUNNING"} {
|
||||
if out.PendingValues[i] != want {
|
||||
t.Fatalf("pending[%d]=%q, want %q", i, out.PendingValues[i], want)
|
||||
}
|
||||
}
|
||||
// The input declaration must stay untouched (defensive copy).
|
||||
if _, padded := in.Terminal[" COMPLETED "]; !padded {
|
||||
t.Fatal("NormalizeWaitSpec mutated its input terminal map")
|
||||
}
|
||||
if in.PendingValues[0] != " NEW " {
|
||||
t.Fatal("NormalizeWaitSpec mutated its input pending values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWaitSpecRejectsDuplicatesAndConflictsAfterTrim(t *testing.T) {
|
||||
terminal := map[string]ResultOutcome{"COMPLETED": ResultOutcomeSuccess}
|
||||
cases := map[string]*WaitSpec{
|
||||
"terminal keys collapsing after trim": {
|
||||
Mode: WaitModePoll,
|
||||
PollCommand: "oa approval-instance get",
|
||||
StatusQuery: "status",
|
||||
Terminal: map[string]ResultOutcome{"COMPLETED": ResultOutcomeSuccess, " COMPLETED ": ResultOutcomeFailure},
|
||||
},
|
||||
"pending values collapsing after trim": {
|
||||
Mode: WaitModePoll,
|
||||
PollCommand: "oa approval-instance get",
|
||||
StatusQuery: "status",
|
||||
Terminal: terminal,
|
||||
PendingValues: []string{"NEW", " NEW "},
|
||||
},
|
||||
"terminal/pending conflict hidden by padding": {
|
||||
Mode: WaitModePoll,
|
||||
PollCommand: "oa approval-instance get",
|
||||
StatusQuery: "status",
|
||||
Terminal: terminal,
|
||||
PendingValues: []string{" COMPLETED "},
|
||||
},
|
||||
}
|
||||
for name, spec := range cases {
|
||||
if _, err := NormalizeWaitSpec(spec, "sample.tool"); err == nil {
|
||||
t.Fatalf("%s: expected error, got nil", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeWaitSpecNilReturnsNil(t *testing.T) {
|
||||
out, err := NormalizeWaitSpec(nil, "sample.tool")
|
||||
if err != nil || out != nil {
|
||||
t.Fatalf("NormalizeWaitSpec(nil) = %#v, %v", out, err)
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ type ContractDecl struct {
|
||||
Positionals []contract.RuntimeSchemaPositional
|
||||
Parameters []contract.ParamDecl
|
||||
DryRun *contract.DryRunSpec
|
||||
Wait *contract.WaitSpec
|
||||
Result *contract.ResultSpec
|
||||
Pagination *contract.PaginationSpec
|
||||
Interface *contract.InterfaceSpec
|
||||
@@ -146,6 +147,9 @@ func (s ContractDecl) empty() bool {
|
||||
if s.DryRun != nil && strings.TrimSpace(s.DryRun.PreviewKind) != "" {
|
||||
return false
|
||||
}
|
||||
if s.Wait != nil && strings.TrimSpace(s.Wait.Mode) != "" {
|
||||
return false
|
||||
}
|
||||
if s.Result != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -200,6 +200,13 @@ func TestFrameworkContractFinalDeepCopyAndSafetyConflicts(t *testing.T) {
|
||||
Parameters: []contract.ParamDecl{{Name: "mode", Enum: []string{"a"}, Required: boolPointer(true)}},
|
||||
Safety: &contract.SafetySpec{Effect: " read ", EffectSource: " source ", Risk: " low ", Confirmation: " not_required ", Idempotency: " idempotent "},
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "plan"},
|
||||
Wait: &contract.WaitSpec{
|
||||
Mode: contract.WaitModePoll,
|
||||
PollCommand: "oa approval-instance get",
|
||||
StatusQuery: "result.status",
|
||||
Terminal: map[string]contract.ResultOutcome{"COMPLETED": contract.ResultOutcomeSuccess, "REJECTED": contract.ResultOutcomeFailure},
|
||||
PendingValues: []string{"NEW"},
|
||||
},
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess},
|
||||
DataSchema: []byte(`{"type":"object"}`), SensitivePaths: []string{"token"},
|
||||
@@ -218,6 +225,8 @@ func TestFrameworkContractFinalDeepCopyAndSafetyConflicts(t *testing.T) {
|
||||
if !ok || got.Result == payload.Result || got.Pagination == payload.Pagination || got.Interface == payload.Interface || got.Selection == payload.Selection || got.Identity == payload.Identity {
|
||||
t.Fatalf("payload not deeply cloned: %#v", got)
|
||||
}
|
||||
payload.Wait.Terminal["COMPLETED"] = contract.ResultOutcomeFailure
|
||||
payload.Wait.PendingValues[0] = "mutated"
|
||||
payload.Parameters[0].Enum[0] = "changed"
|
||||
*payload.Parameters[0].Required = false
|
||||
*payload.Selection.ExampleDispositions[0].Index = 9
|
||||
@@ -226,6 +235,10 @@ func TestFrameworkContractFinalDeepCopyAndSafetyConflicts(t *testing.T) {
|
||||
if again.Parameters[0].Enum[0] != "a" || !*again.Parameters[0].Required || *again.Selection.ExampleDispositions[0].Index != 1 || !*again.Selection.Reviewed {
|
||||
t.Fatalf("stored payload aliased input: %#v", again)
|
||||
}
|
||||
if again.Wait == payload.Wait || again.Wait.Terminal["COMPLETED"] != contract.ResultOutcomeSuccess || again.Wait.PendingValues[0] != "NEW" {
|
||||
t.Fatalf("wait spec aliased input: %#v", again.Wait)
|
||||
t.Fatalf("stored payload aliased input: %#v", again)
|
||||
}
|
||||
|
||||
matching := &cobra.Command{Use: "matching"}
|
||||
t.Cleanup(func() { ClearRuntimeContractFinalForTest(matching) })
|
||||
|
||||
@@ -74,6 +74,15 @@ func cloneContractFinalPayload(in contract.ContractFinalPayload) contract.Contra
|
||||
value := *in.DryRun
|
||||
out.DryRun = &value
|
||||
}
|
||||
if in.Wait != nil {
|
||||
value := *in.Wait
|
||||
value.Terminal = make(map[string]contract.ResultOutcome, len(in.Wait.Terminal))
|
||||
for status, outcome := range in.Wait.Terminal {
|
||||
value.Terminal[status] = outcome
|
||||
}
|
||||
value.PendingValues = cloneSlice(in.Wait.PendingValues)
|
||||
out.Wait = &value
|
||||
}
|
||||
if in.Result != nil {
|
||||
value := *in.Result
|
||||
value.Outcomes = cloneSlice(in.Result.Outcomes)
|
||||
|
||||
@@ -49,12 +49,16 @@ package corecmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -64,6 +68,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
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/wait"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
)
|
||||
|
||||
@@ -282,6 +287,21 @@ type Spec struct {
|
||||
// Orchestrate executes a multi-step command; it assembles whatever payloads
|
||||
// it needs from the Ctx.
|
||||
Orchestrate func(c *Ctx) error
|
||||
// WaitPoll executes one poll of the declared Contract.Wait capability.
|
||||
// Exactly one poll is one call; cadence, status extraction, and outcome
|
||||
// mapping belong to the framework wait phase. Required for poll/auto
|
||||
// declarations — a declared capability without a runtime implementation
|
||||
// can never honor --wait, so New rejects the pairing at construction.
|
||||
// ctx is the wait-phase deadline (--wait-timeout); leaf I/O must honor
|
||||
// it so a blocked poll cannot outlive the declared timeout.
|
||||
WaitPoll func(ctx context.Context, c *Ctx) (wait.PollDoc, error)
|
||||
// WaitEvents opens the push subscription of the declared Contract.Wait
|
||||
// capability (event/auto modes). The framework owns correlation and
|
||||
// status mapping; the leaf owns the transport. Auto mode falls back to
|
||||
// WaitPoll when the stream ends before a terminal status. ctx is the
|
||||
// same wait-phase deadline as WaitPoll; subscription setup must honor
|
||||
// it so --wait-timeout can cancel a blocked subscribe.
|
||||
WaitEvents func(ctx context.Context, c *Ctx) (wait.EventStream, error)
|
||||
}
|
||||
|
||||
// Ctx is the framework-neutral execution context handed to Invoke/Orchestrate.
|
||||
@@ -355,6 +375,15 @@ func (c *Ctx) Changed(name string) bool { return c.cmd.Flags().Changed(name) }
|
||||
// DryRun reports the effective global --dry-run.
|
||||
func (c *Ctx) DryRun() bool { return BoolFlag(c.cmd, "dry-run") }
|
||||
|
||||
// Wait reports the effective --wait flag. It is false on commands that did
|
||||
// not declare the capability: the flag is not registered there, so passing it
|
||||
// is an unknown-flag error rather than a silently ignored value.
|
||||
func (c *Ctx) Wait() bool { return BoolFlag(c.cmd, waitFlagName) }
|
||||
|
||||
// WaitTimeoutSecs reports the effective --wait-timeout in seconds (flag
|
||||
// value, then the declared default, then the framework default).
|
||||
func (c *Ctx) WaitTimeoutSecs() int { return waitTimeoutSecs(c.cmd) }
|
||||
|
||||
// Yes reports the effective global --yes.
|
||||
func (c *Ctx) Yes() bool { return BoolFlag(c.cmd, "yes") }
|
||||
|
||||
@@ -374,6 +403,8 @@ func New(spec Spec) *cobra.Command {
|
||||
validateDispatchDecl(spec)
|
||||
validateSafetySpec(spec)
|
||||
validateContractDecl(spec)
|
||||
normalizeWaitDecl(&spec)
|
||||
validateWaitDecl(spec)
|
||||
validateInputSpecs(spec.Use, spec.Flags)
|
||||
// Help prose inherits the declaration when not authored separately:
|
||||
// Selection.Examples (already contract-validated against the real flags)
|
||||
@@ -390,6 +421,7 @@ func New(spec Spec) *cobra.Command {
|
||||
Hidden: spec.Hidden,
|
||||
}
|
||||
RegisterFlags(cmd, spec.Flags)
|
||||
registerWaitFlags(cmd, spec)
|
||||
ValidateConstraintDecls(spec.Use, spec.Flags, spec.Constraints)
|
||||
embedContractIntoSchema(cmd, spec)
|
||||
AnnotateConstraints(cmd, spec.Constraints)
|
||||
@@ -455,6 +487,10 @@ func New(spec Spec) *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err = runDeclaredWaitPhase(cmd, args, spec, result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return output.StoreResult(cmd.Context(), result)
|
||||
}
|
||||
return spec.Invoke(ctx, toolArgs)
|
||||
@@ -506,6 +542,293 @@ func runDeclaredPreflight(cmd *cobra.Command, args []string, spec Spec) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait-phase framework flags. They are registered natively on the leaf (never
|
||||
// as FlagSpec) so they cannot leak into toolArgs / MCP payloads: --wait is a
|
||||
// client-side execution modifier, not a backend parameter. On commands that
|
||||
// did not declare Contract.Wait the flags do not exist, so passing --wait
|
||||
// fails as an unknown flag instead of being silently ignored.
|
||||
const (
|
||||
waitFlagName = "wait"
|
||||
waitTimeoutFlagName = "wait-timeout"
|
||||
defaultWaitTimeoutS = 300
|
||||
)
|
||||
|
||||
// DefaultWaitTimeoutSecs is the framework default for --wait-timeout when the
|
||||
// declaration carries no reviewed default.
|
||||
const DefaultWaitTimeoutSecs = defaultWaitTimeoutS
|
||||
|
||||
// normalizeWaitDecl rewrites the spec's declared Wait in place with its
|
||||
// canonical form (contract.NormalizeWaitSpec): trimmed status values,
|
||||
// duplicate/conflict rejection, defensive copy. The runtime wait phase and
|
||||
// AttachContract both read spec.Contract.Wait, so normalizing once at
|
||||
// construction guarantees the wait engine, the Schema wire, and the
|
||||
// registered ContractFinal payload all see identical status tables — a
|
||||
// padded declaration can no longer publish a Schema its own runtime treats
|
||||
// as unknown statuses. An invalid declaration panics here, next to the
|
||||
// authoring mistake, with the same message Validate reports.
|
||||
func normalizeWaitDecl(spec *Spec) {
|
||||
decl := spec.Contract.Wait
|
||||
if decl == nil || strings.TrimSpace(decl.Mode) == "" {
|
||||
return
|
||||
}
|
||||
normalized, err := contract.NormalizeWaitSpec(decl, spec.Contract.Identity.CanonicalPath)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("command %q has invalid Contract.Wait: %v", spec.Use, err))
|
||||
}
|
||||
spec.Contract.Wait = normalized
|
||||
}
|
||||
|
||||
// validateWaitDecl enforces the declaration ⇄ implementation pairing at build
|
||||
// time: a declared Contract.Wait without a WaitPoll hook is a capability the
|
||||
// command can never honor, and a WaitPoll hook without the declaration has no
|
||||
// flags or Schema capability to serve. The declaration also requires the
|
||||
// ResultInvoke dispatcher: only the unified-result envelope can be closed
|
||||
// into the terminal outcome (error.type "wait", exit code 8) and the timed-out
|
||||
// pending form — legacy Invoke/Orchestrate/RunE paths emit their own output
|
||||
// and would observe a failure terminal while still exiting 0. All three
|
||||
// mismatches are programming errors.
|
||||
func validateWaitDecl(spec Spec) {
|
||||
decl := spec.Contract.Wait
|
||||
declared := decl != nil && strings.TrimSpace(decl.Mode) != ""
|
||||
if !declared {
|
||||
if spec.WaitPoll != nil || spec.WaitEvents != nil {
|
||||
panic(fmt.Sprintf(
|
||||
"command %q sets a wait hook without declaring Contract.Wait: the wait flags and Schema capability come from the declaration",
|
||||
spec.Use))
|
||||
}
|
||||
return
|
||||
}
|
||||
if spec.ResultInvoke == nil {
|
||||
panic(fmt.Sprintf(
|
||||
"command %q declares Contract.Wait without ResultInvoke: wait closes the unified-result envelope, which legacy Invoke/Orchestrate/RunE paths cannot rewrite",
|
||||
spec.Use))
|
||||
}
|
||||
mode := strings.TrimSpace(decl.Mode)
|
||||
needsPoll := mode == contract.WaitModePoll || mode == contract.WaitModeAuto
|
||||
needsEvent := mode == contract.WaitModeEvent || mode == contract.WaitModeAuto
|
||||
if needsPoll && spec.WaitPoll == nil {
|
||||
panic(fmt.Sprintf(
|
||||
"command %q declares wait mode %s but sets no WaitPoll: a declared wait capability must carry its runtime poll implementation",
|
||||
spec.Use, mode))
|
||||
}
|
||||
if needsEvent && spec.WaitEvents == nil {
|
||||
panic(fmt.Sprintf(
|
||||
"command %q declares wait mode %s but sets no WaitEvents: a declared event wait must carry its runtime subscription",
|
||||
spec.Use, mode))
|
||||
}
|
||||
if !needsPoll && spec.WaitPoll != nil {
|
||||
panic(fmt.Sprintf(
|
||||
"command %q declares wait mode %s but sets WaitPoll: the declaration decides which hooks run",
|
||||
spec.Use, mode))
|
||||
}
|
||||
if !needsEvent && spec.WaitEvents != nil {
|
||||
panic(fmt.Sprintf(
|
||||
"command %q declares wait mode %s but sets WaitEvents: the declaration decides which hooks run",
|
||||
spec.Use, mode))
|
||||
}
|
||||
}
|
||||
|
||||
// registerWaitFlags adds --wait / --wait-timeout to a leaf that declared
|
||||
// Contract.Wait. The timeout default is the reviewed declaration, falling
|
||||
// back to DefaultWaitTimeoutSecs.
|
||||
func registerWaitFlags(cmd *cobra.Command, spec Spec) {
|
||||
decl := spec.Contract.Wait
|
||||
if decl == nil || strings.TrimSpace(decl.Mode) == "" {
|
||||
return
|
||||
}
|
||||
cmd.Flags().Bool(waitFlagName, false,
|
||||
"等待到达命令声明的终态(如审批完成、导出结束)后再返回;未声明该能力的命令不接受此 flag")
|
||||
timeoutDefault := decl.DefaultTimeoutSecs
|
||||
if timeoutDefault <= 0 {
|
||||
timeoutDefault = DefaultWaitTimeoutSecs
|
||||
}
|
||||
cmd.Flags().Int(waitTimeoutFlagName, timeoutDefault,
|
||||
"等待超时秒数;超时以 pending 结束(异步受理不是失败)")
|
||||
}
|
||||
|
||||
// runDeclaredWaitPhase runs the declared wait loop after a successful
|
||||
// ResultInvoke dispatch and closes the accepted unified envelope into the
|
||||
// wait outcome (validateWaitDecl guarantees the ResultInvoke pairing).
|
||||
// Only a pending accepted result is waitable: success / failure / partial
|
||||
// are already terminal and must be returned unchanged. Waiting on a
|
||||
// business failure would let WithOutcome(..., success) overwrite it into
|
||||
// an illegal success-with-error envelope.
|
||||
func runDeclaredWaitPhase(cmd *cobra.Command, args []string, spec Spec, result output.CommandResult) (output.CommandResult, error) {
|
||||
if !BoolFlag(cmd, waitFlagName) {
|
||||
return result, nil
|
||||
}
|
||||
if result == nil || result.Outcome() != output.OutcomePending {
|
||||
return result, nil
|
||||
}
|
||||
decl := spec.Contract.Wait
|
||||
timeout, err := waitTimeoutDuration(int64(waitTimeoutSecs(cmd)))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
ctx := newCtx(cmd, args, spec.Flags)
|
||||
outcome, err := runWaitLoop(cmd.Context(), decl, timeout, spec, ctx, result)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if outcome.TimedOut {
|
||||
cmd.PrintErrf("等待超时(%s):当前状态 %q,未到达终态,以 pending 结束\n", timeout, outcome.Status)
|
||||
return output.WithOutcome(result, output.OutcomePending,
|
||||
output.WithOperationTimedOut(outcome.Status)), nil
|
||||
}
|
||||
if outcome.Outcome == contract.ResultOutcomeFailure {
|
||||
return output.WithOutcome(result, output.OutcomeFailure,
|
||||
output.WithOperationTerminalState(outcome.Status),
|
||||
output.WithErrorInfo(&output.ErrorInfo{
|
||||
Type: "wait",
|
||||
Subtype: "terminal_failure",
|
||||
Message: fmt.Sprintf("等待到达失败终态:%s", outcome.Status),
|
||||
})), nil
|
||||
}
|
||||
return output.WithOutcome(result, output.OutcomeSuccess,
|
||||
output.WithOperationTerminalState(outcome.Status)), nil
|
||||
}
|
||||
|
||||
// runWaitLoop executes the declared wait mode. One deadline spans the event
|
||||
// phase and an auto-mode poll fallback (the inner loops run without their
|
||||
// own timeouts and inherit this context's deadline). The deadline is
|
||||
// forwarded to WaitPoll / WaitEvents and bound onto the cobra command so
|
||||
// leaf I/O that reads either the hook ctx or Command().Context() is
|
||||
// cancelled when --wait-timeout expires.
|
||||
func runWaitLoop(parent context.Context, decl *contract.WaitSpec, timeout time.Duration, spec Spec, ctx *Ctx, result output.CommandResult) (wait.Outcome, error) {
|
||||
loopCtx := parent
|
||||
if timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
loopCtx, cancel = context.WithTimeout(parent, timeout)
|
||||
defer cancel()
|
||||
}
|
||||
if ctx != nil && ctx.cmd != nil {
|
||||
prev := ctx.cmd.Context()
|
||||
ctx.cmd.SetContext(loopCtx)
|
||||
defer ctx.cmd.SetContext(prev)
|
||||
}
|
||||
mode := strings.TrimSpace(decl.Mode)
|
||||
if mode == contract.WaitModePoll {
|
||||
return wait.Run(loopCtx, wait.LoopSpec{
|
||||
StatusQuery: decl.StatusQuery,
|
||||
Terminal: decl.Terminal,
|
||||
Pending: decl.PendingValues,
|
||||
}, func(pollCtx context.Context) (wait.PollDoc, error) {
|
||||
return spec.WaitPoll(pollCtx, ctx)
|
||||
})
|
||||
}
|
||||
resource, err := waitResource(decl, result)
|
||||
if err != nil {
|
||||
return wait.Outcome{}, err
|
||||
}
|
||||
stream, err := spec.WaitEvents(loopCtx, ctx)
|
||||
if err != nil {
|
||||
if loopCtx.Err() != nil {
|
||||
// Subscribe blocked until the wait deadline: same contract as a
|
||||
// cancelled poll — close as timed-out pending, do not surface
|
||||
// ctx.Err() as a subscription failure (and do not poll-fallback
|
||||
// in auto mode; the shared deadline is already exhausted).
|
||||
return wait.Outcome{Outcome: contract.ResultOutcomePending, TimedOut: true}, nil
|
||||
}
|
||||
if mode == contract.WaitModeAuto {
|
||||
// Subscription failed before any event: fall back to polling.
|
||||
return pollWithSpec(loopCtx, decl, spec, ctx)
|
||||
}
|
||||
return wait.Outcome{}, fmt.Errorf("wait: event subscription failed: %w", err)
|
||||
}
|
||||
eventSpec := wait.EventLoopSpec{
|
||||
StatusQuery: decl.StatusQuery,
|
||||
MatchField: decl.MatchField,
|
||||
Terminal: decl.Terminal,
|
||||
Pending: decl.PendingValues,
|
||||
}
|
||||
outcome, err := wait.RunEvent(loopCtx, eventSpec, resource, stream)
|
||||
if err == nil {
|
||||
return outcome, nil
|
||||
}
|
||||
if mode == contract.WaitModeAuto && errors.Is(err, wait.ErrEventStreamEnded) {
|
||||
// Stream ended before a terminal status: fall back to polling under
|
||||
// the same deadline.
|
||||
return pollWithSpec(loopCtx, decl, spec, ctx)
|
||||
}
|
||||
return outcome, err
|
||||
}
|
||||
|
||||
// pollWithSpec runs the poll loop for an auto-mode fallback.
|
||||
func pollWithSpec(loopCtx context.Context, decl *contract.WaitSpec, spec Spec, ctx *Ctx) (wait.Outcome, error) {
|
||||
return wait.Run(loopCtx, wait.LoopSpec{
|
||||
StatusQuery: decl.StatusQuery,
|
||||
Terminal: decl.Terminal,
|
||||
Pending: decl.PendingValues,
|
||||
}, func(pollCtx context.Context) (wait.PollDoc, error) {
|
||||
return spec.WaitPoll(pollCtx, ctx)
|
||||
})
|
||||
}
|
||||
|
||||
// waitResource resolves the resource identifier an event stream correlates
|
||||
// against, from the accepted result data via the declared ResourceQuery.
|
||||
// result.Data() returns any deep-copied business payload, which may be a
|
||||
// map[string]any, struct, or struct pointer. We normalize via JSON round-trip
|
||||
// to support all valid result types uniformly.
|
||||
func waitResource(decl *contract.WaitSpec, result output.CommandResult) (string, error) {
|
||||
raw := result.Data()
|
||||
if raw == nil {
|
||||
return "", fmt.Errorf("wait: accepted result data is nil; cannot resolve resource %q", decl.ResourceQuery)
|
||||
}
|
||||
// Fast path: already a map.
|
||||
if data, ok := raw.(map[string]any); ok {
|
||||
resource, ok := wait.ExtractStatus(wait.PollDoc(data), decl.ResourceQuery)
|
||||
if !ok || strings.TrimSpace(resource) == "" {
|
||||
return "", fmt.Errorf("wait: resource query %q not found in accepted result data", decl.ResourceQuery)
|
||||
}
|
||||
return resource, nil
|
||||
}
|
||||
// Slow path: struct or struct pointer. Normalize via JSON round-trip.
|
||||
jsonBytes, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("wait: accepted result data cannot be serialized to JSON: %w", err)
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal(jsonBytes, &data); err != nil {
|
||||
return "", fmt.Errorf("wait: accepted result data is not an object; cannot resolve resource %q", decl.ResourceQuery)
|
||||
}
|
||||
resource, ok := wait.ExtractStatus(wait.PollDoc(data), decl.ResourceQuery)
|
||||
if !ok || strings.TrimSpace(resource) == "" {
|
||||
return "", fmt.Errorf("wait: resource query %q not found in accepted result data", decl.ResourceQuery)
|
||||
}
|
||||
return resource, nil
|
||||
}
|
||||
|
||||
// waitTimeoutSecs resolves the effective timeout. The flag is registered
|
||||
// with the reviewed declaration default (or the framework default), so the
|
||||
// flag value is authoritative; a non-positive explicit value falls back to
|
||||
// the framework default.
|
||||
func waitTimeoutSecs(cmd *cobra.Command) int {
|
||||
if value, err := cmd.Flags().GetInt(waitTimeoutFlagName); err == nil && value > 0 {
|
||||
return value
|
||||
}
|
||||
return DefaultWaitTimeoutSecs
|
||||
}
|
||||
|
||||
// maxWaitTimeoutSecs is the largest second count that still fits in a
|
||||
// time.Duration. Multiplying a larger int by time.Second overflows to a
|
||||
// non-positive duration, which would skip the deadline and wait forever.
|
||||
const maxWaitTimeoutSecs = math.MaxInt64 / int64(time.Second)
|
||||
|
||||
// waitTimeoutDuration converts a resolved second count into the wait-phase
|
||||
// deadline. Values that cannot be represented as a positive time.Duration
|
||||
// are rejected as validation errors instead of silently disabling timeout.
|
||||
func waitTimeoutDuration(secs int64) (time.Duration, error) {
|
||||
if secs <= 0 {
|
||||
secs = DefaultWaitTimeoutSecs
|
||||
}
|
||||
if secs > maxWaitTimeoutSecs {
|
||||
return 0, apperrors.NewValidation(fmt.Sprintf(
|
||||
"参数 --%s 取值 %d 超出可表示范围(最大 %d 秒)",
|
||||
waitTimeoutFlagName, secs, maxWaitTimeoutSecs))
|
||||
}
|
||||
return time.Duration(secs) * time.Second, nil
|
||||
}
|
||||
|
||||
// validateDispatchDecl enforces "exactly one dispatcher" at build time. Like
|
||||
// ValidateConstraintDecls this panics: a spec with no runnable body (or with two
|
||||
// competing ones) is a programming error that every test and startup path should
|
||||
@@ -1408,6 +1731,13 @@ func AttachContract(cmd *cobra.Command, safety contract.SafetySpec, decl Contrac
|
||||
d.PreviewKind = strings.TrimSpace(d.PreviewKind)
|
||||
payload.DryRun = &d
|
||||
}
|
||||
if decl.Wait != nil && strings.TrimSpace(decl.Wait.Mode) != "" {
|
||||
waitSpec, err := contract.NormalizeWaitSpec(decl.Wait, decl.Identity.CanonicalPath)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("command %q has invalid Contract.Wait: %v", cmd.Name(), err))
|
||||
}
|
||||
payload.Wait = waitSpec
|
||||
}
|
||||
if decl.Result != nil {
|
||||
result, err := contract.NormalizeResultSpec(decl.Result, decl.Identity.CanonicalPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
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/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -2120,3 +2121,111 @@ func TestCrossPlatformCoverageEmbedContractSkipsBlankAndHiddenFlags(t *testing.T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitResourceStronglyTypedDTOs verifies waitResource handles struct and
|
||||
// struct pointer results via JSON normalization (P1 fix for auto-CR).
|
||||
func TestWaitResourceStronglyTypedDTOs(t *testing.T) {
|
||||
type TaskDTO struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
type NestedDTO struct {
|
||||
Meta struct {
|
||||
ResourceID string `json:"resource_id"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
data any
|
||||
query string
|
||||
wantResource string
|
||||
wantErrSubstr string
|
||||
}{
|
||||
{
|
||||
name: "map[string]any fast path",
|
||||
data: map[string]any{"task_id": "abc123"},
|
||||
query: "task_id",
|
||||
wantResource: "abc123",
|
||||
},
|
||||
{
|
||||
name: "struct value",
|
||||
data: TaskDTO{TaskID: "struct-456", Status: "running"},
|
||||
query: "task_id",
|
||||
wantResource: "struct-456",
|
||||
},
|
||||
{
|
||||
name: "struct pointer",
|
||||
data: &TaskDTO{TaskID: "ptr-789", Status: "pending"},
|
||||
query: "task_id",
|
||||
wantResource: "ptr-789",
|
||||
},
|
||||
{
|
||||
name: "nested struct dotted query",
|
||||
data: NestedDTO{},
|
||||
query: "meta.resource_id",
|
||||
wantResource: "",
|
||||
wantErrSubstr: "not found",
|
||||
},
|
||||
{
|
||||
name: "nested struct with value",
|
||||
data: func() NestedDTO {
|
||||
var d NestedDTO
|
||||
d.Meta.ResourceID = "nested-xyz"
|
||||
return d
|
||||
}(),
|
||||
query: "meta.resource_id",
|
||||
wantResource: "nested-xyz",
|
||||
},
|
||||
{
|
||||
name: "nil data",
|
||||
data: nil,
|
||||
query: "task_id",
|
||||
wantErrSubstr: "nil",
|
||||
},
|
||||
{
|
||||
name: "non-object data (string)",
|
||||
data: "not-an-object",
|
||||
query: "task_id",
|
||||
wantErrSubstr: "not an object",
|
||||
},
|
||||
{
|
||||
name: "non-object data (slice)",
|
||||
data: []string{"a", "b"},
|
||||
query: "task_id",
|
||||
wantErrSubstr: "not an object",
|
||||
},
|
||||
{
|
||||
name: "missing query field in struct",
|
||||
data: TaskDTO{TaskID: "abc"},
|
||||
query: "nonexistent",
|
||||
wantErrSubstr: "not found",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
decl := &contract.WaitSpec{
|
||||
ResourceQuery: tt.query,
|
||||
}
|
||||
result := output.Success(tt.data)
|
||||
resource, err := waitResource(decl, result)
|
||||
|
||||
if tt.wantErrSubstr != "" {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tt.wantErrSubstr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErrSubstr) {
|
||||
t.Errorf("error = %q; want substring %q", err.Error(), tt.wantErrSubstr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resource != tt.wantResource {
|
||||
t.Errorf("resource = %q; want %q", resource, tt.wantResource)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,902 @@
|
||||
// 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 corecmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"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/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/wait"
|
||||
)
|
||||
|
||||
func waitTestDecl() ContractDecl {
|
||||
return ContractDecl{
|
||||
Title: "Wait Title",
|
||||
Description: "Wait Desc",
|
||||
Wait: &contract.WaitSpec{
|
||||
Mode: contract.WaitModePoll,
|
||||
PollCommand: "oa approval-instance get",
|
||||
StatusQuery: "result.status",
|
||||
Terminal: map[string]contract.ResultOutcome{"COMPLETED": contract.ResultOutcomeSuccess, "REJECTED": contract.ResultOutcomeFailure},
|
||||
PendingValues: []string{"NEW", "RUNNING"},
|
||||
DefaultTimeoutSecs: 60,
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{Mode: "local", Availability: "available"},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "summary",
|
||||
UseWhen: []string{"when wait"},
|
||||
AvoidWhen: []string{"when nowait"},
|
||||
Examples: []string{"dws wait-sample --wait"},
|
||||
},
|
||||
Identity: contract.ToolIdentitySpec{ProductID: "sample", Name: "waitsample", CanonicalPath: "sample.waitsample", CLIPath: "wait-sample", PrimaryCLIPath: "wait-sample"},
|
||||
}
|
||||
}
|
||||
|
||||
func baseWaitSpec(decl ContractDecl, poll func(context.Context, *Ctx) (wait.PollDoc, error)) Spec {
|
||||
return Spec{
|
||||
Use: "wait-sample",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: decl,
|
||||
WaitPoll: poll,
|
||||
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
|
||||
return output.Pending(map[string]any{"id": "job-1"}, &output.OperationInfo{
|
||||
ID: "job-1",
|
||||
State: "NEW",
|
||||
NextCommand: "dws wait-sample --id job-1",
|
||||
}), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitFlagsOnlyRegisteredWhenDeclared(t *testing.T) {
|
||||
declared := New(baseWaitSpec(waitTestDecl(), func(context.Context, *Ctx) (wait.PollDoc, error) {
|
||||
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
|
||||
}))
|
||||
if flag := declared.Flags().Lookup(waitFlagName); flag == nil {
|
||||
t.Fatal("declared command missing --wait flag")
|
||||
}
|
||||
if flag := declared.Flags().Lookup(waitTimeoutFlagName); flag == nil {
|
||||
t.Fatal("declared command missing --wait-timeout flag")
|
||||
}
|
||||
|
||||
undeclared := New(Spec{
|
||||
Use: "nowait",
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Invoke: func(*Ctx, map[string]any) error { return nil },
|
||||
})
|
||||
if flag := undeclared.Flags().Lookup(waitFlagName); flag != nil {
|
||||
t.Fatal("undeclared command registered --wait")
|
||||
}
|
||||
undeclared.SetArgs([]string{"--wait"})
|
||||
if err := undeclared.Execute(); err == nil || !strings.Contains(err.Error(), "unknown flag") {
|
||||
t.Fatalf("err=%v want unknown-flag", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWaitDeclPairsDeclarationWithImplementation(t *testing.T) {
|
||||
decl := waitTestDecl()
|
||||
spec := baseWaitSpec(decl, nil)
|
||||
expectPanic(t, func() { New(spec) }, "WaitPoll")
|
||||
|
||||
spec.WaitPoll = func(context.Context, *Ctx) (wait.PollDoc, error) { return nil, nil }
|
||||
expectPanic(t, func() {
|
||||
New(Spec{
|
||||
Use: "hook-only",
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Invoke: func(*Ctx, map[string]any) error { return nil },
|
||||
WaitPoll: func(context.Context, *Ctx) (wait.PollDoc, error) { return nil, nil },
|
||||
})
|
||||
}, "Contract.Wait")
|
||||
}
|
||||
|
||||
func expectPanic(t *testing.T, fn func(), want string) {
|
||||
t.Helper()
|
||||
defer func() {
|
||||
recovered := recover()
|
||||
if recovered == nil {
|
||||
t.Fatalf("expected panic containing %q", want)
|
||||
}
|
||||
if message, ok := recovered.(string); !ok || !strings.Contains(message, want) {
|
||||
t.Fatalf("panic=%v want containing %q", recovered, want)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}
|
||||
|
||||
func TestWaitTimeoutFlagDefaultsComeFromDeclaration(t *testing.T) {
|
||||
stub := func(context.Context, *Ctx) (wait.PollDoc, error) {
|
||||
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
|
||||
}
|
||||
declared := New(baseWaitSpec(waitTestDecl(), stub))
|
||||
if value, err := declared.Flags().GetInt(waitTimeoutFlagName); err != nil || value != 60 {
|
||||
t.Fatalf("declared default=%d/%v, want reviewed 60", value, err)
|
||||
}
|
||||
decl := waitTestDecl()
|
||||
decl.Wait.DefaultTimeoutSecs = 0
|
||||
fallback := New(baseWaitSpec(decl, stub))
|
||||
if value, err := fallback.Flags().GetInt(waitTimeoutFlagName); err != nil || value != DefaultWaitTimeoutSecs {
|
||||
t.Fatalf("fallback default=%d/%v, want framework %d", value, err, DefaultWaitTimeoutSecs)
|
||||
}
|
||||
// A non-positive explicit value falls back to the framework default.
|
||||
fallback.SetArgs([]string{"--wait-timeout", "0", "--wait"})
|
||||
if err := fallback.Flags().Set(waitTimeoutFlagName, "0"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := waitTimeoutSecs(fallback); got != DefaultWaitTimeoutSecs {
|
||||
t.Fatalf("waitTimeoutSecs=%d, want %d", got, DefaultWaitTimeoutSecs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitTimeoutDurationRejectsOverflowingSeconds(t *testing.T) {
|
||||
// math.MaxInt64 (9223372036854775807) is a legal pflag int on 64-bit
|
||||
// platforms and overflows time.Duration(secs)*time.Second to a negative
|
||||
// value, which would disable the wait deadline.
|
||||
if _, err := waitTimeoutDuration(math.MaxInt64); err == nil || !strings.Contains(err.Error(), "超出可表示范围") {
|
||||
t.Fatalf("err=%v, want overflow validation", err)
|
||||
}
|
||||
d, err := waitTimeoutDuration(maxWaitTimeoutSecs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d <= 0 || d != time.Duration(maxWaitTimeoutSecs)*time.Second {
|
||||
t.Fatalf("duration=%d, want the largest representable timeout", d)
|
||||
}
|
||||
// Non-positive second counts fall back to the framework default instead
|
||||
// of disabling the deadline (waitTimeoutSecs already maps a zero/negative
|
||||
// flag to the default; this keeps the conversion itself fail-safe).
|
||||
if got, err := waitTimeoutDuration(0); err != nil || got != time.Duration(DefaultWaitTimeoutSecs)*time.Second {
|
||||
t.Fatalf("duration/err=%d/%v, want framework default", got, err)
|
||||
}
|
||||
if got, err := waitTimeoutDuration(-5); err != nil || got != time.Duration(DefaultWaitTimeoutSecs)*time.Second {
|
||||
t.Fatalf("duration/err=%d/%v, want framework default", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResultInvokeWaitTimeoutOverflowIsValidationError(t *testing.T) {
|
||||
if int64(math.MaxInt) <= maxWaitTimeoutSecs {
|
||||
t.Skip("platform int cannot overflow time.Duration")
|
||||
}
|
||||
polled := false
|
||||
cmd := New(baseWaitSpec(waitTestDecl(), func(context.Context, *Ctx) (wait.PollDoc, error) {
|
||||
polled = true
|
||||
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
|
||||
}))
|
||||
cmd.SetArgs([]string{"--wait", "--wait-timeout", strconv.Itoa(math.MaxInt)})
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "超出可表示范围") {
|
||||
t.Fatalf("err=%v, want overflow validation", err)
|
||||
}
|
||||
if polled {
|
||||
t.Fatal("overflowing --wait-timeout must not start the wait loop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResultInvokeWaitPollErrorFailsTheCommand(t *testing.T) {
|
||||
boom := errors.New("rpc down")
|
||||
cmd := New(baseWaitSpec(waitTestDecl(), func(context.Context, *Ctx) (wait.PollDoc, error) {
|
||||
return nil, boom
|
||||
}))
|
||||
cmd.SetArgs([]string{"--wait"})
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "rpc down") {
|
||||
t.Fatalf("err=%v, want poll error surfaced", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResultInvokeWaitUnknownStatusFailsClosed(t *testing.T) {
|
||||
cmd := New(baseWaitSpec(waitTestDecl(), func(context.Context, *Ctx) (wait.PollDoc, error) {
|
||||
return wait.PollDoc{"result": map[string]any{"status": "Mystery"}}, nil
|
||||
}))
|
||||
cmd.SetArgs([]string{"--wait"})
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
err := cmd.Execute()
|
||||
if err == nil || !wait.IsUnknownStatus(err) {
|
||||
t.Fatalf("err=%v, want unknown-status", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitCtxAccessorsExposeDeclaredCapability(t *testing.T) {
|
||||
var gotWait bool
|
||||
var gotTimeout int
|
||||
cmd := New(baseWaitSpec(waitTestDecl(), func(_ context.Context, c *Ctx) (wait.PollDoc, error) {
|
||||
gotWait = c.Wait()
|
||||
gotTimeout = c.WaitTimeoutSecs()
|
||||
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
|
||||
}))
|
||||
cmd.SetArgs([]string{"--wait", "--wait-timeout", "90"})
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !gotWait || gotTimeout != 90 {
|
||||
t.Fatalf("ctx accessors=%v/%d", gotWait, gotTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func eventTestDecl(mode string) ContractDecl {
|
||||
decl := waitTestDecl()
|
||||
decl.Wait.Mode = mode
|
||||
decl.Wait.EventKey = "bpms_instance_change"
|
||||
decl.Wait.MatchField = "process_instance_id"
|
||||
decl.Wait.ResourceQuery = "id"
|
||||
return decl
|
||||
}
|
||||
|
||||
type scriptedStream struct {
|
||||
events []wait.PollDoc
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *scriptedStream) Recv(context.Context) (wait.PollDoc, error) {
|
||||
if len(s.events) > 0 {
|
||||
doc := s.events[0]
|
||||
s.events = s.events[1:]
|
||||
return doc, nil
|
||||
}
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
func TestValidateWaitDeclPairsModeWithHooks(t *testing.T) {
|
||||
poll := func(context.Context, *Ctx) (wait.PollDoc, error) { return nil, nil }
|
||||
events := func(context.Context, *Ctx) (wait.EventStream, error) { return nil, nil }
|
||||
cases := []struct {
|
||||
name string
|
||||
mode string
|
||||
waitPoll bool
|
||||
waitEvents bool
|
||||
want string
|
||||
}{
|
||||
{"event without WaitEvents", contract.WaitModeEvent, false, false, "WaitEvents"},
|
||||
{"auto without WaitPoll", contract.WaitModeAuto, false, true, "WaitPoll"},
|
||||
{"poll with WaitEvents", contract.WaitModePoll, true, true, "WaitEvents"},
|
||||
{"event with WaitPoll", contract.WaitModeEvent, true, true, "WaitPoll"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
expectPanic(t, func() {
|
||||
New(Spec{
|
||||
Use: "wait-sample",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: eventTestDecl(tc.mode),
|
||||
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
|
||||
return output.Pending(map[string]any{}, nil), nil
|
||||
},
|
||||
WaitPoll: hookOrNil(tc.waitPoll, poll),
|
||||
WaitEvents: eventHookOrNil(tc.waitEvents, events),
|
||||
})
|
||||
}, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func hookOrNil(set bool, hook func(context.Context, *Ctx) (wait.PollDoc, error)) func(context.Context, *Ctx) (wait.PollDoc, error) {
|
||||
if !set {
|
||||
return nil
|
||||
}
|
||||
return hook
|
||||
}
|
||||
|
||||
func eventHookOrNil(set bool, hook func(context.Context, *Ctx) (wait.EventStream, error)) func(context.Context, *Ctx) (wait.EventStream, error) {
|
||||
if !set {
|
||||
return nil
|
||||
}
|
||||
return hook
|
||||
}
|
||||
|
||||
func runWaitModeCommand(t *testing.T, decl ContractDecl, poll func(context.Context, *Ctx) (wait.PollDoc, error), events func(context.Context, *Ctx) (wait.EventStream, error), args ...string) (string, error) {
|
||||
t.Helper()
|
||||
cmd := New(Spec{
|
||||
Use: "wait-sample",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: decl,
|
||||
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
|
||||
return output.Pending(map[string]any{"id": "job-1"}, &output.OperationInfo{
|
||||
ID: "job-1", State: "NEW", NextCommand: "dws wait-sample --id job-1",
|
||||
}), nil
|
||||
},
|
||||
WaitPoll: poll,
|
||||
WaitEvents: events,
|
||||
})
|
||||
cmd.SetArgs(append([]string{"--wait"}, args...))
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
|
||||
_, _, err := output.EmitStoredResult(executed)
|
||||
return err
|
||||
}
|
||||
err := cmd.Execute()
|
||||
return stdout.String(), err
|
||||
}
|
||||
|
||||
func TestEventModeClosesEnvelopeFromCorrelatedEvent(t *testing.T) {
|
||||
stream := &scriptedStream{events: []wait.PollDoc{
|
||||
{"process_instance_id": "other", "result": map[string]any{"status": "COMPLETED"}},
|
||||
{"process_instance_id": "job-1", "result": map[string]any{"status": "REJECTED"}},
|
||||
}}
|
||||
stdout, err := runWaitModeCommand(t, eventTestDecl(contract.WaitModeEvent), nil, func(context.Context, *Ctx) (wait.EventStream, error) {
|
||||
return stream, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(stdout, `"outcome": "failure"`) || !strings.Contains(stdout, `"type": "wait"`) {
|
||||
t.Fatalf("stdout=%s", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventModeSurfacesStreamEndAsError(t *testing.T) {
|
||||
_, err := runWaitModeCommand(t, eventTestDecl(contract.WaitModeEvent), nil, func(context.Context, *Ctx) (wait.EventStream, error) {
|
||||
return &scriptedStream{}, nil
|
||||
})
|
||||
if err == nil || !errors.Is(err, wait.ErrEventStreamEnded) {
|
||||
t.Fatalf("err=%v, want stream-ended", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventModeRejectsUnresolvableResource(t *testing.T) {
|
||||
decl := eventTestDecl(contract.WaitModeEvent)
|
||||
decl.Wait.ResourceQuery = "missing"
|
||||
_, err := runWaitModeCommand(t, decl, nil, func(context.Context, *Ctx) (wait.EventStream, error) {
|
||||
return &scriptedStream{}, nil
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "resource query") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoModeFallsBackToPollOnStreamEnd(t *testing.T) {
|
||||
polled := false
|
||||
stdout, err := runWaitModeCommand(t, eventTestDecl(contract.WaitModeAuto),
|
||||
func(context.Context, *Ctx) (wait.PollDoc, error) {
|
||||
polled = true
|
||||
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
|
||||
},
|
||||
func(context.Context, *Ctx) (wait.EventStream, error) {
|
||||
return &scriptedStream{}, nil // ends immediately
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !polled {
|
||||
t.Fatal("auto mode did not fall back to polling")
|
||||
}
|
||||
if !strings.Contains(stdout, `"outcome": "success"`) {
|
||||
t.Fatalf("stdout=%s", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoModeFallsBackToPollOnSubscriptionFailure(t *testing.T) {
|
||||
polled := false
|
||||
_, err := runWaitModeCommand(t, eventTestDecl(contract.WaitModeAuto),
|
||||
func(context.Context, *Ctx) (wait.PollDoc, error) {
|
||||
polled = true
|
||||
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
|
||||
},
|
||||
func(context.Context, *Ctx) (wait.EventStream, error) {
|
||||
return nil, errors.New("no subscriber credential")
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !polled {
|
||||
t.Fatal("auto mode did not fall back to polling on subscription failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResultInvokeWaitClosesEnvelopeOutcome(t *testing.T) {
|
||||
cmd := New(baseWaitSpec(waitTestDecl(), func(context.Context, *Ctx) (wait.PollDoc, error) {
|
||||
return wait.PollDoc{"result": map[string]any{"status": "REJECTED"}}, nil
|
||||
}))
|
||||
cmd.SetArgs([]string{"--wait"})
|
||||
ctx, store := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
|
||||
_, _, err := output.EmitStoredResult(executed)
|
||||
return err
|
||||
}
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code, emitted := output.StoredExitCode(store); !emitted || code != 8 {
|
||||
t.Fatalf("stored code/emitted=%d/%v, want dedicated wait-terminal-failure code 8", code, emitted)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"type": "wait"`) {
|
||||
t.Fatalf("stdout=%s, want error.type wait", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"outcome": "failure"`) {
|
||||
t.Fatalf("stdout=%s", stdout.String())
|
||||
}
|
||||
// The final emitted envelope must carry the observed terminal status in
|
||||
// meta.operation.state — the acceptance-phase state ("NEW") must not
|
||||
// survive the close (P1 regression guard).
|
||||
if !strings.Contains(stdout.String(), `"state": "REJECTED"`) {
|
||||
t.Fatalf("stdout=%s, want operation.state synced to the terminal status", stdout.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"state": "NEW"`) {
|
||||
t.Fatalf("stdout=%s, acceptance-phase operation.state leaked into the terminal envelope", stdout.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"timed_out": true`) {
|
||||
t.Fatalf("stdout=%s, terminal close must not claim timed_out", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResultInvokeWaitSuccessCloseSyncsOperationState(t *testing.T) {
|
||||
cmd := New(baseWaitSpec(waitTestDecl(), func(context.Context, *Ctx) (wait.PollDoc, error) {
|
||||
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
|
||||
}))
|
||||
cmd.SetArgs([]string{"--wait"})
|
||||
ctx, store := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
|
||||
_, _, err := output.EmitStoredResult(executed)
|
||||
return err
|
||||
}
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code, emitted := output.StoredExitCode(store); !emitted || code != 0 {
|
||||
t.Fatalf("stored code/emitted=%d/%v, want success exit 0", code, emitted)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"outcome": "success"`) {
|
||||
t.Fatalf("stdout=%s", stdout.String())
|
||||
}
|
||||
// Success close must publish the terminal status, never the stale
|
||||
// acceptance-phase state (no outcome=success with state=processing/NEW).
|
||||
if !strings.Contains(stdout.String(), `"state": "COMPLETED"`) {
|
||||
t.Fatalf("stdout=%s, want operation.state synced to the terminal status", stdout.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"state": "NEW"`) {
|
||||
t.Fatalf("stdout=%s, acceptance-phase operation.state leaked into the success envelope", stdout.String())
|
||||
}
|
||||
// Operation identity (id / next_command) survives the terminal close.
|
||||
if !strings.Contains(stdout.String(), `"id": "job-1"`) || !strings.Contains(stdout.String(), `"next_command"`) {
|
||||
t.Fatalf("stdout=%s, want operation id/next_command preserved", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResultInvokeWaitTimeoutKeepsPending(t *testing.T) {
|
||||
polls := 0
|
||||
cmd := New(baseWaitSpec(waitTestDecl(), func(context.Context, *Ctx) (wait.PollDoc, error) {
|
||||
polls++
|
||||
return wait.PollDoc{"result": map[string]any{"status": "RUNNING"}}, nil
|
||||
}))
|
||||
cmd.SetArgs([]string{"--wait", "--wait-timeout", "1"})
|
||||
ctx, store := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
|
||||
_, _, err := output.EmitStoredResult(executed)
|
||||
return err
|
||||
}
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("timeout wait must exit 0 (pending is not failure): %v", err)
|
||||
}
|
||||
if code, emitted := output.StoredExitCode(store); !emitted || code != 0 {
|
||||
t.Fatalf("stored code/emitted=%d/%v", code, emitted)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"outcome": "pending"`) {
|
||||
t.Fatalf("stdout=%s", stdout.String())
|
||||
}
|
||||
if polls == 0 {
|
||||
t.Fatal("wait phase never polled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitDeclRequiresResultInvokeDispatcher(t *testing.T) {
|
||||
// A declared wait on the legacy Invoke path would observe a failure
|
||||
// terminal while still exiting 0 — construction must reject it.
|
||||
expectPanic(t, func() {
|
||||
New(Spec{
|
||||
Use: "wait-sample",
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: waitTestDecl(),
|
||||
WaitPoll: func(context.Context, *Ctx) (wait.PollDoc, error) { return nil, nil },
|
||||
Invoke: func(*Ctx, map[string]any) error { return nil },
|
||||
})
|
||||
}, "ResultInvoke")
|
||||
}
|
||||
|
||||
func TestResultInvokeWithoutWaitFlagSkipsPhase(t *testing.T) {
|
||||
polled := false
|
||||
cmd := New(baseWaitSpec(waitTestDecl(), func(context.Context, *Ctx) (wait.PollDoc, error) {
|
||||
polled = true
|
||||
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
|
||||
}))
|
||||
cmd.SetArgs(nil)
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if polled {
|
||||
t.Fatal("wait phase ran without --wait")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachContractPanicsOnInvalidWaitDeclaration(t *testing.T) {
|
||||
decl := waitTestDecl()
|
||||
decl.Wait.Mode = "event" // not implemented
|
||||
defer func() {
|
||||
recovered := recover()
|
||||
if recovered == nil {
|
||||
t.Fatal("expected panic on invalid Contract.Wait")
|
||||
}
|
||||
if message, ok := recovered.(string); !ok || !strings.Contains(message, "Contract.Wait") {
|
||||
t.Fatalf("panic=%v", recovered)
|
||||
}
|
||||
}()
|
||||
AttachContract(&cobra.Command{Use: "wait-sample"}, contract.SafetySpec{
|
||||
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
}, decl, "", "")
|
||||
}
|
||||
|
||||
func TestWaitDeclPaddedStatusValuesAreNormalized(t *testing.T) {
|
||||
// A declaration whose status values carry surrounding whitespace must be
|
||||
// canonicalized at construction so the runtime wait engine and the
|
||||
// published Schema agree: the backend returns "COMPLETED" verbatim, and
|
||||
// a padded terminal key would fail closed as an unknown status.
|
||||
decl := waitTestDecl()
|
||||
decl.Wait.Terminal = map[string]contract.ResultOutcome{
|
||||
" COMPLETED ": contract.ResultOutcomeSuccess,
|
||||
"\tREJECTED": contract.ResultOutcomeFailure,
|
||||
}
|
||||
decl.Wait.PendingValues = []string{" NEW ", "RUNNING "}
|
||||
cmd := New(baseWaitSpec(decl, func(context.Context, *Ctx) (wait.PollDoc, error) {
|
||||
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
|
||||
}))
|
||||
cmd.SetArgs([]string{"--wait"})
|
||||
ctx, store := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
|
||||
_, _, err := output.EmitStoredResult(executed)
|
||||
return err
|
||||
}
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("padded declaration must still reach the terminal status: %v", err)
|
||||
}
|
||||
if code, emitted := output.StoredExitCode(store); !emitted || code != 0 {
|
||||
t.Fatalf("stored code/emitted=%d/%v, want success", code, emitted)
|
||||
}
|
||||
final, ok := contractfinal.RuntimeContractFinal(cmd)
|
||||
if !ok || final.Wait == nil {
|
||||
t.Fatal("registered ContractFinal lost the wait capability")
|
||||
}
|
||||
if _, ok := final.Wait.Terminal["COMPLETED"]; !ok {
|
||||
t.Fatalf("registered terminal table not trimmed: %#v", final.Wait.Terminal)
|
||||
}
|
||||
for _, value := range final.Wait.PendingValues {
|
||||
if strings.TrimSpace(value) != value {
|
||||
t.Fatalf("registered pending value %q not trimmed", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPanicsOnDuplicateOrConflictingWaitStatusesAfterTrim(t *testing.T) {
|
||||
// Values that collapse onto one status after trimming are programming
|
||||
// errors: silently merging them would pick one outcome for two authored
|
||||
// declarations.
|
||||
dupTerminal := waitTestDecl()
|
||||
dupTerminal.Wait.Terminal = map[string]contract.ResultOutcome{
|
||||
"COMPLETED": contract.ResultOutcomeSuccess,
|
||||
" COMPLETED": contract.ResultOutcomeFailure,
|
||||
}
|
||||
expectPanic(t, func() { New(baseWaitSpec(dupTerminal, nil)) }, "Contract.Wait")
|
||||
|
||||
conflict := waitTestDecl()
|
||||
conflict.Wait.PendingValues = []string{" COMPLETED "}
|
||||
expectPanic(t, func() { New(baseWaitSpec(conflict, nil)) }, "Contract.Wait")
|
||||
}
|
||||
|
||||
func TestContractDeclEmptyTreatsWaitAsAuthored(t *testing.T) {
|
||||
// Only Wait is authored: empty() must report non-empty through the Wait
|
||||
// branch (before validateContractDecl then fails on the missing prose).
|
||||
decl := ContractDecl{Wait: &contract.WaitSpec{
|
||||
Mode: contract.WaitModePoll,
|
||||
PollCommand: "oa approval-instance get",
|
||||
StatusQuery: "result.status",
|
||||
Terminal: map[string]contract.ResultOutcome{"COMPLETED": contract.ResultOutcomeSuccess},
|
||||
}}
|
||||
if decl.Empty() {
|
||||
t.Fatal("Wait-only declaration must count as authored")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected validateContractDecl to reject the missing prose")
|
||||
}
|
||||
}()
|
||||
validateContractDecl(Spec{Use: "wait-only", Contract: decl})
|
||||
}
|
||||
|
||||
func TestEventModeRejectsNonObjectResultData(t *testing.T) {
|
||||
cmd := New(Spec{
|
||||
Use: "wait-sample",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: eventTestDecl(contract.WaitModeEvent),
|
||||
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
|
||||
return output.Pending([]any{"not", "an", "object"}, &output.OperationInfo{
|
||||
ID: "job-1", State: "NEW", NextCommand: "dws wait-sample",
|
||||
}), nil
|
||||
},
|
||||
WaitEvents: func(context.Context, *Ctx) (wait.EventStream, error) {
|
||||
return &scriptedStream{}, nil
|
||||
},
|
||||
})
|
||||
cmd.SetArgs([]string{"--wait"})
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "not an object") {
|
||||
t.Fatalf("err=%v, want non-object data rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventModeSubscriptionFailureSurfacesInStrictMode(t *testing.T) {
|
||||
decl := eventTestDecl(contract.WaitModeEvent)
|
||||
_, err := runWaitModeCommand(t, decl, nil, func(context.Context, *Ctx) (wait.EventStream, error) {
|
||||
return nil, errors.New("no subscriber credential")
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "subscription failed") {
|
||||
t.Fatalf("err=%v, want subscription failure surfaced", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResultInvokeNonPendingSkipsWaitPhase(t *testing.T) {
|
||||
partial, err := output.NewPartialData(2,
|
||||
[]any{map[string]any{"id": "ok"}},
|
||||
[]output.PartialFailedEntry{{ID: "bad", Error: &output.ErrorInfo{Type: "api", Message: "item failed"}}},
|
||||
nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
result output.CommandResult
|
||||
want string
|
||||
}{
|
||||
{"failure", output.Failure(&output.ErrorInfo{Type: "api", Message: "business failed"}), "failure"},
|
||||
{"success", output.Success(map[string]any{"id": "job-1"}), "success"},
|
||||
{"partial", output.Partial(partial), "partial_failure"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
polled := false
|
||||
subscribed := false
|
||||
cmd := New(Spec{
|
||||
Use: "wait-sample",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: eventTestDecl(contract.WaitModeAuto),
|
||||
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
|
||||
return tc.result, nil
|
||||
},
|
||||
WaitPoll: func(context.Context, *Ctx) (wait.PollDoc, error) {
|
||||
polled = true
|
||||
return wait.PollDoc{"result": map[string]any{"status": "COMPLETED"}}, nil
|
||||
},
|
||||
WaitEvents: func(context.Context, *Ctx) (wait.EventStream, error) {
|
||||
subscribed = true
|
||||
return &scriptedStream{}, nil
|
||||
},
|
||||
})
|
||||
cmd.SetArgs([]string{"--wait"})
|
||||
ctx, store := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
|
||||
_, _, err := output.EmitStoredResult(executed)
|
||||
return err
|
||||
}
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if polled || subscribed {
|
||||
t.Fatal("wait phase must not call WaitPoll/WaitEvents for a non-pending initial result")
|
||||
}
|
||||
if _, emitted := output.StoredExitCode(store); !emitted {
|
||||
t.Fatal("initial result was not stored")
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"outcome": "`+tc.want+`"`) {
|
||||
t.Fatalf("stdout=%s, want outcome %s preserved", stdout.String(), tc.want)
|
||||
}
|
||||
if strings.Contains(stdout.String(), `"type": "wait"`) {
|
||||
t.Fatalf("stdout=%s, wait phase overwrote the original envelope", stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitTimeoutCancelsBlockingPoll(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
cmd := New(baseWaitSpec(waitTestDecl(), func(ctx context.Context, c *Ctx) (wait.PollDoc, error) {
|
||||
close(started)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-c.Command().Context().Done():
|
||||
return nil, c.Command().Context().Err()
|
||||
}
|
||||
}))
|
||||
cmd.SetArgs([]string{"--wait", "--wait-timeout", "1"})
|
||||
ctx, store := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
|
||||
_, _, err := output.EmitStoredResult(executed)
|
||||
return err
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Execute() }()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("blocking poll never started")
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("timeout wait must exit 0 (pending is not failure): %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("blocked poll was not cancelled by --wait-timeout")
|
||||
}
|
||||
if code, emitted := output.StoredExitCode(store); !emitted || code != 0 {
|
||||
t.Fatalf("stored code/emitted=%d/%v", code, emitted)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"outcome": "pending"`) {
|
||||
t.Fatalf("stdout=%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitTimeoutCancelsBlockingSubscribe(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
cmd := New(Spec{
|
||||
Use: "wait-sample",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: eventTestDecl(contract.WaitModeEvent),
|
||||
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
|
||||
return output.Pending(map[string]any{"id": "job-1"}, &output.OperationInfo{
|
||||
ID: "job-1", State: "NEW", NextCommand: "dws wait-sample --id job-1",
|
||||
}), nil
|
||||
},
|
||||
WaitEvents: func(ctx context.Context, c *Ctx) (wait.EventStream, error) {
|
||||
close(started)
|
||||
// Leaf subscribe may wait on either the hook ctx or the cobra
|
||||
// command context; both must carry the wait-timeout deadline.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-c.Command().Context().Done():
|
||||
return nil, c.Command().Context().Err()
|
||||
}
|
||||
},
|
||||
})
|
||||
cmd.SetArgs([]string{"--wait", "--wait-timeout", "1"})
|
||||
ctx, store := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
|
||||
_, _, err := output.EmitStoredResult(executed)
|
||||
return err
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Execute() }()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("blocking subscribe never started")
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("timeout wait must exit 0 (pending is not failure): %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("blocked subscribe was not cancelled by --wait-timeout")
|
||||
}
|
||||
if code, emitted := output.StoredExitCode(store); !emitted || code != 0 {
|
||||
t.Fatalf("stored code/emitted=%d/%v", code, emitted)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"outcome": "pending"`) {
|
||||
t.Fatalf("stdout=%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitTimeoutCancelsBlockingPollAfterAutoFallback(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
cmd := New(Spec{
|
||||
Use: "wait-sample",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: eventTestDecl(contract.WaitModeAuto),
|
||||
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
|
||||
return output.Pending(map[string]any{"id": "job-1"}, &output.OperationInfo{
|
||||
ID: "job-1", State: "NEW", NextCommand: "dws wait-sample --id job-1",
|
||||
}), nil
|
||||
},
|
||||
WaitEvents: func(context.Context, *Ctx) (wait.EventStream, error) {
|
||||
return &scriptedStream{}, nil // ends immediately → poll fallback
|
||||
},
|
||||
WaitPoll: func(ctx context.Context, _ *Ctx) (wait.PollDoc, error) {
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
},
|
||||
})
|
||||
cmd.SetArgs([]string{"--wait", "--wait-timeout", "1"})
|
||||
ctx, store := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
var stdout bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
|
||||
_, _, err := output.EmitStoredResult(executed)
|
||||
return err
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Execute() }()
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("auto-fallback blocking poll never started")
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("timeout wait must exit 0 (pending is not failure): %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("blocked auto-fallback poll was not cancelled by --wait-timeout")
|
||||
}
|
||||
if code, emitted := output.StoredExitCode(store); !emitted || code != 0 {
|
||||
t.Fatalf("stored code/emitted=%d/%v", code, emitted)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `"outcome": "pending"`) {
|
||||
t.Fatalf("stdout=%s", stdout.String())
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,8 @@ const (
|
||||
// 5 internal (CategoryInternal 与兜底:非结构化错误、panic 收敛均归 5)
|
||||
// 6 discovery (CategoryDiscovery)
|
||||
// 7 partial_failure(部分成功专用码,见 ExitCodePartial)
|
||||
// 8 wait (--wait 观察到失败终态的专用码,见 internal/output
|
||||
// 的 exitCodeWait;不设 Category,仅经统一信封产出)
|
||||
//
|
||||
// ExitCodePartial is the partial-result exit code shared with internal/output.
|
||||
// It is not returned for CategoryPartial errors because they lack the typed
|
||||
|
||||
@@ -132,6 +132,7 @@ func TestClientCreateRuleBasedSubscriptionsUsesDocumentedRuleParam(t *testing.T)
|
||||
{"oa_approval_task_finished", EventOAApprovalTaskFinished, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_task_redirected", EventOAApprovalTaskRedirected, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_instance_started", EventOAApprovalInstanceStarted, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_instance_cc", EventOAApprovalInstanceCC, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_instance_terminated", EventOAApprovalInstanceTerminated, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_instance_finished", EventOAApprovalInstanceFinished, RuleOptions{}, map[string]any{}},
|
||||
{"read_group", EventReadGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
|
||||
|
||||
@@ -180,6 +180,19 @@ type OAApprovalInstanceStartedOutput struct {
|
||||
EventTime int64 `json:"event_time" description:"审批实例事件业务时间" format:"timestamp_ms"`
|
||||
}
|
||||
|
||||
type OAApprovalInstanceCCOutput struct {
|
||||
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
|
||||
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
|
||||
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
|
||||
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
|
||||
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
|
||||
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
|
||||
Title string `json:"title" description:"审批标题"`
|
||||
Status string `json:"status" description:"审批实例到达抄送节点时的状态"`
|
||||
CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"`
|
||||
EventTime int64 `json:"event_time" description:"审批抄送事件业务时间" format:"timestamp_ms"`
|
||||
}
|
||||
|
||||
type OAApprovalInstanceTerminatedOutput struct {
|
||||
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
|
||||
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
|
||||
@@ -667,6 +680,19 @@ func projectOAApprovalEvent(ev transport.Event, base baseEventOutput, raw json.R
|
||||
CreateTime: payload.Body.CreateTime,
|
||||
EventTime: payload.EventTime,
|
||||
}, nil
|
||||
case EventOAApprovalInstanceCC:
|
||||
return OAApprovalInstanceCCOutput{
|
||||
Type: base.Type,
|
||||
EventID: base.EventID,
|
||||
Timestamp: base.Timestamp,
|
||||
SubscribeID: base.SubscribeID,
|
||||
ProcessInstanceID: payload.Body.ProcessInstanceID,
|
||||
ProcessCode: payload.Body.ProcessCode,
|
||||
Title: payload.Body.Title,
|
||||
Status: payload.Body.Status,
|
||||
CreateTime: payload.Body.CreateTime,
|
||||
EventTime: payload.EventTime,
|
||||
}, nil
|
||||
case EventOAApprovalInstanceTerminated:
|
||||
return OAApprovalInstanceTerminatedOutput{
|
||||
Type: base.Type,
|
||||
@@ -871,6 +897,8 @@ func outputTypeForEvent(eventKey string) reflect.Type {
|
||||
return reflect.TypeOf(OAApprovalTaskRedirectedOutput{})
|
||||
case eventKey == EventOAApprovalInstanceStarted:
|
||||
return reflect.TypeOf(OAApprovalInstanceStartedOutput{})
|
||||
case eventKey == EventOAApprovalInstanceCC:
|
||||
return reflect.TypeOf(OAApprovalInstanceCCOutput{})
|
||||
case eventKey == EventOAApprovalInstanceTerminated:
|
||||
return reflect.TypeOf(OAApprovalInstanceTerminatedOutput{})
|
||||
case eventKey == EventOAApprovalInstanceFinished:
|
||||
@@ -906,6 +934,7 @@ func isOAEvent(eventKey string) bool {
|
||||
eventKey == EventOAApprovalTaskFinished ||
|
||||
eventKey == EventOAApprovalTaskRedirected ||
|
||||
eventKey == EventOAApprovalInstanceStarted ||
|
||||
eventKey == EventOAApprovalInstanceCC ||
|
||||
eventKey == EventOAApprovalInstanceTerminated ||
|
||||
eventKey == EventOAApprovalInstanceFinished
|
||||
}
|
||||
|
||||
@@ -173,6 +173,8 @@ func personalOAData(eventKey string) string {
|
||||
body["finishTime"] = int64(1785229199000)
|
||||
case EventOAApprovalInstanceStarted:
|
||||
body["status"] = "RUNNING"
|
||||
case EventOAApprovalInstanceCC:
|
||||
body["status"] = "RUNNING"
|
||||
case EventOAApprovalInstanceTerminated:
|
||||
body["status"] = "TERMINATED"
|
||||
body["finishTime"] = int64(1785229199000)
|
||||
@@ -521,6 +523,21 @@ func TestCrossPlatformCoverageProjectOutputOAEvents(t *testing.T) {
|
||||
EventTime: 1785229199000,
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceCC,
|
||||
want: OAApprovalInstanceCCOutput{
|
||||
Type: EventOAApprovalInstanceCC,
|
||||
EventID: "oa-event",
|
||||
Timestamp: 1785229200123,
|
||||
SubscribeID: "outer-sub",
|
||||
ProcessInstanceID: "process-instance-1",
|
||||
ProcessCode: "PROC-TEST-1",
|
||||
Title: "测试审批",
|
||||
Status: "RUNNING",
|
||||
CreateTime: 1785229100000,
|
||||
EventTime: 1785229199000,
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceTerminated,
|
||||
want: OAApprovalInstanceTerminatedOutput{
|
||||
@@ -785,6 +802,7 @@ func TestCrossPlatformCoverageProjectOutputRejectsInvalidOAPayloads(t *testing.T
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceCC,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
} {
|
||||
|
||||
@@ -43,6 +43,7 @@ const (
|
||||
EventOAApprovalTaskFinished = "user_oa_approval_task_finished"
|
||||
EventOAApprovalTaskRedirected = "user_oa_approval_task_redirected"
|
||||
EventOAApprovalInstanceStarted = "user_oa_approval_instance_started"
|
||||
EventOAApprovalInstanceCC = "user_oa_approval_instance_cc"
|
||||
EventOAApprovalInstanceTerminated = "user_oa_approval_instance_terminated"
|
||||
EventOAApprovalInstanceFinished = "user_oa_approval_instance_finished"
|
||||
)
|
||||
@@ -323,6 +324,17 @@ var definitions = []Definition{
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventOAApprovalInstanceCC,
|
||||
DisplayName: "审批单抄送",
|
||||
Description: "审批实例到达抄送节点,发送给被抄送人",
|
||||
Category: "oa",
|
||||
RuleType: "all",
|
||||
Status: StatusEnabled,
|
||||
RequiredParams: nil,
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventOAApprovalInstanceTerminated,
|
||||
DisplayName: "审批单终止",
|
||||
|
||||
@@ -50,6 +50,7 @@ func TestCatalogEnabledEvents(t *testing.T) {
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceCC,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
}
|
||||
@@ -65,6 +66,7 @@ func TestOAEventCatalogDefinitions(t *testing.T) {
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceCC,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
}
|
||||
@@ -158,6 +160,7 @@ func TestSchemaDocumentsDefaultToTransportEnvelope(t *testing.T) {
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceCC,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
} {
|
||||
@@ -509,6 +512,13 @@ func TestOAEventSchemaDocumentsMatchOutputDTO(t *testing.T) {
|
||||
"process_code", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceCC,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceTerminated,
|
||||
properties: []string{
|
||||
@@ -633,6 +643,7 @@ func TestBuildRuleParamAllEvents(t *testing.T) {
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceCC,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
} {
|
||||
@@ -845,6 +856,7 @@ func TestSupportsMessageFilter(t *testing.T) {
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceCC,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
"unknown_event",
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type workflowFixture struct {
|
||||
Version int `json:"version"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Cases []cli.ToolSearchWorkflowEvaluationCase `json:"cases"`
|
||||
}
|
||||
|
||||
type comparisonOutput struct {
|
||||
Diagnostic cli.ToolSearchDiagnosticComparison `json:"diagnostic"`
|
||||
Shadows []cli.ToolSearchDiagnosticComparison `json:"shadows"`
|
||||
AgentPlanningAB *cli.ToolSearchAgentPlanningABReport `json:"agent_planning_ab,omitempty"`
|
||||
AgentAB *cli.ToolSearchAgentABReport `json:"agent_ab,omitempty"`
|
||||
Independent *cli.ToolSearchIndependentReport `json:"independent,omitempty"`
|
||||
}
|
||||
|
||||
type independentFixture struct {
|
||||
Version string `json:"version"`
|
||||
State string `json:"state"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Cases []cli.ToolSearchIndependentCase `json:"cases"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
var workflowsPath string
|
||||
var agentResultsPath string
|
||||
var directPlansPath string
|
||||
var searchPlansPath string
|
||||
var agentModel string
|
||||
var outputPath string
|
||||
var independentQrelsPath string
|
||||
flag.StringVar(&workflowsPath, "workflows", "scripts/testdata/tool_search_workflows.json", "reviewed diagnostic workflow fixture")
|
||||
flag.StringVar(&agentResultsPath, "agent-results", "", "optional paired Agent A/B result JSON")
|
||||
flag.StringVar(&directPlansPath, "agent-plans-direct", "", "optional direct_schema Agent planning output JSON")
|
||||
flag.StringVar(&searchPlansPath, "agent-plans-search", "", "optional search_inspect Agent planning output JSON")
|
||||
flag.StringVar(&agentModel, "agent-model", "", "model identifier for paired planning outputs")
|
||||
flag.StringVar(&outputPath, "output", "", "output JSON path; stdout when empty")
|
||||
flag.StringVar(&independentQrelsPath, "independent-qrels", "", "optional independently authored tool-search-qrels.v1 JSON")
|
||||
flag.Parse()
|
||||
|
||||
// Runtime and CI use the same declaration-owned Catalog assembly. The
|
||||
// comparison generator never reads or writes a repository Catalog JSON.
|
||||
cli.RegisterSchemaSourceRoot(func() *cobra.Command { return app.NewSchemaSourceRootCommand() })
|
||||
|
||||
workflows, err := readWorkflows(workflowsPath)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
diagnostic, err := cli.BuildDeliveryToolSearchDiagnosticComparison(context.Background(), workflows)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
actionShadow, err := cli.BuildDeliveryToolSearchDiagnosticComparisonForAlgorithm(context.Background(), workflows, cli.ToolSearchLexicalBM25Action)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
shadow, err := cli.BuildDeliveryToolSearchDiagnosticComparisonForAlgorithm(context.Background(), workflows, cli.ToolSearchLexicalTFIDF)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
result := comparisonOutput{Diagnostic: diagnostic, Shadows: []cli.ToolSearchDiagnosticComparison{actionShadow, shadow}}
|
||||
if independentQrelsPath != "" {
|
||||
fixture, readErr := readIndependentQrels(independentQrelsPath)
|
||||
if readErr != nil {
|
||||
fatal(readErr)
|
||||
}
|
||||
independent, evaluationErr := cli.BuildDeliveryToolSearchIndependentEvaluation(context.Background(), fixture.Cases)
|
||||
if evaluationErr != nil {
|
||||
fatal(evaluationErr)
|
||||
}
|
||||
result.Independent = &independent
|
||||
}
|
||||
if directPlansPath != "" || searchPlansPath != "" || agentModel != "" {
|
||||
if directPlansPath == "" || searchPlansPath == "" || agentModel == "" {
|
||||
fatal(fmt.Errorf("-agent-plans-direct, -agent-plans-search and -agent-model must be provided together"))
|
||||
}
|
||||
directPlans, readErr := readAgentPlans(directPlansPath)
|
||||
if readErr != nil {
|
||||
fatal(readErr)
|
||||
}
|
||||
searchPlans, readErr := readAgentPlans(searchPlansPath)
|
||||
if readErr != nil {
|
||||
fatal(readErr)
|
||||
}
|
||||
planningReport, scoreErr := cli.ScoreToolSearchAgentPlanningAB(agentModel, workflows, directPlans, searchPlans)
|
||||
if scoreErr != nil {
|
||||
fatal(scoreErr)
|
||||
}
|
||||
result.AgentPlanningAB = &planningReport
|
||||
}
|
||||
if agentResultsPath != "" {
|
||||
agentInput, readErr := readAgentResults(agentResultsPath)
|
||||
if readErr != nil {
|
||||
fatal(readErr)
|
||||
}
|
||||
if agentInput.Catalog != diagnostic.Catalog {
|
||||
fatal(fmt.Errorf("Agent A/B Catalog hashes do not match the embedded binary"))
|
||||
}
|
||||
agentReport, aggregateErr := cli.AggregateToolSearchAgentAB(agentInput)
|
||||
if aggregateErr != nil {
|
||||
fatal(aggregateErr)
|
||||
}
|
||||
diagnostic.AgentABStatus = "completed"
|
||||
result.Diagnostic = diagnostic
|
||||
result.AgentAB = &agentReport
|
||||
}
|
||||
encoded, err := json.MarshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
encoded = append(encoded, '\n')
|
||||
if outputPath == "" {
|
||||
if _, err := os.Stdout.Write(encoded); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := writeAtomically(outputPath, encoded); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func readIndependentQrels(path string) (independentFixture, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return independentFixture{}, fmt.Errorf("read independent qrels: %w", err)
|
||||
}
|
||||
var fixture independentFixture
|
||||
if err := decodeStrict(data, &fixture); err != nil {
|
||||
return independentFixture{}, fmt.Errorf("decode independent qrels: %w", err)
|
||||
}
|
||||
if fixture.Version != "tool-search-qrels.v1" || len(fixture.Cases) == 0 {
|
||||
return independentFixture{}, fmt.Errorf("independent qrels must be tool-search-qrels.v1 and non-empty")
|
||||
}
|
||||
return fixture, nil
|
||||
}
|
||||
|
||||
func readWorkflows(path string) ([]cli.ToolSearchWorkflowEvaluationCase, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read workflows: %w", err)
|
||||
}
|
||||
var fixture workflowFixture
|
||||
if err := decodeStrict(data, &fixture); err != nil {
|
||||
return nil, fmt.Errorf("decode workflows: %w", err)
|
||||
}
|
||||
if fixture.Version != 1 || len(fixture.Cases) == 0 {
|
||||
return nil, fmt.Errorf("workflow fixture must be version 1 and non-empty")
|
||||
}
|
||||
return fixture.Cases, nil
|
||||
}
|
||||
|
||||
func readAgentResults(path string) (cli.ToolSearchAgentABInput, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return cli.ToolSearchAgentABInput{}, fmt.Errorf("read Agent A/B results: %w", err)
|
||||
}
|
||||
var input cli.ToolSearchAgentABInput
|
||||
if err := decodeStrict(data, &input); err != nil {
|
||||
return cli.ToolSearchAgentABInput{}, fmt.Errorf("decode Agent A/B results: %w", err)
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func readAgentPlans(path string) (cli.ToolSearchAgentPlanOutput, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return cli.ToolSearchAgentPlanOutput{}, fmt.Errorf("read Agent plans: %w", err)
|
||||
}
|
||||
var output cli.ToolSearchAgentPlanOutput
|
||||
if err := decodeStrict(data, &output); err != nil {
|
||||
return cli.ToolSearchAgentPlanOutput{}, fmt.Errorf("decode Agent plans: %w", err)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func decodeStrict(data []byte, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("multiple JSON values are not allowed")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeAtomically(path string, data []byte) error {
|
||||
directory := filepath.Dir(path)
|
||||
if err := os.MkdirAll(directory, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
temporary, err := os.CreateTemp(directory, ".tool-search-comparison-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
if _, err := temporary.Write(data); err != nil {
|
||||
temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(temporaryPath, path)
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -3,18 +3,21 @@ package helpers
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
// boolPtr returns a pointer to v, for ParamDecl.Required declarations.
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
// aisearchKeywordAliases 是 --keyword flag 的同义瞎猜兜底列表。
|
||||
// 模型可能写 --name / --q / --query / --text,这些都被识别为 keyword。
|
||||
var aisearchKeywordAliases = []string{"name", "q", "query", "text"}
|
||||
// 这些是原有的宽松兜底名,不属于本次 Primary 迁移;保持原生 hidden
|
||||
// 注册,不给它们新增 alias_of/origin 元数据。
|
||||
var aisearchPersonGuessFlags = []string{"name", "q", "text"}
|
||||
|
||||
// 历史解析优先级必须独立于新旧 Primary:双传时旧 --keyword 始终获胜。
|
||||
var aisearchQueryResolutionOrder = []string{"keyword", "name", "q", "query", "text"}
|
||||
|
||||
// flagValue 安全地读取 flag 值:先查 local,再查自身 PersistentFlags,
|
||||
// 再查 parents 的 PersistentFlags。比 cmd.Flags().GetString 更鲁棒,
|
||||
@@ -58,14 +61,11 @@ func aisearchFlagOrDefault(cmd *cobra.Command, primary, def string, aliases ...s
|
||||
return def
|
||||
}
|
||||
|
||||
// resolveAisearchKeyword 从命令的 flag 中解析 keyword:优先 --keyword,
|
||||
// 否则 fallback 到 aisearchKeywordAliases 中的任一同义 flag。
|
||||
// resolveAisearchKeyword 按历史顺序解析人员搜索词。虽然公开 Primary 已改为
|
||||
// --query,但兼容期内显式旧 --keyword 的值仍优先。
|
||||
func resolveAisearchKeyword(cmd *cobra.Command) string {
|
||||
if v := flagValue(cmd, "keyword"); v != "" {
|
||||
return v
|
||||
}
|
||||
for _, alias := range aisearchKeywordAliases {
|
||||
if v := flagValue(cmd, alias); v != "" {
|
||||
for _, name := range aisearchQueryResolutionOrder {
|
||||
if v := flagValue(cmd, name); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
@@ -73,9 +73,23 @@ func resolveAisearchKeyword(cmd *cobra.Command) string {
|
||||
}
|
||||
|
||||
func addAisearchPersonFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().StringP("keyword", "w", "", "搜索关键词 (必填,如人名、技能关键词等)")
|
||||
// Alias evidence must be emitted by corecmd.FlagSpec.Aliases. Build the pair
|
||||
// on a temporary declaration command so the legacy alias can retain its
|
||||
// historical -w shorthand without extending the global alias mechanism or
|
||||
// assigning that shorthand to the new --query Primary.
|
||||
declaration := &cobra.Command{Use: "aisearch-query-flags"}
|
||||
corecmd.RegisterFlags(declaration, []corecmd.FlagSpec{{
|
||||
Name: "query",
|
||||
Usage: "搜索关键词 (必填,如人名、技能关键词等)",
|
||||
Aliases: []string{"keyword"},
|
||||
}})
|
||||
queryFlag := declaration.Flags().Lookup("query")
|
||||
keywordFlag := declaration.Flags().Lookup("keyword")
|
||||
keywordFlag.Shorthand = "w"
|
||||
cmd.Flags().AddFlag(queryFlag)
|
||||
cmd.Flags().AddFlag(keywordFlag)
|
||||
cmd.Flags().StringP("dimension", "d", "all", "查询维度: all/name/department/position/duty/supervisor/subordinate/phone/jobNumber,多个用逗号分隔")
|
||||
for _, alias := range aisearchKeywordAliases {
|
||||
for _, alias := range aisearchPersonGuessFlags {
|
||||
cmd.Flags().String(alias, "", "")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
@@ -98,8 +112,7 @@ func runAisearchPerson(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
keyword := resolveAisearchKeyword(cmd)
|
||||
if keyword == "" {
|
||||
// 复用原有报错文案("keyword is required")
|
||||
return validateRequiredFlags(cmd, "keyword")
|
||||
return validateRequiredFlags(cmd, "query")
|
||||
}
|
||||
dimensions := parseDimensions(flagValue(cmd, "dimension"))
|
||||
return callMCPTool("enterprise_person_search", map[string]any{
|
||||
@@ -188,8 +201,8 @@ func newAisearchCommand() *cobra.Command {
|
||||
Use: "aisearch",
|
||||
Short: "AI 搜问",
|
||||
Long: `AI 搜问:搜索企业人员信息、企业内部知识内容与企业内部行为记录。`,
|
||||
// 智能 root:模型常漏 person 子命令直接 dws aisearch --keyword xxx,
|
||||
// 检测到 keyword 就自动等价于 person;否则退回 group help。
|
||||
// 智能 root:模型常漏 person 子命令直接 dws aisearch --query xxx,
|
||||
// 检测到 query 就自动等价于 person;否则退回 group help。
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if resolveAisearchKeyword(cmd) != "" {
|
||||
return runAisearchPerson(cmd, args)
|
||||
@@ -199,9 +212,9 @@ func newAisearchCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
// root 和 person 各自定义同一组本地 flag,这样:
|
||||
// - dws aisearch --keyword xxx ← root 自己能解析
|
||||
// - dws aisearch person --keyword xxx ← person 本地 flag
|
||||
// - dws aisearch search --keyword xxx ← search 是 person 的 alias
|
||||
// - dws aisearch --query xxx ← root 自己能解析
|
||||
// - dws aisearch person --query xxx ← person 本地 flag
|
||||
// - dws aisearch search --query xxx ← search 是 person 的 alias
|
||||
// 不能放在 product PersistentFlags:否则 enterprise/behavior 会公开接受
|
||||
// 它们不拥有的 person-only dimension,破坏 Help ↔ Schema 完整性。
|
||||
addAisearchPersonFlags(root)
|
||||
@@ -219,7 +232,7 @@ func newAisearchCommand() *cobra.Command {
|
||||
"query", "people", "ask", "find", "lookup",
|
||||
"contact",
|
||||
},
|
||||
// 显式声明允许任意位置参数:模型可能写 dws aisearch person search --keyword xxx,
|
||||
// 显式声明允许任意位置参数:模型可能写 dws aisearch person search --query xxx,
|
||||
// 此时 "search" 会作为 positional arg 被忽略,不报错。
|
||||
Args: cobra.ArbitraryArgs,
|
||||
Short: "搜索企业人员",
|
||||
@@ -237,13 +250,13 @@ func newAisearchCommand() *cobra.Command {
|
||||
jobNumber 工号
|
||||
|
||||
多个维度用逗号分隔。`,
|
||||
Example: ` dws aisearch person --keyword "张三" --dimension department
|
||||
dws aisearch person --keyword "产品部" --dimension department
|
||||
dws aisearch person --keyword "五道" --dimension supervisor
|
||||
dws aisearch person --keyword "AI搜问" --dimension duty
|
||||
dws aisearch person --keyword "李四" --dimension name,department
|
||||
dws aisearch person --keyword "13800138000" --dimension phone
|
||||
dws aisearch person --keyword "W12345" --dimension jobNumber`,
|
||||
Example: ` dws aisearch person --query "张三" --dimension department
|
||||
dws aisearch person --query "产品部" --dimension department
|
||||
dws aisearch person --query "五道" --dimension supervisor
|
||||
dws aisearch person --query "AI搜问" --dimension duty
|
||||
dws aisearch person --query "李四" --dimension name,department
|
||||
dws aisearch person --query "13800138000" --dimension phone
|
||||
dws aisearch person --query "W12345" --dimension jobNumber`,
|
||||
RunE: runAisearchPerson,
|
||||
}
|
||||
DeclareLeafMetadata(personCmd, LeafSpec{
|
||||
@@ -277,10 +290,13 @@ func newAisearchCommand() *cobra.Command {
|
||||
"搜企业知识内容时用 aisearch enterprise;搜行为记录时用 aisearch behavior",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws aisearch person --keyword \"张三\" --dimension name --format json",
|
||||
"dws aisearch person --keyword \"五道\" --dimension supervisor --format json",
|
||||
"dws aisearch person --query \"张三\" --dimension name --format json",
|
||||
"dws aisearch person --query \"五道\" --dimension supervisor --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "query", Property: "keyword", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
addAisearchPersonFlags(personCmd)
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
|
||||
package helpers
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
)
|
||||
|
||||
func TestAisearchPersonFlagsDoNotLeakIntoContentCommands(t *testing.T) {
|
||||
root := newAisearchCommand()
|
||||
@@ -12,12 +17,34 @@ func TestAisearchPersonFlagsDoNotLeakIntoContentCommands(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"keyword", "dimension"} {
|
||||
for _, name := range []string{"query", "dimension"} {
|
||||
flag := person.Flags().Lookup(name)
|
||||
if flag == nil || flag.Hidden {
|
||||
t.Fatalf("person --%s = %#v, want visible local flag", name, flag)
|
||||
}
|
||||
}
|
||||
if person.Flags().Lookup("query").Shorthand != "" {
|
||||
t.Fatal("new Primary --query must not acquire a shorthand")
|
||||
}
|
||||
keyword := person.Flags().Lookup("keyword")
|
||||
if keyword == nil || !keyword.Hidden || keyword.Shorthand != "w" {
|
||||
t.Fatalf("person --keyword = %#v, want hidden legacy flag with -w", keyword)
|
||||
}
|
||||
if got := keyword.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "query" {
|
||||
t.Fatalf("person --keyword alias_of = %#v, want query", got)
|
||||
}
|
||||
if got := keyword.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
|
||||
t.Fatalf("person --keyword alias_origin = %#v, want %q", got, runtimeannotate.FlagAliasOriginCorecmdV1)
|
||||
}
|
||||
for _, name := range []string{"name", "q", "text"} {
|
||||
flag := person.Flags().Lookup(name)
|
||||
if flag == nil || !flag.Hidden {
|
||||
t.Fatalf("person --%s = %#v, want preserved hidden guess flag", name, flag)
|
||||
}
|
||||
if got := flag.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 0 {
|
||||
t.Fatalf("person --%s unexpectedly gained alias_of metadata: %#v", name, got)
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range []string{"enterprise", "behavior"} {
|
||||
cmd, _, err := root.Find([]string{path})
|
||||
@@ -35,3 +62,38 @@ func TestAisearchPersonFlagsDoNotLeakIntoContentCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAisearchPersonQueryPrimaryCompatibility(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "root new", args: []string{"--query", "new"}, want: "new"},
|
||||
{name: "root old", args: []string{"--keyword", "old"}, want: "old"},
|
||||
{name: "root old shorthand", args: []string{"-w", "old-short"}, want: "old-short"},
|
||||
{name: "root both old wins", args: []string{"--query", "new", "--keyword", "old"}, want: "old"},
|
||||
{name: "person new", args: []string{"person", "--query", "new"}, want: "new"},
|
||||
{name: "person old", args: []string{"person", "--keyword", "old"}, want: "old"},
|
||||
{name: "person old shorthand", args: []string{"person", "-w", "old-short"}, want: "old-short"},
|
||||
{name: "person both old wins", args: []string{"person", "--query", "new", "--keyword", "old"}, want: "old"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executeFilterCoverage(t, newAisearchCommand(), tc.args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.tool != "enterprise_person_search" || caller.args["keyword"] != tc.want {
|
||||
t.Fatalf("call = %s %#v, want backend keyword %q", caller.tool, caller.args, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
err := executeFilterCoverage(t, newAisearchCommand(), "person")
|
||||
if err == nil || !strings.Contains(err.Error(), "--query") || strings.Contains(err.Error(), "--keyword") {
|
||||
t.Fatalf("missing query error = %v, want only new Primary", err)
|
||||
}
|
||||
}
|
||||
|
||||
+325
-5
@@ -520,6 +520,102 @@ func normalizeFilters(parsed any) any {
|
||||
}
|
||||
}
|
||||
|
||||
type aitableStatsItem struct {
|
||||
FieldID string `json:"fieldId"`
|
||||
StatsType string `json:"statsType"`
|
||||
}
|
||||
|
||||
func aitableStatsValidationf(format string, args ...any) error {
|
||||
return apperrors.NewValidation(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func parseAitableStatsItems(raw string, uppercase, uniqueFields bool, maxItems int) ([]map[string]any, error) {
|
||||
var items []aitableStatsItem
|
||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||
return nil, aitableStatsValidationf("--stats 必须是 JSON 数组: %v", err)
|
||||
}
|
||||
var strictItems []aitableStatsItem
|
||||
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&strictItems); err != nil {
|
||||
return nil, aitableStatsValidationf("--stats 包含不支持的字段: %v", err)
|
||||
}
|
||||
items = strictItems
|
||||
if len(items) == 0 {
|
||||
return nil, aitableStatsValidationf("--stats 至少需要一个统计项")
|
||||
}
|
||||
if maxItems > 0 && len(items) > maxItems {
|
||||
return nil, aitableStatsValidationf("--stats 单次最多支持 %d 个统计项,got %d", maxItems, len(items))
|
||||
}
|
||||
|
||||
seenFields := make(map[string]struct{}, len(items))
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for index, item := range items {
|
||||
item.FieldID = strings.TrimSpace(item.FieldID)
|
||||
item.StatsType = strings.TrimSpace(item.StatsType)
|
||||
if item.FieldID == "" || item.StatsType == "" {
|
||||
return nil, aitableStatsValidationf("--stats[%d] 的 fieldId 和 statsType 均不能为空", index)
|
||||
}
|
||||
if uniqueFields {
|
||||
if _, exists := seenFields[item.FieldID]; exists {
|
||||
return nil, aitableStatsValidationf("--stats 中 fieldId %q 重复;query_records_stats 要求同一字段的多个统计类型拆成多次调用", item.FieldID)
|
||||
}
|
||||
seenFields[item.FieldID] = struct{}{}
|
||||
}
|
||||
if uppercase && item.StatsType != strings.ToUpper(item.StatsType) {
|
||||
return nil, aitableStatsValidationf("--stats[%d].statsType 必须使用大写枚举值,got %q", index, item.StatsType)
|
||||
}
|
||||
if !uppercase && item.StatsType != strings.ToLower(item.StatsType) {
|
||||
return nil, aitableStatsValidationf("--stats[%d].statsType 必须使用小写值,got %q", index, item.StatsType)
|
||||
}
|
||||
out = append(out, map[string]any{"fieldId": item.FieldID, "statsType": item.StatsType})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseAitableObjectFlag(name, raw string) (map[string]any, error) {
|
||||
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value map[string]any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return nil, aitableStatsValidationf("--%s 必须是 JSON 对象: %v", name, err)
|
||||
}
|
||||
if value == nil {
|
||||
return nil, aitableStatsValidationf("--%s 必须是 JSON 对象,不能是 null", name)
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||
if err == nil {
|
||||
return nil, aitableStatsValidationf("--%s 只能包含一个 JSON 对象", name)
|
||||
}
|
||||
return nil, aitableStatsValidationf("--%s 包含无效的尾随内容: %v", name, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func validateAitableStatsFilters(value map[string]any, rawJSON string) error {
|
||||
if err := validateFiltersStructure(value, rawJSON); err != nil {
|
||||
return aitableStatsValidationf("%v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAitableArrayDSL(name, raw string) error {
|
||||
var value []any
|
||||
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
||||
return aitableStatsValidationf("--%s 必须是 JSON 数组字符串: %v", name, err)
|
||||
}
|
||||
if len(value) == 0 {
|
||||
return aitableStatsValidationf("--%s 至少需要一个条目", name)
|
||||
}
|
||||
for index, item := range value {
|
||||
if _, ok := item.(map[string]any); !ok {
|
||||
return aitableStatsValidationf("--%s[%d] 必须是 JSON 对象,got %T", name, index, item)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeViewConfigFilter 将 view config 中的 filter 字段规范化为服务端要求的数组格式。
|
||||
// 服务端 POJO 要求 config.filter 为 []FilterRule(JSON array)。
|
||||
// 常见错误格式:
|
||||
@@ -695,15 +791,29 @@ const aitableMaxRetries = 3
|
||||
// callAitableTool 是 aitable 专用的 MCP 调用入口,带自动重试。
|
||||
// 替代直接调用 callMCPTool,对网络抖动和服务端瞬态错误进行透明重试。
|
||||
func callAitableTool(toolName string, args map[string]any) error {
|
||||
return callAitableToolContext(context.Background(), toolName, args)
|
||||
}
|
||||
|
||||
func callAitableToolContext(ctx context.Context, toolName string, args map[string]any) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= aitableMaxRetries; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if attempt > 0 {
|
||||
backoff := time.Duration(1<<(attempt-1)) * time.Second // 1s, 2s, 4s
|
||||
fmt.Fprintf(os.Stderr, "[aitable retry %d/%d] %s after %v...\n", attempt, aitableMaxRetries, toolName, backoff)
|
||||
helperSleep(backoff)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-helperAfter(backoff):
|
||||
}
|
||||
}
|
||||
|
||||
err := callMCPTool(toolName, args)
|
||||
err := callMCPToolContext(ctx, toolName, args)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -1098,7 +1208,7 @@ func newAitableCommand() *cobra.Command {
|
||||
dws aitable base [list|search|get|get-primary-doc-id|create|update|delete|copy] Base 管理
|
||||
dws aitable table [get|create|update|delete] 数据表管理
|
||||
dws aitable field [get|create|update|delete|search-options] 字段管理
|
||||
dws aitable record [query|create|update|delete] 记录管理
|
||||
dws aitable record [query|stats|group-stats|create|update|delete] 记录管理
|
||||
dws aitable view [get|create|update|delete] 视图管理
|
||||
dws aitable form [list|delete|update] 表单管理
|
||||
dws aitable form field [list|update|hide] 表单字段管理
|
||||
@@ -1781,7 +1891,7 @@ config 结构参考:
|
||||
if v, _ := cmd.Flags().GetString("field-ids"); v != "" {
|
||||
toolArgs["fieldIds"] = parseCSVValues(v)
|
||||
}
|
||||
return callAitableTool("get_fields", toolArgs)
|
||||
return callAitableToolContext(cmd.Context(), "get_fields", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(fieldGetCmd, LeafSpec{
|
||||
@@ -2245,6 +2355,199 @@ newFieldName、config、aiConfig 至少传入一项。
|
||||
},
|
||||
})
|
||||
|
||||
recordStatsCmd := &cobra.Command{
|
||||
Use: "stats",
|
||||
Short: "整表或过滤后的字段聚合统计",
|
||||
Long: `对单张数据表执行不分组的服务端聚合,底层调用 query_records_stats。
|
||||
适用于记录计数、求和、平均值、最大值、最小值、中位数、去重数、完整率等标量统计。
|
||||
|
||||
--stats 是 JSON 数组,单次最多 20 项;每项必须包含 fieldId 和大写 statsType。
|
||||
同一个 fieldId 不能在一次请求中重复,如需对同一字段计算多个指标,请拆成多次调用。
|
||||
常用 statsType:COUNT、COUNT_COLUMN、SUM、AVG、MAX、MIN、MEDIAN、STANDARD_DEVIATION、RANGE、
|
||||
DISTINCT、DISTINCT_RATIO、EXISTS、UN_EXISTS、EXIST_RATIO、UN_EXIST_RATIO、CHECKED、UN_CHECKED、
|
||||
CHECKED_RATIO、UN_CHECKED_RATIO、LATEST_DATE、EARLIEST_DATE、DATE_RANGE、DATE_RANGE_MONTH。
|
||||
|
||||
需要统计全部匹配记录时不要传 --limit;limit 会改变参与统计的记录范围。
|
||||
lt/gt/lte/gte 的过滤值必须使用 JSON 数字;单选/多选过滤建议使用 field get 返回的选项 ID。`,
|
||||
Example: ` dws aitable record stats --base-id BASE_ID --table-id TABLE_ID --stats '[{"fieldId":"fldAmount","statsType":"SUM"}]'
|
||||
dws aitable record stats --base-id BASE_ID --table-id TABLE_ID --filters '{"operator":"and","operands":[{"operator":"gt","operands":["fldAmount",0]}]}' --stats '[{"fieldId":"fldTitle","statsType":"COUNT"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-id", "stats"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stats, err := parseAitableStatsItems(mustGetFlag(cmd, "stats"), true, true, 20)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": mustGetFlag(cmd, "table-id"),
|
||||
"stats": stats,
|
||||
}
|
||||
if raw, _ := cmd.Flags().GetString("filters"); raw != "" {
|
||||
filters, err := parseAitableObjectFlag("filters", raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAitableStatsFilters(filters, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["filters"] = normalizeFilters(filters)
|
||||
}
|
||||
if raw, _ := cmd.Flags().GetString("sort"); raw != "" {
|
||||
if err := validateAitableArrayDSL("sort", raw); err != nil {
|
||||
return err
|
||||
}
|
||||
// query_records_stats 的 MCP Schema 将 sort 定义为 JSON 数组编码后的字符串,
|
||||
// 与 query_records 使用的数组类型不同。
|
||||
toolArgs["sort"] = raw
|
||||
}
|
||||
if value, _ := cmd.Flags().GetInt("limit"); cmd.Flags().Changed("limit") {
|
||||
if value <= 0 {
|
||||
return aitableStatsValidationf("--limit 必须大于 0;统计全部匹配记录时请省略该参数")
|
||||
}
|
||||
toolArgs["limit"] = value
|
||||
}
|
||||
if value, _ := cmd.Flags().GetString("keyword"); value != "" {
|
||||
toolArgs["keyword"] = value
|
||||
}
|
||||
if value, _ := cmd.Flags().GetString("search-field-ids"); value != "" {
|
||||
toolArgs["searchFieldIds"] = parseCSVValues(value)
|
||||
}
|
||||
if value, _ := cmd.Flags().GetString("data-version"); value != "" {
|
||||
toolArgs["dataVersion"] = value
|
||||
}
|
||||
return callAitableTool("query_records_stats", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(recordStatsCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "query_records_stats",
|
||||
CanonicalPath: "aitable.query_records_stats",
|
||||
CLIPath: "aitable record stats",
|
||||
PrimaryCLIPath: "aitable record stats",
|
||||
},
|
||||
Description: "对整表或过滤后的记录执行不分组字段聚合。",
|
||||
Interface: aitableMCPInterface("query_records_stats"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "对整表或过滤后的记录执行不分组字段聚合。",
|
||||
UseWhen: []string{"需要记录总数、SUM/AVG/MAX/MIN、中位数、去重数、完整率等标量统计时,优先服务端聚合"},
|
||||
AvoidWhen: []string{"需要按字段分组或使用 query_stats 高级聚合时用 record group-stats;需要行级明细时用 record query"},
|
||||
Examples: []string{`dws aitable record stats --base-id <BASE_ID> --table-id <TABLE_ID> --stats '[{"fieldId":"<FIELD_ID>","statsType":"COUNT"}]'`},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
|
||||
{Name: "stats", Property: "stats", Required: boolPtr(true), InterfaceType: "array"},
|
||||
{Name: "filters", Property: "filters", InterfaceType: "object"},
|
||||
{Name: "search-field-ids", Property: "searchFieldIds", InterfaceType: "array"},
|
||||
{Name: "data-version", Property: "dataVersion"},
|
||||
},
|
||||
Result: aitableRecordsStatsResultSpec(),
|
||||
},
|
||||
})
|
||||
|
||||
recordGroupStatsCmd := &cobra.Command{
|
||||
Use: "group-stats",
|
||||
Short: "分组、去重及高级聚合统计",
|
||||
Long: `对单张数据表执行分组或高级服务端聚合,底层调用 query_stats,最多返回 1000 个分组。
|
||||
适用于按一个或多个字段分组、条件唯一实体计数,以及 distinct、distinct_ratio 等高级统计。
|
||||
|
||||
--stats 是 JSON 数组,每项包含 fieldId 和小写 statsType。基础类型为 sum、avg、count、max、min;
|
||||
部分后端也支持 median、distinct、distinct_ratio 等高级类型,不支持时服务端会返回明确错误。
|
||||
--group 是 JSON 数组编码后的字符串,不分组的 DISTINCT 标量统计可以省略。
|
||||
不要依赖服务端 limit 选择 Top N;应在不超过 1000 个分组时获取完整结果后再排序。`,
|
||||
Example: ` dws aitable record group-stats --base-id BASE_ID --table-id TABLE_ID --group '[{"fieldId":"fldCategory","direction":"ASC","fieldConfig":null,"arraySplitMode":true}]' --stats '[{"fieldId":"fldAmount","statsType":"sum"}]'
|
||||
dws aitable record group-stats --base-id BASE_ID --table-id TABLE_ID --stats '[{"fieldId":"fldStore","statsType":"distinct"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-id", "stats"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stats, err := parseAitableStatsItems(mustGetFlag(cmd, "stats"), false, false, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": mustGetFlag(cmd, "table-id"),
|
||||
"stats": stats,
|
||||
}
|
||||
if raw, _ := cmd.Flags().GetString("filters"); raw != "" {
|
||||
filters, err := parseAitableObjectFlag("filters", raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAitableStatsFilters(filters, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["filters"] = normalizeFilters(filters)
|
||||
}
|
||||
if raw, _ := cmd.Flags().GetString("group"); raw != "" {
|
||||
if err := validateAitableArrayDSL("group", raw); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["group"] = raw
|
||||
}
|
||||
if raw, _ := cmd.Flags().GetString("sort"); raw != "" {
|
||||
if err := validateAitableArrayDSL("sort", raw); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["sortDsl"] = raw
|
||||
}
|
||||
if value, _ := cmd.Flags().GetInt("limit"); cmd.Flags().Changed("limit") {
|
||||
if value < 1 || value > 1000 {
|
||||
return aitableStatsValidationf("--limit 必须在 [1, 1000] 范围内,got %d", value)
|
||||
}
|
||||
toolArgs["limit"] = value
|
||||
}
|
||||
if value, _ := cmd.Flags().GetString("data-version"); value != "" {
|
||||
toolArgs["dataVersion"] = value
|
||||
}
|
||||
return callAitableTool("query_stats", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(recordGroupStatsCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "query_stats",
|
||||
CanonicalPath: "aitable.query_stats",
|
||||
CLIPath: "aitable record group-stats",
|
||||
PrimaryCLIPath: "aitable record group-stats",
|
||||
},
|
||||
Description: "按字段分组,或执行去重等高级聚合统计。",
|
||||
Interface: aitableMCPInterface("query_stats"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按字段分组,或执行去重等高级聚合统计。",
|
||||
UseWhen: []string{"需要各分类/实体的分组统计,或对满足条件的门店、客户、商品等执行 DISTINCT 唯一计数时"},
|
||||
AvoidWhen: []string{"普通不分组 COUNT/SUM/AVG/MAX/MIN 优先用 record stats;原始记录和 Top N 明细用 record query"},
|
||||
Examples: []string{`dws aitable record group-stats --base-id <BASE_ID> --table-id <TABLE_ID> --stats '[{"fieldId":"<FIELD_ID>","statsType":"distinct"}]'`},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
|
||||
{Name: "stats", Property: "stats", Required: boolPtr(true), InterfaceType: "array"},
|
||||
{Name: "filters", Property: "filters", InterfaceType: "object"},
|
||||
{Name: "group", Property: "group"},
|
||||
{Name: "sort", Property: "sortDsl"},
|
||||
{Name: "data-version", Property: "dataVersion"},
|
||||
},
|
||||
Result: aitableGroupedStatsResultSpec(),
|
||||
},
|
||||
})
|
||||
|
||||
recordCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "新增记录",
|
||||
@@ -7230,6 +7533,23 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
recordQueryCmd.Flags().Int("page-limit", 50, "自动翻页最大页数(仅 --all 时生效)。默认 50 页(约 5000 条);设为 0 表示显式不限页数;超限时错误详情保留已取记录和续传 cursor")
|
||||
recordQueryCmd.Flags().String("view-id", "", "视图 ID(record query 不支持按视图过滤,此参数会被忽略并给出提示)")
|
||||
_ = recordQueryCmd.Flags().MarkHidden("view-id")
|
||||
recordStatsCmd.Flags().String("base-id", "", "Base ID(通过 base get 确认目标)(必填)")
|
||||
recordStatsCmd.Flags().String("table-id", "", "Table ID(通过 table get 获取)(必填)")
|
||||
recordStatsCmd.Flags().String("stats", "", `统计项 JSON 数组(必填),例如 [{"fieldId":"fldAmount","statsType":"SUM"}];statsType 必须大写,最多 20 项且 fieldId 不得重复`)
|
||||
recordStatsCmd.Flags().String("filters", "", "结构化过滤条件 JSON 对象;数值比较值必须是 JSON 数字")
|
||||
recordStatsCmd.Flags().String("sort", "", `参与统计记录的排序 DSL(JSON 数组字符串),例如 [{"fieldId":"fldDate","direction":"ASC"}]`)
|
||||
recordStatsCmd.Flags().Int("limit", 0, "参与统计的最大记录数;省略表示统计全部匹配记录")
|
||||
recordStatsCmd.Flags().String("keyword", "", "全文关键词,仅匹配的记录参与统计")
|
||||
recordStatsCmd.Flags().String("search-field-ids", "", "关键词搜索字段 ID 列表,逗号分隔;仅 --keyword 非空时生效")
|
||||
recordStatsCmd.Flags().String("data-version", "", "可选数据版本;通常省略以使用最新版本")
|
||||
recordGroupStatsCmd.Flags().String("base-id", "", "Base ID(通过 base get 确认目标)(必填)")
|
||||
recordGroupStatsCmd.Flags().String("table-id", "", "Table ID(通过 table get 获取)(必填)")
|
||||
recordGroupStatsCmd.Flags().String("stats", "", `统计项 JSON 数组(必填),例如 [{"fieldId":"fldAmount","statsType":"sum"}];statsType 必须小写`)
|
||||
recordGroupStatsCmd.Flags().String("filters", "", "结构化过滤条件 JSON 对象;数值比较值必须是 JSON 数字")
|
||||
recordGroupStatsCmd.Flags().String("group", "", `分组字段 DSL(JSON 数组字符串);不分组的 distinct 统计可省略`)
|
||||
recordGroupStatsCmd.Flags().String("sort", "", "统计结果排序 DSL(JSON 数组字符串),映射到 MCP sortDsl")
|
||||
recordGroupStatsCmd.Flags().Int("limit", 0, "返回的分组结果数,范围 1-1000;省略表示不额外限制")
|
||||
recordGroupStatsCmd.Flags().String("data-version", "", "可选数据版本;通常省略以使用最新版本")
|
||||
recordCreateCmd.Flags().String("base-id", "", "Base ID,可通过 base list 或 base search 获取 (必填)")
|
||||
recordCreateCmd.Flags().String("table-id", "", "Table ID,可通过 base get 获取 (必填)")
|
||||
recordCreateCmd.Flags().String("records", "", "待创建的记录列表 JSON 数组,单次最多 100 条 (必填)")
|
||||
@@ -7294,7 +7614,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
recordPrimaryDocCreateCmd.Flags().String("record-id", "", "目标 Record ID (必填)")
|
||||
|
||||
recordCmd.AddCommand(
|
||||
recordQueryCmd, recordCreateCmd,
|
||||
recordQueryCmd, recordStatsCmd, recordGroupStatsCmd, recordCreateCmd,
|
||||
recordUpdateCmd, recordDeleteCmd,
|
||||
recordBatchUpdateCmd,
|
||||
recordHistoryListCmd,
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -18,6 +19,22 @@ type aitableCommandCoverageCaller struct {
|
||||
response map[string]string
|
||||
}
|
||||
|
||||
type aitableCommandContextKey struct{}
|
||||
|
||||
type aitableCommandContextCaller struct {
|
||||
value any
|
||||
}
|
||||
|
||||
func (c *aitableCommandContextCaller) CallTool(ctx context.Context, _, _ string, _ map[string]any) (*edition.ToolResult, error) {
|
||||
c.value = ctx.Value(aitableCommandContextKey{})
|
||||
return nil, context.Canceled
|
||||
}
|
||||
|
||||
func (*aitableCommandContextCaller) Format() string { return "json" }
|
||||
func (*aitableCommandContextCaller) DryRun() bool { return false }
|
||||
func (*aitableCommandContextCaller) Fields() string { return "" }
|
||||
func (*aitableCommandContextCaller) JQ() string { return "" }
|
||||
|
||||
func (c *aitableCommandCoverageCaller) CallTool(_ context.Context, _, tool string, _ map[string]any) (*edition.ToolResult, error) {
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
@@ -67,6 +84,11 @@ func TestCrossPlatformCoverageAitableRetryWrappersExhaustAndRecover(t *testing.T
|
||||
oldDeps, oldSleep := deps, helperSleep
|
||||
t.Cleanup(func() { deps, helperSleep = oldDeps, oldSleep })
|
||||
helperSleep = func(time.Duration) {}
|
||||
testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time {
|
||||
ready := make(chan time.Time, 1)
|
||||
ready <- time.Time{}
|
||||
return ready
|
||||
})
|
||||
|
||||
retryable := fmt.Errorf("timeout: retryable: true")
|
||||
caller := &aitableTestCaller{errors: []error{retryable, retryable, retryable, retryable}}
|
||||
@@ -86,6 +108,48 @@ func TestCrossPlatformCoverageAitableRetryWrappersExhaustAndRecover(t *testing.T
|
||||
if err := callAitableHelperTool("retry", nil); err == nil {
|
||||
t.Fatal("exhausted helper retries returned nil")
|
||||
}
|
||||
|
||||
caller = &aitableTestCaller{}
|
||||
installAitableDeps(t, caller)
|
||||
if err := callAitableToolContext(nil, "nil-context", nil); err != nil {
|
||||
t.Fatalf("nil context was not normalized: %v", err)
|
||||
}
|
||||
|
||||
caller = &aitableTestCaller{errors: []error{retryable}}
|
||||
installAitableDeps(t, caller)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
backoffPending := make(chan time.Time)
|
||||
testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time {
|
||||
cancel()
|
||||
return backoffPending
|
||||
})
|
||||
if err := callAitableToolContext(ctx, "cancel-during-backoff", nil); err != context.Canceled {
|
||||
t.Fatalf("cancel during retry backoff = %v, want %v", err, context.Canceled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableFieldListPreservesCommandContext(t *testing.T) {
|
||||
old := deps
|
||||
t.Cleanup(func() { deps = old })
|
||||
caller := &aitableCommandContextCaller{}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
|
||||
root := newAitableCommand()
|
||||
installExampleGlobalFlags(root)
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{"field", "list", "--base-id=b", "--table-id=t"})
|
||||
ctx := context.WithValue(context.Background(), aitableCommandContextKey{}, "field-list-context")
|
||||
if err := root.ExecuteContext(ctx); err == nil {
|
||||
t.Fatal("field list context probe unexpectedly succeeded")
|
||||
}
|
||||
if caller.value != "field-list-context" {
|
||||
t.Fatalf("field list caller context value = %#v", caller.value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableCommandValidationEdges(t *testing.T) {
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
@@ -72,3 +74,102 @@ func aitableCompositeInterface(reason string) *contract.InterfaceSpec {
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
func aitableRecordsStatsResultSpec() *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{
|
||||
contract.ResultOutcomeSuccess,
|
||||
contract.ResultOutcomeFailure,
|
||||
},
|
||||
DataSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"description":"不分组的字段聚合结果",
|
||||
"properties":{
|
||||
"results":{
|
||||
"type":"array",
|
||||
"description":"按数据版本返回的聚合结果批次",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"dataVersion":{"description":"参与统计的数据版本"},
|
||||
"deltaVersion":{"description":"聚合结果的增量版本"},
|
||||
"results":{
|
||||
"type":"array",
|
||||
"description":"请求中各统计项的结果",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"fieldId":{"type":"string","description":"被统计的字段 ID"},
|
||||
"statsType":{"type":"string","description":"实际执行的统计类型"},
|
||||
"value":{"description":"统计值;具体 JSON 类型取决于统计类型和字段类型"}
|
||||
},
|
||||
"required":["fieldId","statsType","value"],
|
||||
"additionalProperties":true
|
||||
}
|
||||
}
|
||||
},
|
||||
"required":["results"],
|
||||
"additionalProperties":true
|
||||
}
|
||||
}
|
||||
},
|
||||
"required":["results"],
|
||||
"additionalProperties":true
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
func aitableGroupedStatsResultSpec() *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{
|
||||
contract.ResultOutcomeSuccess,
|
||||
contract.ResultOutcomeFailure,
|
||||
},
|
||||
DataSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"description":"分组或高级字段聚合结果",
|
||||
"properties":{
|
||||
"dataVersion":{"description":"参与统计的数据版本"},
|
||||
"results":{
|
||||
"type":"array",
|
||||
"description":"每个分组一条结果;未分组时通常只有一条",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"groupKeys":{
|
||||
"type":"array",
|
||||
"description":"当前结果的分组键",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"fieldId":{"type":"string","description":"分组字段 ID"},
|
||||
"value":{"description":"分组值;编码取决于字段类型"},
|
||||
"recordCount":{"type":"integer","description":"该分组包含的记录数"}
|
||||
},
|
||||
"additionalProperties":true
|
||||
}
|
||||
},
|
||||
"fieldStatsMap":{
|
||||
"type":"object",
|
||||
"description":"按字段 ID 索引的聚合值",
|
||||
"additionalProperties":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"action":{"type":"string","description":"实际执行的统计动作"},
|
||||
"value":{"description":"统计值;具体 JSON 类型取决于统计动作和字段类型"}
|
||||
},
|
||||
"required":["action","value"],
|
||||
"additionalProperties":true
|
||||
}
|
||||
}
|
||||
},
|
||||
"required":["fieldStatsMap"],
|
||||
"additionalProperties":true
|
||||
}
|
||||
}
|
||||
},
|
||||
"required":["results"],
|
||||
"additionalProperties":true
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
func runAitableStatsCLI(t *testing.T, args ...string) (*aitableTestCaller, error) {
|
||||
t.Helper()
|
||||
caller := &aitableTestCaller{}
|
||||
installAitableDeps(t, caller)
|
||||
command := newAitableCommand()
|
||||
command.SilenceErrors = true
|
||||
command.SilenceUsage = true
|
||||
command.SetArgs(args)
|
||||
return caller, command.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableRecordsStatsForwardsValidatedContract(t *testing.T) {
|
||||
caller, err := runAitableStatsCLI(t,
|
||||
"record", "stats",
|
||||
"--base-id", "base-stats",
|
||||
"--table-id", "table-stats",
|
||||
"--stats", `[{"fieldId":"fldCount","statsType":"COUNT"},{"fieldId":"fldAmount","statsType":"SUM"}]`,
|
||||
"--filters", `{"operator":"and","operands":[{"operator":"gt","operands":["fldAmount",0]}]}`,
|
||||
"--sort", `[{"fieldId":"fldAmount","direction":"DESC"}]`,
|
||||
"--limit", "500",
|
||||
"--keyword", "华东",
|
||||
"--search-field-ids", "fldName,fldRegion",
|
||||
"--data-version", "42",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("record stats returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("tool calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.server != "aitable" || call.tool != "query_records_stats" {
|
||||
t.Fatalf("tool = %s/%s, want aitable/query_records_stats", call.server, call.tool)
|
||||
}
|
||||
wantStats := []map[string]any{
|
||||
{"fieldId": "fldCount", "statsType": "COUNT"},
|
||||
{"fieldId": "fldAmount", "statsType": "SUM"},
|
||||
}
|
||||
if !reflect.DeepEqual(call.args["stats"], wantStats) {
|
||||
t.Fatalf("stats = %#v, want %#v", call.args["stats"], wantStats)
|
||||
}
|
||||
filters := call.args["filters"].(map[string]any)
|
||||
condition := filters["operands"].([]any)[0].(map[string]any)
|
||||
if got := condition["operands"].([]any)[1]; got != json.Number("0") {
|
||||
t.Fatalf("numeric filter value = %#v (%T), want json.Number(0)", got, got)
|
||||
}
|
||||
if call.args["sort"] != `[{"fieldId":"fldAmount","direction":"DESC"}]` ||
|
||||
call.args["limit"] != 500 || call.args["keyword"] != "华东" || call.args["dataVersion"] != "42" {
|
||||
t.Fatalf("forwarded args = %#v", call.args)
|
||||
}
|
||||
if !reflect.DeepEqual(call.args["searchFieldIds"], []string{"fldName", "fldRegion"}) {
|
||||
t.Fatalf("searchFieldIds = %#v", call.args["searchFieldIds"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableGroupStatsForwardsStringDSL(t *testing.T) {
|
||||
group := `[{"fieldId":"fldRegion","direction":"ASC","fieldConfig":null,"arraySplitMode":true}]`
|
||||
sortDSL := `[{"fieldId":"fldAmount","direction":"DESC"}]`
|
||||
caller, err := runAitableStatsCLI(t,
|
||||
"record", "group-stats",
|
||||
"--base-id", "base-stats",
|
||||
"--table-id", "table-stats",
|
||||
"--stats", `[{"fieldId":"fldStore","statsType":"distinct"},{"fieldId":"fldAmount","statsType":"avg"}]`,
|
||||
"--filters", `{"operator":"OR","operands":[{"operator":"not_before","operands":["fldDate","2026-01-01"]}]}`,
|
||||
"--group", group,
|
||||
"--sort", sortDSL,
|
||||
"--limit", "1000",
|
||||
"--data-version", "43",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("record group-stats returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("tool calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.server != "aitable" || call.tool != "query_stats" {
|
||||
t.Fatalf("tool = %s/%s, want aitable/query_stats", call.server, call.tool)
|
||||
}
|
||||
if call.args["group"] != group || call.args["sortDsl"] != sortDSL || call.args["limit"] != 1000 || call.args["dataVersion"] != "43" {
|
||||
t.Fatalf("forwarded args = %#v", call.args)
|
||||
}
|
||||
stats := call.args["stats"].([]map[string]any)
|
||||
if len(stats) != 2 || stats[0]["statsType"] != "distinct" || stats[1]["statsType"] != "avg" {
|
||||
t.Fatalf("stats = %#v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableStatsRejectsInvalidInputsBeforeCallingMCP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "records stats require uppercase",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`},
|
||||
wantErr: "大写",
|
||||
},
|
||||
{
|
||||
name: "records stats require at least one item",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[]`},
|
||||
wantErr: "至少需要一个统计项",
|
||||
},
|
||||
{
|
||||
name: "records stats enforce item cap",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[
|
||||
{"fieldId":"f01","statsType":"COUNT"},{"fieldId":"f02","statsType":"COUNT"},
|
||||
{"fieldId":"f03","statsType":"COUNT"},{"fieldId":"f04","statsType":"COUNT"},
|
||||
{"fieldId":"f05","statsType":"COUNT"},{"fieldId":"f06","statsType":"COUNT"},
|
||||
{"fieldId":"f07","statsType":"COUNT"},{"fieldId":"f08","statsType":"COUNT"},
|
||||
{"fieldId":"f09","statsType":"COUNT"},{"fieldId":"f10","statsType":"COUNT"},
|
||||
{"fieldId":"f11","statsType":"COUNT"},{"fieldId":"f12","statsType":"COUNT"},
|
||||
{"fieldId":"f13","statsType":"COUNT"},{"fieldId":"f14","statsType":"COUNT"},
|
||||
{"fieldId":"f15","statsType":"COUNT"},{"fieldId":"f16","statsType":"COUNT"},
|
||||
{"fieldId":"f17","statsType":"COUNT"},{"fieldId":"f18","statsType":"COUNT"},
|
||||
{"fieldId":"f19","statsType":"COUNT"},{"fieldId":"f20","statsType":"COUNT"},
|
||||
{"fieldId":"f21","statsType":"COUNT"}
|
||||
]`},
|
||||
wantErr: "单次最多支持 20 个统计项",
|
||||
},
|
||||
{
|
||||
name: "records stats require item fields",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":" ","statsType":"COUNT"}]`},
|
||||
wantErr: "均不能为空",
|
||||
},
|
||||
{
|
||||
name: "records stats reject unknown item fields",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT","unknownKey":123}]`},
|
||||
wantErr: "不支持的字段",
|
||||
},
|
||||
{
|
||||
name: "records stats reject duplicate field",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"},{"fieldId":"f","statsType":"SUM"}]`},
|
||||
wantErr: "重复",
|
||||
},
|
||||
{
|
||||
name: "group stats require lowercase",
|
||||
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`},
|
||||
wantErr: "小写",
|
||||
},
|
||||
{
|
||||
name: "group must be array DSL",
|
||||
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--group", `{}`},
|
||||
wantErr: "JSON 数组",
|
||||
},
|
||||
{
|
||||
name: "group limit capped",
|
||||
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--limit", "1001"},
|
||||
wantErr: "[1, 1000]",
|
||||
},
|
||||
{
|
||||
name: "filters require logical root",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{"operator":"eq","operands":[]}`},
|
||||
wantErr: `root "operator" must be "and" or "or"`,
|
||||
},
|
||||
{
|
||||
name: "records filters reject malformed JSON",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{`},
|
||||
wantErr: "必须是 JSON 对象",
|
||||
},
|
||||
{
|
||||
name: "records filters reject null",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `null`},
|
||||
wantErr: "不能是 null",
|
||||
},
|
||||
{
|
||||
name: "records filters reject a second JSON value",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{} {}`},
|
||||
wantErr: "只能包含一个 JSON 对象",
|
||||
},
|
||||
{
|
||||
name: "records filters reject invalid trailing content",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{} trailing`},
|
||||
wantErr: "无效的尾随内容",
|
||||
},
|
||||
{
|
||||
name: "records filters require operand array",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{"operator":"and","operands":{}}`},
|
||||
wantErr: `"operands" must be an array`,
|
||||
},
|
||||
{
|
||||
name: "records filters reject silently unsupported operators",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{"operator":"and","operands":[{"operator":"date_between","operands":["fldDate",[1,2]]}]}`},
|
||||
wantErr: `unsupported filter operator "date_between"`,
|
||||
},
|
||||
{
|
||||
name: "records sort requires an item",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--sort", `[]`},
|
||||
wantErr: "至少需要一个条目",
|
||||
},
|
||||
{
|
||||
name: "records sort items must be objects",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--sort", `[1]`},
|
||||
wantErr: "必须是 JSON 对象",
|
||||
},
|
||||
{
|
||||
name: "records limit must be positive",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--limit", "0"},
|
||||
wantErr: "必须大于 0",
|
||||
},
|
||||
{
|
||||
name: "group filters reject malformed JSON",
|
||||
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--filters", `{`},
|
||||
wantErr: "必须是 JSON 对象",
|
||||
},
|
||||
{
|
||||
name: "group filters require logical root",
|
||||
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--filters", `{"operator":"eq","operands":[]}`},
|
||||
wantErr: `root "operator" must be "and" or "or"`,
|
||||
},
|
||||
{
|
||||
name: "group filters reject silently unsupported operators",
|
||||
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--filters", `{"operator":"and","operands":[{"operator":"date_between","operands":["fldDate",[1,2]]}]}`},
|
||||
wantErr: `unsupported filter operator "date_between"`,
|
||||
},
|
||||
{
|
||||
name: "group sort rejects invalid DSL",
|
||||
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--sort", `{}`},
|
||||
wantErr: "JSON 数组",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller, err := runAitableStatsCLI(t, test.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, test.wantErr)
|
||||
}
|
||||
var structured *apperrors.Error
|
||||
if !errors.As(err, &structured) || structured.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("error = %#v, want structured validation error", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("tool calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableStatsPublishInterfaceAndResultContracts(t *testing.T) {
|
||||
root := newAitableCommand()
|
||||
tests := []struct {
|
||||
path string
|
||||
rpc string
|
||||
}{
|
||||
{path: "aitable record stats", rpc: "query_records_stats"},
|
||||
{path: "aitable record group-stats", rpc: "query_stats"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
leaf := findCLIPath(root, test.path)
|
||||
if leaf == nil {
|
||||
t.Fatalf("missing leaf %q", test.path)
|
||||
}
|
||||
final, ok := contractfinal.RuntimeContractFinal(leaf)
|
||||
if !ok || final.Interface == nil || final.Interface.Ref == nil || final.Interface.Ref.RPCName != test.rpc {
|
||||
t.Fatalf("%s interface = %#v", test.path, final.Interface)
|
||||
}
|
||||
if final.Result == nil || len(final.Result.Outcomes) == 0 || len(final.Result.DataSchema) == 0 {
|
||||
t.Fatalf("%s result = %#v", test.path, final.Result)
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
-59
@@ -19,15 +19,29 @@ import (
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
"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"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
// promoteLegacyChatString copies an explicitly supplied legacy flag into the
|
||||
// new canonical flag. Cobra validates MarkFlagRequired after PreRunE, and the
|
||||
// overwrite also preserves the migration rule that a legacy value wins when
|
||||
// both spellings are present.
|
||||
func promoteLegacyChatString(cmd *cobra.Command, canonical, legacy string) error {
|
||||
if !cmd.Flags().Changed(legacy) {
|
||||
return nil
|
||||
}
|
||||
value, err := cmd.Flags().GetString(legacy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cmd.Flags().Set(canonical, value)
|
||||
}
|
||||
|
||||
func callProjectedChatMessages(cmd *cobra.Command, toolName string, args map[string]any, search bool) error {
|
||||
if deps.Caller.DryRun() {
|
||||
return callMCPToolOnServer("chat", toolName, args)
|
||||
@@ -3144,7 +3158,7 @@ func newChatCommand() *cobra.Command {
|
||||
--open-dingtalk-id 单聊接收人 openDingTalkId
|
||||
|
||||
纯文本 / Markdown 消息(默认):
|
||||
无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。
|
||||
无需指定 --msg-type,直接传消息内容即可。推荐使用 --content flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。
|
||||
图文混排时,公网图片 URL 需要写成 Markdown 图片语法:,才会以内联图片展示。
|
||||
如果省略开头的 !,例如 [图片标题](https://example.com/image.png),将按链接/URL 展示,不会渲染为图片。
|
||||
|
||||
@@ -3154,24 +3168,24 @@ func newChatCommand() *cobra.Command {
|
||||
获取 openMessageId 和 openConversationId,无需再按消息内容反查。
|
||||
|
||||
本地图片 / 文件消息:
|
||||
统一使用 --msg-type file --file-path <本地路径>。CLI 会完成上传并按 file 消息发送;
|
||||
统一使用 --msg-type file --file <本地路径>。CLI 会完成上传并按 file 消息发送;
|
||||
.png/.jpg 也会显示为可下载的文件附件,不会生成 mediaId 或渲染成内联图片。
|
||||
|
||||
旧版内联图片消息:
|
||||
仅当上游已经提供有效 mediaId 时,使用 --msg-type image --media-id。
|
||||
当前 CLI 不提供本地文件到 mediaId 的上传能力。`,
|
||||
Example: ` dws chat message send --conversation-id <openconversation_id> "hello"
|
||||
dws chat message send --user <userId> "请查收"
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> "请查收"
|
||||
dws chat message send --conversation-id <openconversation_id> --title "周报提醒" "请大家本周五前提交周报"
|
||||
Example: ` dws chat message send --conversation-id <openconversation_id> --content "hello"
|
||||
dws chat message send --user <userId> --content "请查收"
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --content "请查收"
|
||||
dws chat message send --conversation-id <openconversation_id> --title "周报提醒" --content "请大家本周五前提交周报"
|
||||
# 图文混排 Markdown:公网图片 URL 需要写成  才会以内联图片展示
|
||||
dws chat message send --conversation-id <openconversation_id> --text $'这是图文说明\n\n'
|
||||
dws chat message send --conversation-id <openconversation_id> --content $'这是图文说明\n\n'
|
||||
# 发送本地图片或文件(图片会作为可下载的 file 附件发送)
|
||||
dws chat message send --conversation-id <openconversation_id> --msg-type file --file-path ./screenshot.png
|
||||
dws chat message send --conversation-id <openconversation_id> --msg-type file --file-path ./report.pdf
|
||||
dws chat message send --conversation-id <openconversation_id> --msg-type file --file ./screenshot.png
|
||||
dws chat message send --conversation-id <openconversation_id> --msg-type file --file ./report.pdf
|
||||
# 发送本地音频/视频(audio/video 是 file 的语义别名)
|
||||
dws chat message send --conversation-id <openconversation_id> --msg-type audio --file-path ./recording.mp3
|
||||
dws chat message send --conversation-id <openconversation_id> --msg-type video --file-path ./demo.mp4
|
||||
dws chat message send --conversation-id <openconversation_id> --msg-type audio --file ./recording.mp3
|
||||
dws chat message send --conversation-id <openconversation_id> --msg-type video --file ./demo.mp4
|
||||
# 旧版内联图片:仅当上游已经持有有效 mediaId 时使用
|
||||
dws chat message send --conversation-id <openconversation_id> --msg-type image --media-id <mediaId>
|
||||
# 查询群 ID: dws chat search --query "群名"
|
||||
@@ -3250,7 +3264,7 @@ func newChatCommand() *cobra.Command {
|
||||
contentJSON = fmt.Sprintf(`{"mediaId":"%s"}`, mediaId)
|
||||
case "file", "audio", "video":
|
||||
serviceMsgType = "file"
|
||||
filePath, _ := cmd.Flags().GetString("file-path")
|
||||
filePath := flagOrFallback(cmd, "file-path", "file")
|
||||
dentryId, _ := cmd.Flags().GetInt64("dentry-id")
|
||||
spaceId, _ := cmd.Flags().GetInt64("space-id")
|
||||
if (dentryId == 0) != (spaceId == 0) {
|
||||
@@ -3281,7 +3295,7 @@ func newChatCommand() *cobra.Command {
|
||||
}
|
||||
contentJSON, _ = buildConversationFileContent(dentryId, spaceId, meta)
|
||||
} else if dentryId == 0 || spaceId == 0 {
|
||||
return fmt.Errorf("--file-path must be a readable local file, or pass legacy --dentry-id and --space-id: %w", err)
|
||||
return fmt.Errorf("--file must be a readable local file, or pass legacy --dentry-id and --space-id: %w", err)
|
||||
}
|
||||
}
|
||||
if contentJSON == "" {
|
||||
@@ -3289,7 +3303,7 @@ func newChatCommand() *cobra.Command {
|
||||
fileType, _ := cmd.Flags().GetString("file-type")
|
||||
fileSize, _ := cmd.Flags().GetInt64("file-size")
|
||||
if dentryId == 0 || spaceId == 0 || fileName == "" {
|
||||
return fmt.Errorf("readable local --file-path is required for msgType=file; legacy flags --dentry-id, --space-id, --file-name are still supported")
|
||||
return fmt.Errorf("readable local --file is required for msgType=file; legacy flags --dentry-id, --space-id, --file-name are still supported")
|
||||
}
|
||||
contentJSON = fmt.Sprintf(`{"dentryId":%d,"spaceId":%d,"fileName":"%s","fileType":"%s","filePath":"%s","fileSize":%d}`,
|
||||
dentryId, spaceId, fileName, fileType, filePath, fileSize)
|
||||
@@ -3335,7 +3349,7 @@ func newChatCommand() *cobra.Command {
|
||||
text = args[0]
|
||||
}
|
||||
if text == "" {
|
||||
return fmt.Errorf("message content required (use --text or positional arg, or --media-id for image)")
|
||||
return fmt.Errorf("message content required (use --content or positional arg, or --media-id for image)")
|
||||
}
|
||||
title, _ := cmd.Flags().GetString("title")
|
||||
if title == "" {
|
||||
@@ -3395,7 +3409,7 @@ func newChatCommand() *cobra.Command {
|
||||
AgentSummary: "以当前用户身份发送群聊或单聊消息",
|
||||
UseWhen: []string{"用户明确要以个人身份发送文本或媒体消息时;响应返回 openTaskId 后用 chat message query-send-status 确认投递并取得后续操作所需的消息 ID"},
|
||||
AvoidWhen: []string{"机器人身份或 Webhook 发送应使用对应命令"},
|
||||
Examples: []string{"dws chat message send --group <openConversationId> \"项目已更新\""},
|
||||
Examples: []string{"dws chat message send --group <openConversationId> --content \"项目已更新\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "ai-tag", Property: "clawType", InterfaceType: "string"},
|
||||
@@ -3716,19 +3730,25 @@ func newChatCommand() *cobra.Command {
|
||||
chatMessageSendByWebhookCmd := &cobra.Command{
|
||||
Use: "send-by-webhook",
|
||||
Short: "自定义机器人 Webhook 发送群消息",
|
||||
Long: `通过自定义机器人 Webhook 发送群消息。@ 人时需在 --text 中包含 @userId 或 @手机号,否则 @ 不生效。
|
||||
Long: `通过自定义机器人 Webhook 发送群消息。@ 人时需在 --content 中包含 @userId 或 @手机号,否则 @ 不生效。
|
||||
|
||||
⚠️ 重要:该接口会真实发送消息到目标群聊,不可用于测试或试探性调用。调用前必须确认消息内容无误。`,
|
||||
Example: ` dws chat message send-by-webhook --token <webhook-token> --title "告警" --text "CPU 超 90%" --at-all
|
||||
dws chat message send-by-webhook --token <webhook-token> --title "test" --text "hi @118785" --at-users 118785`,
|
||||
Example: ` dws chat message send-by-webhook --token <webhook-token> --title "告警" --content "CPU 超 90%" --at-all
|
||||
dws chat message send-by-webhook --token <webhook-token> --title "test" --content "hi @118785" --at-users 118785`,
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return promoteLegacyChatString(cmd, "content", "text")
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "token", "title", "text"); err != nil {
|
||||
if err := promoteLegacyChatString(cmd, "content", "text"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "token", "title", "content"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"robotToken": mustGetFlag(cmd, "token"),
|
||||
"title": mustGetFlag(cmd, "title"),
|
||||
"text": mustGetFlag(cmd, "text"),
|
||||
"text": mustGetFlag(cmd, "content"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("at-all"); v {
|
||||
toolArgs["isAtAll"] = true
|
||||
@@ -3803,11 +3823,12 @@ func newChatCommand() *cobra.Command {
|
||||
"使用企业机器人 robot-code 发消息时用 chat message send-by-bot",
|
||||
"以个人身份发消息时用 chat message send",
|
||||
},
|
||||
Examples: []string{"dws chat message send-by-webhook --token <webhook-token> --title \"告警\" --text \"CPU 超 90%\" --at-all"},
|
||||
Examples: []string{"dws chat message send-by-webhook --token <webhook-token> --title \"告警\" --content \"CPU 超 90%\" --at-all"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "at-all", Property: "isAtAll"},
|
||||
{Name: "at-users", Property: "atUserIds"},
|
||||
{Name: "content", Property: "text"},
|
||||
{Name: "token", Property: "robotToken"},
|
||||
},
|
||||
},
|
||||
@@ -4356,7 +4377,7 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
|
||||
Example: ` dws chat message recall --conversation-id <openConversationId> --message-id <openMessageId>
|
||||
|
||||
# 发送后撤回:send -> query-send-status -> recall
|
||||
dws chat message send --conversation-id <openConversationId> --text "待撤回的内容"
|
||||
dws chat message send --conversation-id <openConversationId> --content "待撤回的内容"
|
||||
dws chat message query-send-status --open-task-id <上一步返回的openTaskId>
|
||||
dws chat message recall --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -4417,7 +4438,7 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
|
||||
Example: ` dws chat message edit --conversation-id <openConversationId> --message-id <openMessageId> --text "更新后的内容"
|
||||
|
||||
# 发送后编辑:send -> query-send-status -> edit
|
||||
dws chat message send --conversation-id <openConversationId> --text "原始内容"
|
||||
dws chat message send --conversation-id <openConversationId> --content "原始内容"
|
||||
dws chat message query-send-status --open-task-id <上一步返回的openTaskId>
|
||||
dws chat message edit --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId> --text "更新后的内容"
|
||||
|
||||
@@ -4753,21 +4774,19 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
|
||||
chatMessageSendCmd.Flags().String("user", "", "单聊接收人 userId(单聊时与 --open-dingtalk-id 二选一)")
|
||||
chatMessageSendCmd.Flags().String("open-dingtalk-id", "", "单聊接收人 openDingTalkId(单聊时与 --user 二选一)")
|
||||
chatMessageSendCmd.Flags().String("title", "", "消息标题,显示在消息列表(可选,未指定时使用消息内容)")
|
||||
// 别名注册: --text/--content/--body/--message/--markdown → 位置参数
|
||||
chatMessageSendCmd.Flags().String("text", "", "消息内容(推荐方式,也可用位置参数传递。内容含换行/特殊字符时必须使用此 flag)")
|
||||
chatMessageSendCmd.Flags().String("content", "", "--text 的别名")
|
||||
chatMessageSendCmd.Flags().String("body", "", "--text 的别名")
|
||||
chatMessageSendCmd.Flags().String("message", "", "--text 的别名")
|
||||
chatMessageSendCmd.Flags().String("markdown", "", "--text 的别名")
|
||||
// --text 不再隐藏,作为首选传参方式展示在 help 中
|
||||
_ = chatMessageSendCmd.Flags().MarkHidden("content")
|
||||
_ = chatMessageSendCmd.Flags().MarkHidden("body")
|
||||
_ = chatMessageSendCmd.Flags().MarkHidden("message")
|
||||
_ = chatMessageSendCmd.Flags().MarkHidden("markdown")
|
||||
corecmd.RegisterFlags(chatMessageSendCmd, []corecmd.FlagSpec{{
|
||||
Name: "content",
|
||||
Usage: "消息内容(推荐方式,也可用位置参数传递。内容含换行/特殊字符时必须使用此 flag)",
|
||||
Aliases: []string{"text"},
|
||||
}})
|
||||
for _, alias := range []string{"body", "message", "markdown"} {
|
||||
chatMessageSendCmd.Flags().String(alias, "", "--content 的兼容别名")
|
||||
_ = chatMessageSendCmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
chatMessageSendCmd.Flags().Bool("at-all", false, "@所有人(仅群聊时生效,可选),设置时,消息内容中一定要包含对应的占位符<@all>")
|
||||
chatMessageSendCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingTalkId 列表,逗号分隔(仅群聊时生效,可选),设置--at-open-dingtalk-ids openDingTalkId1,openDingTalkId2时,消息内容中一定要包含对应格式的占位符<@openDingTalkId1> <@openDingTalkId2>")
|
||||
chatMessageSendCmd.Flags().String("media-id", "", "上游已提供的图片 mediaId(仅旧版 msgType=image;CLI 不提供本地上传到 mediaId)")
|
||||
chatMessageSendCmd.Flags().String("msg-type", "", "富媒体消息类型: image/file/audio/video/location/profile(本地图片/文件推荐 file --file-path;image 仅接受已有 mediaId)")
|
||||
chatMessageSendCmd.Flags().String("msg-type", "", "富媒体消息类型: image/file/audio/video/location/profile(本地图片/文件推荐 file --file;image 仅接受已有 mediaId)")
|
||||
chatMessageSendCmd.Flags().String("latitude", "", "位置消息纬度(msgType=location 时必填)")
|
||||
chatMessageSendCmd.Flags().String("longitude", "", "位置消息经度(msgType=location 时必填)")
|
||||
chatMessageSendCmd.Flags().String("location-name", "", "位置消息地址名称(msgType=location 时必填)")
|
||||
@@ -4777,7 +4796,11 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
|
||||
chatMessageSendCmd.Flags().Int64("space-id", 0, "空间 ID(与 --dentry-id 成对传入时跳过自动上传)")
|
||||
chatMessageSendCmd.Flags().String("file-name", "", "文件名")
|
||||
chatMessageSendCmd.Flags().String("file-type", "", "文件类型/扩展名")
|
||||
chatMessageSendCmd.Flags().String("file-path", "", "本地文件路径(msgType=file/audio/video 时直接上传并按 file 消息发送)")
|
||||
corecmd.RegisterFlags(chatMessageSendCmd, []corecmd.FlagSpec{{
|
||||
Name: "file",
|
||||
Usage: "本地文件路径(msgType=file/audio/video 时直接上传并按 file 消息发送)",
|
||||
Aliases: []string{"file-path"},
|
||||
}})
|
||||
chatMessageSendCmd.Flags().Int64("file-size", 0, "文件大小,单位字节")
|
||||
_ = chatMessageSendCmd.Flags().MarkHidden("dentry-id")
|
||||
_ = chatMessageSendCmd.Flags().MarkHidden("space-id")
|
||||
@@ -4798,12 +4821,12 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
|
||||
cli.AnnotateRuntimePositionals(chatMessageSendCmd, contract.RuntimeSchemaPositional{
|
||||
Name: "content",
|
||||
Type: "string",
|
||||
Description: "消息内容(也可使用 --text;富媒体消息可省略)",
|
||||
Description: "消息内容(也可使用 --content;富媒体消息可省略)",
|
||||
Required: false,
|
||||
Index: 0,
|
||||
})
|
||||
cli.AnnotateRuntimeFlagEnum(chatMessageSendCmd, "msg-type", "image", "file", "audio", "video")
|
||||
cli.AnnotateRuntimeFlagFormat(chatMessageSendCmd, "file-path", "file-path")
|
||||
cli.AnnotateRuntimeFlagFormat(chatMessageSendCmd, "file", "file-path")
|
||||
|
||||
chatMessageSendByBotCmd.Flags().String("robot-code", "", "机器人 Code (必填)")
|
||||
_ = chatMessageSendByBotCmd.MarkFlagRequired("robot-code")
|
||||
@@ -4838,8 +4861,12 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
|
||||
_ = chatMessageSendByWebhookCmd.MarkFlagRequired("token")
|
||||
chatMessageSendByWebhookCmd.Flags().String("title", "", "消息标题 (必填)")
|
||||
_ = chatMessageSendByWebhookCmd.MarkFlagRequired("title")
|
||||
chatMessageSendByWebhookCmd.Flags().String("text", "", "消息内容 (必填)")
|
||||
_ = chatMessageSendByWebhookCmd.MarkFlagRequired("text")
|
||||
corecmd.RegisterFlags(chatMessageSendByWebhookCmd, []corecmd.FlagSpec{{
|
||||
Name: "content",
|
||||
Usage: "消息内容 (必填)",
|
||||
Aliases: []string{"text"},
|
||||
}})
|
||||
_ = chatMessageSendByWebhookCmd.MarkFlagRequired("content")
|
||||
chatMessageSendByWebhookCmd.Flags().Bool("at-all", false, "@ 所有人")
|
||||
chatMessageSendByWebhookCmd.Flags().String("at-mobiles", "", "@ 指定手机号,逗号分隔")
|
||||
chatMessageSendByWebhookCmd.Flags().String("at-users", "", "@ 指定用户,逗号分隔")
|
||||
@@ -5049,7 +5076,7 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
|
||||
Use: "conversation-info",
|
||||
Short: "获取会话基础信息",
|
||||
Long: `获取指定会话的基础信息。
|
||||
发送本地文件消息请优先使用 dws chat message send --msg-type file --file-path <本地文件>,CLI 不再要求调用方获取或传递 spaceId。`,
|
||||
发送本地文件消息请优先使用 dws chat message send --msg-type file --file <本地文件>,CLI 不再要求调用方获取或传递 spaceId。`,
|
||||
Example: ` dws chat conversation-info --conversation-id <openConversationId> --format json
|
||||
dws chat conversation-info --user <userId> --format json
|
||||
dws chat conversation-info --open-dingtalk-id <openDingTalkId> --format json`,
|
||||
@@ -5164,11 +5191,11 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
|
||||
Hidden: true,
|
||||
Long: `chat file upload 已下线,不再调用 chat/upload_conversation_file_by_url。
|
||||
|
||||
发送本地文件消息请改用 chat message send --msg-type file --file-path;该路径仍然可用,CLI 内部会完成本地文件上传和消息发送。`,
|
||||
Example: ` dws chat message send --conversation-id <openConversationId> --msg-type file --file-path ./report.pdf --format json
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file-path ./report.pdf --format json`,
|
||||
发送本地文件消息请改用 chat message send --msg-type file --file;该路径仍然可用,CLI 内部会完成本地文件上传和消息发送。`,
|
||||
Example: ` dws chat message send --conversation-id <openConversationId> --msg-type file --file ./report.pdf --format json
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file ./report.pdf --format json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("chat file upload 已下线;chat/upload_conversation_file_by_url 当前不可用。发送本地文件请改用: dws chat message send --msg-type file --file-path <本地路径>")
|
||||
return fmt.Errorf("chat file upload 已下线;chat/upload_conversation_file_by_url 当前不可用。发送本地文件请改用: dws chat message send --msg-type file --file <本地路径>")
|
||||
},
|
||||
}
|
||||
chatFileUploadCmd.Flags().String("conversation-id", "", "群聊 openConversationId(群聊时使用)")
|
||||
@@ -6934,10 +6961,22 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
- 群聊: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 "收到,马上处理"
|
||||
dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "请看一下" --at-open-dingtalk-ids <mentionedOpenDingTalkId>`,
|
||||
Example: ` dws chat message reply --group <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --content "收到,马上处理"
|
||||
dws chat message reply --group <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --content "请看一下" --at-open-dingtalk-ids <mentionedOpenDingTalkId>`,
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := promoteLegacyChatString(cmd, "group", "conversation-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
return promoteLegacyChatString(cmd, "content", "text")
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "conversation-id", "ref-msg-id", "ref-sender", "text"); err != nil {
|
||||
if err := promoteLegacyChatString(cmd, "group", "conversation-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := promoteLegacyChatString(cmd, "content", "text"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "group", "ref-msg-id", "ref-sender", "content"); err != nil {
|
||||
return err
|
||||
}
|
||||
refSender := mustGetFlag(cmd, "ref-sender")
|
||||
@@ -6954,7 +6993,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
clawType = edition.ClawType()
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"openConversationId": mustGetFlag(cmd, "conversation-id"),
|
||||
"openConversationId": mustGetFlag(cmd, "group"),
|
||||
"msgType": "reply",
|
||||
"clawType": clawType,
|
||||
}
|
||||
@@ -6962,7 +7001,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
atOpenIDs := mustGetFlag(cmd, "at-open-dingtalk-ids")
|
||||
replyText := applyCurrentUserGroupMentions(
|
||||
toolArgs,
|
||||
mustGetFlag(cmd, "text"),
|
||||
mustGetFlag(cmd, "content"),
|
||||
atOpenIDs,
|
||||
atAll,
|
||||
)
|
||||
@@ -7004,24 +7043,32 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
AgentSummary: "引用指定消息发送个人回复",
|
||||
UseWhen: []string{"用户要针对某条已有消息进行引用回复时"},
|
||||
AvoidWhen: []string{"无需引用上下文的普通消息使用 chat message send"},
|
||||
Examples: []string{"dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text \"收到\""},
|
||||
Examples: []string{"dws chat message reply --group <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --content \"收到\""},
|
||||
},
|
||||
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"},
|
||||
{Name: "group", Property: "openConversationId"},
|
||||
},
|
||||
},
|
||||
})
|
||||
chatMessageReplyCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持单聊/群聊)")
|
||||
_ = chatMessageReplyCmd.MarkFlagRequired("conversation-id")
|
||||
corecmd.RegisterFlags(chatMessageReplyCmd, []corecmd.FlagSpec{{
|
||||
Name: "group",
|
||||
Usage: "会话 openConversationId (必填,支持单聊/群聊)",
|
||||
Aliases: []string{"conversation-id"},
|
||||
}})
|
||||
_ = chatMessageReplyCmd.MarkFlagRequired("group")
|
||||
chatMessageReplyCmd.Flags().String("ref-msg-id", "", "被引用的消息 openMessageId (必填)")
|
||||
_ = chatMessageReplyCmd.MarkFlagRequired("ref-msg-id")
|
||||
chatMessageReplyCmd.Flags().String("ref-sender", "", "被引用消息的发送者 openDingTalkId (必填)")
|
||||
_ = chatMessageReplyCmd.MarkFlagRequired("ref-sender")
|
||||
chatMessageReplyCmd.Flags().String("text", "", "回复内容 (必填)")
|
||||
_ = chatMessageReplyCmd.MarkFlagRequired("text")
|
||||
corecmd.RegisterFlags(chatMessageReplyCmd, []corecmd.FlagSpec{{
|
||||
Name: "content",
|
||||
Usage: "回复内容 (必填)",
|
||||
Aliases: []string{"text"},
|
||||
}})
|
||||
_ = chatMessageReplyCmd.MarkFlagRequired("content")
|
||||
chatMessageReplyCmd.Flags().String("uuid", "", "幂等键(可选)")
|
||||
chatMessageReplyCmd.Flags().Bool("ai-tag", true, "消息是否带 AI 发送角标(默认 true)")
|
||||
chatMessageReplyCmd.Flags().Bool("at-all", false, "@所有人(仅群聊时生效;正文缺少 <@all> 时自动补齐)")
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const chatMediaUploadReplacement = "dws chat message send --msg-type file --file-path <本地路径>"
|
||||
const chatMediaUploadReplacement = "dws chat message send --msg-type file --file <本地路径>"
|
||||
|
||||
func newChatMediaGroup() *cobra.Command {
|
||||
media := &cobra.Command{
|
||||
@@ -43,11 +43,11 @@ func newChatMediaUploadCommand() *cobra.Command {
|
||||
Deprecated: "请改用 " + chatMediaUploadReplacement,
|
||||
Long: `此命令仅为 1.x 命令行兼容保留,不再读取应用凭证或调用旧版媒体上传接口。
|
||||
|
||||
发送本地图片或文件时,请使用 chat message send --msg-type file --file-path。
|
||||
发送本地图片或文件时,请使用 chat message send --msg-type file --file。
|
||||
该路径会把图片作为可下载的 file 消息发送,不会生成 mediaId,也不会渲染成内联 image 消息。
|
||||
如果上游已经提供 mediaId,仍可使用 chat message send --msg-type image --media-id。`,
|
||||
Example: " dws chat message send --group <openConversationId> --msg-type file --file-path ./screenshot.png\n" +
|
||||
" dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file-path ./report.pdf",
|
||||
Example: " dws chat message send --conversation-id <openConversationId> --msg-type file --file ./screenshot.png\n" +
|
||||
" dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file ./report.pdf",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(*cobra.Command, []string) error {
|
||||
@@ -56,7 +56,7 @@ func newChatMediaUploadCommand() *cobra.Command {
|
||||
}
|
||||
// Keep the historical flags so existing argv remains parseable while the
|
||||
// 1.x compatibility command returns an actionable migration error.
|
||||
cmd.Flags().String("file", "", "旧版兼容参数;本地文件请改用 chat message send --file-path")
|
||||
cmd.Flags().String("file", "", "旧版兼容参数;本地文件请改用 chat message send --file")
|
||||
cmd.Flags().String("type", "image", "旧版兼容参数;不再执行媒体上传")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestCrossPlatformCoverageChatMediaUploadIsDeprecatedCompatibilityStub(t *te
|
||||
if err == nil {
|
||||
t.Fatal("deprecated media upload returned nil error")
|
||||
}
|
||||
for _, want := range []string{"已下线", "chat message send", "--msg-type file", "--file-path", "--media-id"} {
|
||||
for _, want := range []string{"已下线", "chat message send", "--msg-type file", "--file", "--media-id"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("media upload migration error missing %q: %v", want, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const commentServer = "doc-comment"
|
||||
|
||||
var commentBatchResultSchema = json.RawMessage(`{
|
||||
"type":"object",
|
||||
"description":"按请求顺序返回的评论详情",
|
||||
"properties":{
|
||||
"commentList":{
|
||||
"type":"array",
|
||||
"description":"与请求 comments 顺序一致的评论详情列表",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"description":"单条评论查询结果;不存在时 found=false",
|
||||
"properties":{
|
||||
"topicId":{"type":"string","description":"评论主题 ID"},
|
||||
"commentKey":{"type":"string","description":"评论唯一标识"},
|
||||
"found":{"type":"boolean","description":"该 topicId/commentKey 是否存在"},
|
||||
"content":{"type":["string","null"],"description":"纯文本评论内容"},
|
||||
"quote":{"type":["string","null"],"description":"划词评论引用内容"},
|
||||
"creatorId":{"type":["string","null"],"description":"创建者用户 ID"},
|
||||
"createTime":{"type":["integer","null"],"description":"创建时间,毫秒时间戳"},
|
||||
"updateTime":{"type":["integer","null"],"description":"更新时间,毫秒时间戳"},
|
||||
"isSolved":{"type":["boolean","null"],"description":"是否已解决"},
|
||||
"isEmoji":{"type":["boolean","null"],"description":"是否为表情回复"},
|
||||
"replyCommentKey":{"type":["string","null"],"description":"被回复评论的标识"}
|
||||
},
|
||||
"required":["topicId","commentKey","found"],
|
||||
"additionalProperties":true
|
||||
}
|
||||
}
|
||||
},
|
||||
"required":["commentList"],
|
||||
"additionalProperties":true
|
||||
}`)
|
||||
|
||||
var commentStatusResultSchema = json.RawMessage(`{
|
||||
"type":"object",
|
||||
"description":"评论解决状态更新结果",
|
||||
"properties":{
|
||||
"commentKey":{"type":"string","description":"评论唯一标识"},
|
||||
"resolved":{"type":"boolean","description":"更新后的解决状态"},
|
||||
"message":{"type":"string","description":"操作结果消息"}
|
||||
},
|
||||
"required":["commentKey","resolved"],
|
||||
"additionalProperties":true
|
||||
}`)
|
||||
|
||||
var commentReactionResultSchema = json.RawMessage(`{
|
||||
"type":"object",
|
||||
"description":"表情回复创建结果",
|
||||
"properties":{
|
||||
"commentKey":{"type":"string","description":"新建表情回复的评论标识"},
|
||||
"message":{"type":"string","description":"操作结果消息"}
|
||||
},
|
||||
"required":["commentKey"],
|
||||
"additionalProperties":true
|
||||
}`)
|
||||
|
||||
// newCommentBaseCommands returns the base comment commands shared by Doc and Sheet.
|
||||
// Both resource domains call the same comment MCP tools; only CLI identity and
|
||||
// guidance differ.
|
||||
func newCommentBaseCommands(surface string) []*cobra.Command {
|
||||
batchCmd := newCommentBatchQueryCommand(surface)
|
||||
resolveCmd := newCommentStatusCommand(surface, true)
|
||||
restoreCmd := newCommentStatusCommand(surface, false)
|
||||
reactCmd := newCommentReactionCommand(surface)
|
||||
commands := []*cobra.Command{batchCmd, resolveCmd, restoreCmd, reactCmd}
|
||||
for _, cmd := range commands {
|
||||
addCommentNodeAliases(cmd)
|
||||
}
|
||||
return commands
|
||||
}
|
||||
|
||||
func newCommentBatchQueryCommand(surface string) *cobra.Command {
|
||||
resourceName := commentResourceName(surface)
|
||||
cmd := &cobra.Command{
|
||||
Use: "batch-query",
|
||||
Aliases: []string{"batch-query-comments", "batch"},
|
||||
Short: "按 topicId + commentKey 批量查询评论详情",
|
||||
Long: `批量查询同一文档或表格中的评论详情,单次最多 100 条。
|
||||
|
||||
--comment-ref 可重复传入,格式为 topicId:commentKey。topicId 和 commentKey
|
||||
均可从 comment list 的返回结果中获得。结果严格保持输入顺序;不存在的评论
|
||||
会返回 found=false 的占位项。`,
|
||||
Example: fmt.Sprintf(" dws %s comment batch-query --node <NODE_ID> --comment-ref global:<COMMENT_KEY> --comment-ref <TOPIC_ID>:<COMMENT_KEY> --format json", surface),
|
||||
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
|
||||
}
|
||||
rawRefs, _ := cmd.Flags().GetStringSlice("comment-ref")
|
||||
refs, err := parseCommentRefs(rawRefs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer(commentServer, "batch_query_comments", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"comments": refs,
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: commentIdentity(surface, "batch_query_comments", "batch-query"),
|
||||
Description: "按 topicId + commentKey 批量查询评论详情,保持输入顺序并显式标记缺失项",
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: commentBatchResultSchema,
|
||||
},
|
||||
Interface: commentInterface("batch_query_comments"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "已知多组 topicId/commentKey 时,一次获取完整评论详情",
|
||||
UseWhen: []string{fmt.Sprintf("需要回查%s comment list 结果中的多条评论详情,或批量核对评论是否仍存在时", resourceName)},
|
||||
AvoidWhen: []string{fmt.Sprintf("尚未取得%s评论的 topicId/commentKey 时先使用 comment list", resourceName)},
|
||||
Examples: []string{fmt.Sprintf("dws %s comment batch-query --node <NODE_ID> --comment-ref <TOPIC_ID>:<COMMENT_KEY> --format json", surface)},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "comment-ref", Property: "comments", InterfaceType: "array"},
|
||||
},
|
||||
},
|
||||
})
|
||||
cmd.Flags().String("node", "", "文档或表格 ID / URL (必填)")
|
||||
cmd.Flags().StringSlice("comment-ref", nil, "评论引用 topicId:commentKey,可重复;最多 100 条 (必填)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCommentStatusCommand(surface string, resolve bool) *cobra.Command {
|
||||
resourceName := commentResourceName(surface)
|
||||
use := "restore"
|
||||
rpc := "restore_comment"
|
||||
short := "将已解决评论恢复为未解决"
|
||||
resolved := false
|
||||
if resolve {
|
||||
use = "resolve"
|
||||
rpc = "resolve_comment"
|
||||
short = "将评论标记为已解决"
|
||||
resolved = true
|
||||
}
|
||||
cmd := &cobra.Command{
|
||||
Use: use,
|
||||
Aliases: []string{use + "-comment"},
|
||||
Short: short,
|
||||
Example: fmt.Sprintf(" dws %s comment %s --node <NODE_ID> --comment-key <COMMENT_KEY> --format json", surface, use),
|
||||
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
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "comment-key"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer(commentServer, rpc, map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"commentKey": mustGetFlag(cmd, "comment-key"),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: commentIdentity(surface, rpc, use),
|
||||
Description: short,
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: commentStatusResultSchema,
|
||||
},
|
||||
Interface: commentInterface(rpc),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: short,
|
||||
UseWhen: []string{fmt.Sprintf("用户明确要求把%s中的某条评论标记为%s时", resourceName, map[bool]string{true: "已解决", false: "未解决"}[resolved])},
|
||||
AvoidWhen: []string{fmt.Sprintf("永久删除%s评论使用 comment delete;修改文字使用 comment update", resourceName)},
|
||||
Examples: []string{fmt.Sprintf("dws %s comment %s --node <NODE_ID> --comment-key <COMMENT_KEY> --format json", surface, use)},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "comment-key", Property: "commentKey"},
|
||||
},
|
||||
},
|
||||
})
|
||||
cmd.Flags().String("node", "", "文档或表格 ID / URL (必填)")
|
||||
cmd.Flags().String("comment-key", "", "目标评论的 commentKey (必填)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCommentReactionCommand(surface string) *cobra.Command {
|
||||
resourceName := commentResourceName(surface)
|
||||
cmd := &cobra.Command{
|
||||
Use: "react-reply",
|
||||
Short: "创建一条表情回复",
|
||||
Long: `创建表情回复的便捷命令。底层复用 reply_comment,并固定传入 emoji=true。
|
||||
|
||||
与 comment reply --emoji 一致,--reaction 必须填写钉钉表情名称,不要直接传
|
||||
Unicode Emoji。例如用户要求 😄 时传“憨笑”,要求 👏 时传“鼓掌”。
|
||||
|
||||
当前轻量版仅支持创建,不包含删除或聚合能力。`,
|
||||
Example: fmt.Sprintf(" dws %s comment react-reply --node <NODE_ID> --comment-key <COMMENT_KEY> --reaction \"憨笑\" --format json", surface),
|
||||
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
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "comment-key", "reaction"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer(commentServer, "reply_comment", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
|
||||
"content": mustGetFlag(cmd, "reaction"),
|
||||
"emoji": true,
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: commentIdentity(surface, "react_reply", "react-reply"),
|
||||
Description: "为指定评论创建表情回复(轻量版)",
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: commentReactionResultSchema,
|
||||
},
|
||||
Interface: commentInterface("reply_comment"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "使用钉钉表情名称为指定评论创建表情回复",
|
||||
UseWhen: []string{fmt.Sprintf("用户要用表情回应%s中的某条评论时;先将 Unicode Emoji 转为钉钉表情名称,例如 😄 转为憨笑、👏 转为鼓掌", resourceName)},
|
||||
AvoidWhen: []string{fmt.Sprintf("%s评论的普通文字回复使用 comment reply;当前轻量版不支持删除 reaction", resourceName)},
|
||||
Examples: []string{fmt.Sprintf("dws %s comment react-reply --node <NODE_ID> --comment-key <COMMENT_KEY> --reaction \"憨笑\" --format json", surface)},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "comment-key", Property: "replyCommentKey"},
|
||||
{Name: "reaction", Property: "content"},
|
||||
},
|
||||
},
|
||||
})
|
||||
cmd.Flags().String("node", "", "文档或表格 ID / URL (必填)")
|
||||
cmd.Flags().String("comment-key", "", "被回应评论的 commentKey (必填)")
|
||||
cmd.Flags().String("reaction", "", "钉钉表情名称,不是 Unicode Emoji;例如 😄=憨笑、👏=鼓掌 (必填)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func parseCommentRefs(rawRefs []string) ([]map[string]any, error) {
|
||||
if len(rawRefs) == 0 {
|
||||
return nil, fmt.Errorf("missing required flag(s): --comment-ref")
|
||||
}
|
||||
if len(rawRefs) > 100 {
|
||||
return nil, fmt.Errorf("--comment-ref 最多可传 100 条,当前为 %d 条", len(rawRefs))
|
||||
}
|
||||
refs := make([]map[string]any, 0, len(rawRefs))
|
||||
for index, raw := range rawRefs {
|
||||
parts := strings.SplitN(strings.TrimSpace(raw), ":", 2)
|
||||
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
|
||||
return nil, fmt.Errorf("invalid --comment-ref[%d] %q: expected topicId:commentKey", index, raw)
|
||||
}
|
||||
refs = append(refs, map[string]any{
|
||||
"topicId": strings.TrimSpace(parts[0]),
|
||||
"commentKey": strings.TrimSpace(parts[1]),
|
||||
})
|
||||
}
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
func addCommentNodeAliases(cmd *cobra.Command) {
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("id", "", "")
|
||||
cmd.Flags().String("node-id", "", "")
|
||||
cmd.Flags().String("doc-id", "", "")
|
||||
cmd.Flags().String("file-id", "", "")
|
||||
for _, name := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
|
||||
_ = cmd.Flags().MarkHidden(name)
|
||||
}
|
||||
}
|
||||
|
||||
func commentIdentity(surface, name, cliLeaf string) contract.ToolIdentitySpec {
|
||||
return contract.ToolIdentitySpec{
|
||||
ProductID: surface,
|
||||
Name: name,
|
||||
CanonicalPath: surface + "." + name,
|
||||
CLIPath: surface + " comment " + cliLeaf,
|
||||
PrimaryCLIPath: surface + " comment " + cliLeaf,
|
||||
}
|
||||
}
|
||||
|
||||
func commentResourceName(surface string) string {
|
||||
if surface == "sheet" {
|
||||
return "表格"
|
||||
}
|
||||
return "文档"
|
||||
}
|
||||
|
||||
func commentInterface(rpc string) *contract.InterfaceSpec {
|
||||
return &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: commentServer, RPCName: rpc},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func executeCommentBaseCommand(t *testing.T, caller *docCommentMutationCaller, surface string, args ...string) error {
|
||||
t.Helper()
|
||||
previousDeps := deps
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
})
|
||||
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
os.Args = []string{"dws", surface}
|
||||
var cmd *cobra.Command
|
||||
if surface == "sheet" {
|
||||
cmd = newSheetCommand()
|
||||
} else {
|
||||
cmd = newDocCommand()
|
||||
}
|
||||
cmd.PersistentFlags().Bool("yes", false, "skip confirmation")
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(args)
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommentBaseCommandsRegisteredForDocAndSheet(t *testing.T) {
|
||||
for _, surface := range []struct {
|
||||
name string
|
||||
cmd *cobra.Command
|
||||
}{
|
||||
{name: "doc", cmd: newDocCommand()},
|
||||
{name: "sheet", cmd: newSheetCommand()},
|
||||
} {
|
||||
for _, leaf := range []string{"batch-query", "resolve", "restore", "react-reply"} {
|
||||
cmd, remaining, err := surface.cmd.Find([]string{"comment", leaf})
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("dws %s comment %s not registered: cmd=%v remaining=%v err=%v",
|
||||
surface.name, leaf, cmd, remaining, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocCommentBatchQueryMapsStructuredRefs(t *testing.T) {
|
||||
caller := &docCommentMutationCaller{}
|
||||
err := executeCommentBaseCommand(t, caller, "doc",
|
||||
"comment", "batch-query", "--node", "doc-1",
|
||||
"--comment-ref", "global:comment-1", "--comment-ref", "topic-2:comment-2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := docCommentMutationCall{
|
||||
productID: commentServer,
|
||||
toolName: "batch_query_comments",
|
||||
args: map[string]any{
|
||||
"nodeId": "doc-1",
|
||||
"comments": []map[string]any{
|
||||
{"topicId": "global", "commentKey": "comment-1"},
|
||||
{"topicId": "topic-2", "commentKey": "comment-2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSheetCommentResolveAndRestoreUseSharedRPCs(t *testing.T) {
|
||||
caller := &docCommentMutationCaller{}
|
||||
if err := executeCommentBaseCommand(t, caller, "sheet",
|
||||
"comment", "resolve", "--node", "sheet-1", "--comment-key", "comment-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := executeCommentBaseCommand(t, caller, "sheet",
|
||||
"comment", "restore", "--node", "sheet-1", "--comment-key", "comment-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 2 {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
if caller.calls[0].toolName != "resolve_comment" || caller.calls[1].toolName != "restore_comment" {
|
||||
t.Fatalf("tools = %q, %q", caller.calls[0].toolName, caller.calls[1].toolName)
|
||||
}
|
||||
wantArgs := map[string]any{"nodeId": "sheet-1", "commentKey": "comment-1"}
|
||||
if !reflect.DeepEqual(caller.calls[0].args, wantArgs) || !reflect.DeepEqual(caller.calls[1].args, wantArgs) {
|
||||
t.Fatalf("calls = %#v, want args %#v", caller.calls, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommentReactReplyForcesEmojiTrue(t *testing.T) {
|
||||
caller := &docCommentMutationCaller{}
|
||||
err := executeCommentBaseCommand(t, caller, "doc",
|
||||
"comment", "react-reply", "--file-id", "doc-1",
|
||||
"--comment-key", "comment-1", "--reaction", "鼓掌")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := docCommentMutationCall{
|
||||
productID: commentServer,
|
||||
toolName: "reply_comment",
|
||||
args: map[string]any{
|
||||
"nodeId": "doc-1",
|
||||
"replyCommentKey": "comment-1",
|
||||
"content": "鼓掌",
|
||||
"emoji": true,
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommentReactReplyGuidesDingTalkEmojiNames(t *testing.T) {
|
||||
for _, surface := range []struct {
|
||||
name string
|
||||
cmd *cobra.Command
|
||||
}{
|
||||
{name: "doc", cmd: newDocCommand()},
|
||||
{name: "sheet", cmd: newSheetCommand()},
|
||||
} {
|
||||
cmd, remaining, err := surface.cmd.Find([]string{"comment", "react-reply"})
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("dws %s comment react-reply lookup: remaining=%v err=%v", surface.name, remaining, err)
|
||||
}
|
||||
reaction := cmd.Flags().Lookup("reaction")
|
||||
if reaction == nil || !strings.Contains(reaction.Usage, "不是 Unicode Emoji") || !strings.Contains(reaction.Usage, "😄=憨笑") {
|
||||
t.Fatalf("dws %s reaction guidance = %#v", surface.name, reaction)
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "不要直接传") || !strings.Contains(cmd.Example, `--reaction "憨笑"`) {
|
||||
t.Fatalf("dws %s react-reply help missing DingTalk emoji-name guidance", surface.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageParseCommentRefsRejectsInvalidShapeAndLimit(t *testing.T) {
|
||||
if _, err := parseCommentRefs(nil); err == nil {
|
||||
t.Fatal("missing refs returned nil error")
|
||||
}
|
||||
if _, err := parseCommentRefs([]string{"missing-separator"}); err == nil {
|
||||
t.Fatal("invalid ref returned nil error")
|
||||
}
|
||||
tooMany := make([]string, 101)
|
||||
for index := range tooMany {
|
||||
tooMany[index] = "global:comment"
|
||||
}
|
||||
if _, err := parseCommentRefs(tooMany); err == nil {
|
||||
t.Fatal("101 refs returned nil error")
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func TestCallbackInboundMediaPreservesFutureRichTextAttachment(t *testing.T) {
|
||||
|
||||
// TestExtractCallbackText covers the markdown / richText fallback path used
|
||||
// when SDK data.Text.Content is empty. This is the recovery path for
|
||||
// `dws chat message send --group ... --text ...` (defaults to msgType=markdown)
|
||||
// `dws chat message send --conversation-id ... --content ...` (defaults to msgType=markdown)
|
||||
// which otherwise gets silently dropped by the connector.
|
||||
func TestExtractCallbackText(t *testing.T) {
|
||||
cases := []struct {
|
||||
|
||||
@@ -1259,7 +1259,7 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
}
|
||||
// Structured-text fallback: DingTalk leaves data.Text.Content blank on
|
||||
// markdown / richText callbacks (the body ships in data.Content). Without
|
||||
// this, `dws chat message send --group ... --text ...` — which defaults
|
||||
// this, `dws chat message send --conversation-id ... --content ...` — which defaults
|
||||
// to msgType=markdown — hits the drop branch below and the bot looks
|
||||
// dead to the sender.
|
||||
if text == "" {
|
||||
|
||||
+24
-15
@@ -15,10 +15,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -1018,7 +1018,7 @@ func inferMimeType(fileName string) string {
|
||||
}
|
||||
|
||||
// buildBlockElement 从 flags 构建块元素 JSON 对象。
|
||||
// 优先级: --element (原始 JSON) > --heading > --text
|
||||
// 优先级: --element (原始 JSON) > --heading > --text (旧名) > --content
|
||||
func buildBlockElement(cmd *cobra.Command) (any, error) {
|
||||
if raw, _ := cmd.Flags().GetString("element"); raw != "" {
|
||||
var obj any
|
||||
@@ -1037,13 +1037,13 @@ func buildBlockElement(cmd *cobra.Command) (any, error) {
|
||||
"heading": map[string]any{"text": h, "level": level},
|
||||
}, nil
|
||||
}
|
||||
if t, _ := cmd.Flags().GetString("text"); t != "" {
|
||||
if t := flagOrFallback(cmd, "text", "content"); t != "" {
|
||||
return map[string]any{
|
||||
"blockType": "paragraph",
|
||||
"paragraph": map[string]any{"text": t},
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("block content required: --text / --heading / --element")
|
||||
return nil, fmt.Errorf("block content required: --content / --heading / --element")
|
||||
}
|
||||
|
||||
// buildNodeTransferRunE creates a RunE handler for copy/move commands.
|
||||
@@ -2127,12 +2127,12 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
|
||||
Use: "insert",
|
||||
Short: "插入块元素",
|
||||
Long: `向文档插入块元素。通过 --element 传入 JSON 格式的块元素。
|
||||
可用 --text 快速插入段落,--heading + --level 快速插入标题。
|
||||
可用 --content 快速插入段落,--heading + --level 快速插入标题。
|
||||
|
||||
块类型: paragraph, heading, blockquote, callout, columns,
|
||||
orderedList, unorderedList, table, sheet, attachment, slot`,
|
||||
Example: ` # 快捷插入段落
|
||||
dws doc block insert --node DOC_ID --text "这是一段文字"
|
||||
dws doc block insert --node DOC_ID --content "这是一段文字"
|
||||
|
||||
# 快捷插入标题
|
||||
dws doc block insert --node DOC_ID --heading "二级标题" --level 2
|
||||
@@ -2155,7 +2155,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
|
||||
dws doc block insert --node DOC_ID --element '{"blockType":"unorderedList","unorderedList":{"list":{"listId":"list-2"}},"children":[{"text":"第二项"}]}'
|
||||
|
||||
# 在指定位置之前插入
|
||||
dws doc block insert --node DOC_ID --text "插入内容" --ref-block BLOCK_ID --where before`,
|
||||
dws doc block insert --node DOC_ID --content "插入内容" --ref-block BLOCK_ID --where before`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
@@ -2241,14 +2241,14 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "向文档插入块元素",
|
||||
UseWhen: []string{"在文档中插入新块(段落/标题等);简单场景用 --text/--heading,复杂块用 --element JSON"},
|
||||
UseWhen: []string{"在文档中插入新块(段落/标题等);简单场景用 --content/--heading,复杂块用 --element JSON"},
|
||||
AvoidWhen: []string{
|
||||
"整篇追加 Markdown 优先 doc update --mode append",
|
||||
"插入本地文件附件优先 doc media insert",
|
||||
"删块用 block delete;改已有块用 block update",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws doc block insert --node <DOC_ID> --text \"这是一段文字\" --format json",
|
||||
"dws doc block insert --node <DOC_ID> --content \"这是一段文字\" --format json",
|
||||
"dws doc block insert --node <DOC_ID> --heading \"二级标题\" --level 2 --format json",
|
||||
},
|
||||
},
|
||||
@@ -2265,8 +2265,8 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
|
||||
Use: "update",
|
||||
Short: "更新块元素",
|
||||
Long: `更新文档中指定块的内容或样式。需提供 --block-id 和块元素内容。
|
||||
可用 --text 快速更新为段落,--heading + --level 快速更新为标题。`,
|
||||
Example: ` dws doc block update --node DOC_ID --block-id BLOCK_ID --text "新内容" # 查询 nodeId: dws doc search --query "..." 或 dws doc list # 查询 blockId: dws doc block list --node <nodeId>
|
||||
可用 --content 快速更新为段落,--heading + --level 快速更新为标题。`,
|
||||
Example: ` dws doc block update --node DOC_ID --block-id BLOCK_ID --content "新内容" # 查询 nodeId: dws doc search --query "..." 或 dws doc list # 查询 blockId: dws doc block list --node <nodeId>
|
||||
dws doc block update --node DOC_ID --block-id BLOCK_ID --element '{"blockType":"heading","heading":{"text":"新标题","level":1}}'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "block-id"); err != nil {
|
||||
@@ -2338,7 +2338,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
|
||||
"插入新块用 block insert;删除用 block delete",
|
||||
"改文档显示名用 rename;整篇覆盖用 update overwrite",
|
||||
},
|
||||
Examples: []string{"dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --text \"新内容\" --format json"},
|
||||
Examples: []string{"dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --content \"新内容\" --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "content-format", Property: "format"},
|
||||
@@ -2748,7 +2748,6 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
|
||||
|
||||
// block insert
|
||||
blockInsertCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
|
||||
blockInsertCmd.Flags().String("text", "", "快捷: 段落文本内容")
|
||||
blockInsertCmd.Flags().String("heading", "", "快捷: 标题文本")
|
||||
blockInsertCmd.Flags().Int("level", 1, "标题级别 1-6 (配合 --heading)")
|
||||
blockInsertCmd.Flags().String("element", "", "块元素 JSON (高级)")
|
||||
@@ -2762,7 +2761,6 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
|
||||
// block update
|
||||
blockUpdateCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
|
||||
blockUpdateCmd.Flags().String("block-id", "", "目标块 ID (必填)")
|
||||
blockUpdateCmd.Flags().String("text", "", "快捷: 段落文本内容")
|
||||
blockUpdateCmd.Flags().String("heading", "", "快捷: 标题文本")
|
||||
blockUpdateCmd.Flags().Int("level", 1, "标题级别 1-6 (配合 --heading)")
|
||||
blockUpdateCmd.Flags().String("element", "", "块元素 JSON (高级)")
|
||||
@@ -3517,6 +3515,7 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
}
|
||||
|
||||
commentCmd.AddCommand(commentListCmd, commentCreateCmd, commentReplyCmd, commentUpdateCmd, commentDeleteCmd, commentCreateInlineCmd)
|
||||
commentCmd.AddCommand(newCommentBaseCommands("doc")...)
|
||||
|
||||
// ── permission (文档协作权限) ────────────────────────────
|
||||
permissionCmd := &cobra.Command{
|
||||
@@ -4643,6 +4642,16 @@ CLI 内部自动完成全部流程:
|
||||
RegisterCrossProductAliases(child)
|
||||
}
|
||||
}
|
||||
// Register the block content Primary after the cross-product pass. Registering
|
||||
// --content earlier would make the global content/markdown group expand a new
|
||||
// --markdown alias onto these commands, which is outside this migration.
|
||||
for _, cmd := range []*cobra.Command{blockInsertCmd, blockUpdateCmd} {
|
||||
corecmd.RegisterFlags(cmd, []corecmd.FlagSpec{{
|
||||
Name: "content",
|
||||
Usage: "快捷: 段落文本内容",
|
||||
Aliases: []string{"text"},
|
||||
}})
|
||||
}
|
||||
|
||||
// ── deprecated 标记:文件管理命令已迁移到 drive ──
|
||||
deprecatedDocToDrive := map[*cobra.Command]string{
|
||||
|
||||
@@ -155,7 +155,18 @@ func callMCPToolReturnTextOnServer(ctx context.Context, serverID, toolName strin
|
||||
// ReadToolCaller capability; if unavailable, it fails closed instead of
|
||||
// returning a synthetic dry-run envelope that looks like business data.
|
||||
func CallMCPReadToolTextOnServer(serverID, toolName string, args map[string]any) (string, error) {
|
||||
return callMCPReadToolReturnTextOnServer(context.Background(), serverID, toolName, args)
|
||||
return CallMCPReadToolTextOnServerContext(context.Background(), serverID, toolName, args)
|
||||
}
|
||||
|
||||
// CallMCPReadToolTextOnServerContext is the cancellable form used by Cobra
|
||||
// commands and composite shortcuts. Keeping the caller context attached to the
|
||||
// transport lets SIGTERM/parent deadlines stop an in-flight MCP read and return
|
||||
// a structured error instead of leaving the CLI silent until the host kills it.
|
||||
func CallMCPReadToolTextOnServerContext(ctx context.Context, serverID, toolName string, args map[string]any) (string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return callMCPReadToolReturnTextOnServer(ctx, serverID, toolName, args)
|
||||
}
|
||||
|
||||
func callMCPReadToolReturnTextOnServer(ctx context.Context, serverID, toolName string, args map[string]any) (string, error) {
|
||||
@@ -266,7 +277,17 @@ func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResul
|
||||
// print path. Exported for the shortcut layer's multi-step ("smart") shortcuts,
|
||||
// which chain several tool calls and need each intermediate result as data.
|
||||
func CallMCPToolTextOnServer(serverID, toolName string, args map[string]any) (string, error) {
|
||||
return callMCPToolReturnTextOnServer(context.Background(), serverID, toolName, args)
|
||||
return CallMCPToolTextOnServerContext(context.Background(), serverID, toolName, args)
|
||||
}
|
||||
|
||||
// CallMCPToolTextOnServerContext invokes one MCP tool while preserving the
|
||||
// command's cancellation and deadline. Legacy callers may continue using
|
||||
// CallMCPToolTextOnServer, which intentionally retains background context.
|
||||
func CallMCPToolTextOnServerContext(ctx context.Context, serverID, toolName string, args map[string]any) (string, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return callMCPToolReturnTextOnServer(ctx, serverID, toolName, args)
|
||||
}
|
||||
|
||||
// CallMCPToolDataOnServer invokes one tool without printing and decodes its
|
||||
@@ -293,7 +314,11 @@ func CallMCPToolDataOnServer(ctx context.Context, serverID, toolName string, arg
|
||||
// callMCPTool 是通用的 MCP 工具调用入口:自动路由 → 调用 → 格式化输出。
|
||||
// 通过 resolveProductID() 自动确定目标 MCP Server,JSON 输出使用默认的 HTML 转义。
|
||||
func callMCPTool(toolName string, args map[string]any) error {
|
||||
return callMCPToolInternalOpts("", toolName, args, false)
|
||||
return callMCPToolContext(context.Background(), toolName, args)
|
||||
}
|
||||
|
||||
func callMCPToolContext(ctx context.Context, toolName string, args map[string]any) error {
|
||||
return callMCPToolInternalOptsContext(ctx, "", toolName, args, false)
|
||||
}
|
||||
|
||||
// callMCPToolUnescaped 与 callMCPTool 功能相同,但 JSON 输出禁用 HTML 转义。
|
||||
@@ -306,7 +331,7 @@ func callMCPToolUnescaped(toolName string, args map[string]any) error {
|
||||
// callMCPToolOnServer 在指定的 MCP Server 上调用工具,跳过 resolveProductID() 的自动路由。
|
||||
// 用于需要显式指定 serverID 的场景(如 credit 等多 server 产品)。
|
||||
func callMCPToolOnServer(serverID, toolName string, args map[string]any) error {
|
||||
return callMCPToolInternalOpts(serverID, toolName, args, false)
|
||||
return callMCPToolInternalOptsContext(context.Background(), serverID, toolName, args, false)
|
||||
}
|
||||
|
||||
// CallMCPToolOnServer is the exported version of callMCPToolOnServer for use
|
||||
@@ -315,6 +340,13 @@ func CallMCPToolOnServer(serverID, toolName string, args map[string]any) error {
|
||||
return callMCPToolOnServer(serverID, toolName, args)
|
||||
}
|
||||
|
||||
// CallMCPToolOnServerContext is the cancellable print-path variant for
|
||||
// Shortcut execution. It preserves the same output and error projection as the
|
||||
// legacy wrapper while allowing the root signal context to abort transport.
|
||||
func CallMCPToolOnServerContext(ctx context.Context, serverID, toolName string, args map[string]any) error {
|
||||
return callMCPToolInternalOptsContext(ctx, serverID, toolName, args, false)
|
||||
}
|
||||
|
||||
// GroupRunE is the exported version of groupRunE for use by extension packages.
|
||||
func GroupRunE(cmd *cobra.Command, args []string) error {
|
||||
return groupRunE(cmd, args)
|
||||
@@ -343,7 +375,13 @@ func MustGetStringFlag(cmd *cobra.Command, name string) string {
|
||||
// 3. 错误分类:网关错误 → 未登录 → PAT 错误 → 业务错误
|
||||
// 4. 根据 --format 标志选择输出格式(json / table / raw)
|
||||
func callMCPToolInternalOpts(explicitServerID, toolName string, args map[string]any, unescapeHTML bool) error {
|
||||
ctx := context.Background()
|
||||
return callMCPToolInternalOptsContext(context.Background(), explicitServerID, toolName, args, unescapeHTML)
|
||||
}
|
||||
|
||||
func callMCPToolInternalOptsContext(ctx context.Context, explicitServerID, toolName string, args map[string]any, unescapeHTML bool) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
// DryRun 模式:仅预览工具名和参数,不实际调用 MCP Server
|
||||
if deps.Caller.DryRun() {
|
||||
|
||||
@@ -39,6 +39,20 @@ type helpersReadCaller struct {
|
||||
readCalls int
|
||||
}
|
||||
|
||||
type helpersContextCaller struct {
|
||||
*helpersCoreCaller
|
||||
seenErr error
|
||||
}
|
||||
|
||||
func (c *helpersContextCaller) CallTool(ctx context.Context, _ string, _ string, _ map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls++
|
||||
c.seenErr = ctx.Err()
|
||||
if c.seenErr != nil {
|
||||
return nil, c.seenErr
|
||||
}
|
||||
return nil, errors.New("stop after context capture")
|
||||
}
|
||||
|
||||
func (c *helpersReadCaller) CallReadTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
c.readCalls++
|
||||
return c.readResult, c.readErr
|
||||
@@ -128,6 +142,48 @@ func TestCrossPlatformCoverageReadLookupInitializationAndRegularExecution(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMCPContextWrappersPreserveCancellation(t *testing.T) {
|
||||
caller := &helpersContextCaller{helpersCoreCaller: &helpersCoreCaller{format: "json"}}
|
||||
installHelpersCoreDeps(t, caller)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
if _, err := CallMCPToolTextOnServerContext(ctx, "aitable", "get_fields", nil); err == nil {
|
||||
t.Fatal("cancelled text call unexpectedly succeeded")
|
||||
}
|
||||
if !errors.Is(caller.seenErr, context.Canceled) || caller.calls != 1 {
|
||||
t.Fatalf("text call context/calls = %v, %d", caller.seenErr, caller.calls)
|
||||
}
|
||||
|
||||
if err := CallMCPToolOnServerContext(ctx, "aitable", "get_fields", nil); err == nil {
|
||||
t.Fatal("cancelled print call unexpectedly succeeded")
|
||||
}
|
||||
if !errors.Is(caller.seenErr, context.Canceled) || caller.calls != 2 {
|
||||
t.Fatalf("print call context/calls = %v, %d", caller.seenErr, caller.calls)
|
||||
}
|
||||
|
||||
// Public context-aware wrappers intentionally accept nil for compatibility,
|
||||
// but must normalize it before crossing the transport boundary.
|
||||
if _, err := CallMCPToolTextOnServerContext(nil, "aitable", "get_fields", nil); err == nil {
|
||||
t.Fatal("nil-context text call unexpectedly succeeded with a nil result")
|
||||
}
|
||||
if caller.seenErr != nil || caller.calls != 3 {
|
||||
t.Fatalf("nil-context text call context/calls = %v, %d", caller.seenErr, caller.calls)
|
||||
}
|
||||
if _, err := CallMCPReadToolTextOnServerContext(nil, "aitable", "get_fields", nil); err == nil {
|
||||
t.Fatal("nil-context read call unexpectedly succeeded with a nil result")
|
||||
}
|
||||
if caller.seenErr != nil || caller.calls != 4 {
|
||||
t.Fatalf("nil-context read call context/calls = %v, %d", caller.seenErr, caller.calls)
|
||||
}
|
||||
if err := CallMCPToolOnServerContext(nil, "aitable", "get_fields", nil); err == nil {
|
||||
t.Fatal("nil-context print call unexpectedly succeeded with a nil result")
|
||||
}
|
||||
if caller.seenErr != nil || caller.calls != 5 {
|
||||
t.Fatalf("nil-context print call context/calls = %v, %d", caller.seenErr, caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSharedDependenciesRoutingAndWrappers(t *testing.T) {
|
||||
oldDeps := deps
|
||||
deps = nil
|
||||
|
||||
+286
-3
@@ -10,7 +10,10 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -42,6 +45,283 @@ func oaFormValues(raw string) ([]map[string]string, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseOAAttachmentFileInfos(raw string) ([]map[string]any, error) {
|
||||
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var items []map[string]any
|
||||
if err := decoder.Decode(&items); err != nil {
|
||||
return nil, fmt.Errorf("--file-infos JSON 解析失败: %w", err)
|
||||
}
|
||||
if err := rejectTrailingOAAttachmentJSON(decoder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) < 1 || len(items) > 10 {
|
||||
return nil, fmt.Errorf("--file-infos 必须包含 1 至 10 个文件")
|
||||
}
|
||||
|
||||
infos := make([]map[string]any, 0, len(items))
|
||||
for index, item := range items {
|
||||
for name := range item {
|
||||
if name != "spaceId" && name != "fileId" {
|
||||
return nil, fmt.Errorf("--file-infos 第 %d 项包含未知字段 %q", index+1, name)
|
||||
}
|
||||
}
|
||||
|
||||
spaceValue, ok := item["spaceId"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("--file-infos 第 %d 项缺少 spaceId", index+1)
|
||||
}
|
||||
spaceID, ok := spaceValue.(json.Number)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("--file-infos 第 %d 项 spaceId 必须是数字", index+1)
|
||||
}
|
||||
|
||||
fileValue, ok := item["fileId"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("--file-infos 第 %d 项缺少 fileId", index+1)
|
||||
}
|
||||
fileID, ok := fileValue.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("--file-infos 第 %d 项 fileId 必须是字符串", index+1)
|
||||
}
|
||||
fileID = strings.TrimSpace(fileID)
|
||||
if fileID == "" {
|
||||
return nil, fmt.Errorf("--file-infos 第 %d 项 fileId 不能为空", index+1)
|
||||
}
|
||||
|
||||
infos = append(infos, map[string]any{"spaceId": spaceID, "fileId": fileID})
|
||||
}
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
func rejectTrailingOAAttachmentJSON(decoder *json.Decoder) error {
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("--file-infos JSON 包含多余内容")
|
||||
}
|
||||
return fmt.Errorf("--file-infos JSON 解析失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOAAttachmentFileInfos(cmd *cobra.Command, _ []string) error {
|
||||
raw, _ := cmd.Flags().GetString("file-infos")
|
||||
_, err := parseOAAttachmentFileInfos(raw)
|
||||
return err
|
||||
}
|
||||
|
||||
func validateOAPreviewFileIDs(cmd *cobra.Command, _ []string) error {
|
||||
fileIDs, _ := cmd.Flags().GetStringSlice("file-ids")
|
||||
if len(fileIDs) > 20 {
|
||||
return fmt.Errorf("--file-ids 最多包含 20 个附件 ID")
|
||||
}
|
||||
for index, fileID := range fileIDs {
|
||||
if strings.TrimSpace(fileID) == "" {
|
||||
return fmt.Errorf("--file-ids 第 %d 项不能为空", index+1)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) {
|
||||
data, err := CallMCPToolDataOnServer(cmd.Context(), "oa", tool, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response, ok := data.(map[string]any)
|
||||
if !ok {
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("oa/%s 返回值不是 JSON 对象", tool))
|
||||
}
|
||||
result, ok := response["result"]
|
||||
if !ok {
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("oa/%s 返回值缺少 result", tool))
|
||||
}
|
||||
return output.Success(result), nil
|
||||
}
|
||||
|
||||
func newOAAttachmentCommand() *cobra.Command {
|
||||
attachmentCmd := &cobra.Command{
|
||||
Use: "attachment",
|
||||
Short: "审批附件授权与下载链接",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
downloadURLCmd := NewLeafCommand(LeafSpec{
|
||||
Use: "download-url",
|
||||
Short: "获取审批附件下载链接",
|
||||
Example: " dws oa approval attachment download-url --instance-id <processInstanceId> --file-id <fileId>",
|
||||
Server: "oa",
|
||||
Tool: "get_attachment_download_url",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
ResultCall: callOAAttachmentResult,
|
||||
Flags: []LeafFlag{
|
||||
{Name: "instance-id", Usage: "审批实例 ID (必填)", Bind: "processInstanceId", Trim: true, Required: true, MarkRequired: true},
|
||||
{Name: "file-id", Usage: "审批附件文件 ID (必填)", Bind: "fileId", Trim: true, Required: true, MarkRequired: true},
|
||||
{Name: "with-comment-attachment", Usage: "是否包含评论中的附件", Kind: LeafBool, Bind: "withCommentAttachment"},
|
||||
},
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "oa",
|
||||
Name: "get_attachment_download_url",
|
||||
CanonicalPath: "oa.get_attachment_download_url",
|
||||
CLIPath: "oa approval attachment download-url",
|
||||
PrimaryCLIPath: "oa approval attachment download-url",
|
||||
},
|
||||
Description: "获取审批附件下载授权并生成临时下载链接",
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"审批附件临时下载信息","properties":{"spaceId":{"type":"integer","description":"审批附件所在钉盘空间 ID"},"agentId":{"type":"integer","description":"审批应用 Agent ID"},"downloadUri":{"type":"string","description":"带临时授权签名的附件下载链接"},"class":{"type":"string","description":"服务端响应类型标识"},"fileId":{"type":"string","description":"审批附件文件 ID"}},"required":["spaceId","agentId","downloadUri","fileId"],"additionalProperties":true}`),
|
||||
SensitivePaths: []string{"downloadUri"},
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "get_attachment_download_url"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "获取审批实例中指定附件的临时下载链接",
|
||||
UseWhen: []string{"已从审批详情获得 processInstanceId 和 fileId,需要获取附件下载链接时"},
|
||||
AvoidWhen: []string{
|
||||
"只需查看审批表单和附件元数据时使用 dws oa approval detail",
|
||||
"需要将附件真正保存到本地时不要误认为本命令会下载文件;它只返回链接",
|
||||
},
|
||||
Examples: []string{"dws oa approval attachment download-url --instance-id <processInstanceId> --file-id <fileId>"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "instance-id", Property: "processInstanceId", InterfaceType: "string"},
|
||||
{Name: "file-id", Property: "fileId", InterfaceType: "string"},
|
||||
{Name: "with-comment-attachment", Property: "withCommentAttachment", InterfaceType: "boolean"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
authorizeDownloadCmd := NewLeafCommand(LeafSpec{
|
||||
Use: "authorize-download",
|
||||
Short: "授权当前用户下载审批钉盘文件",
|
||||
Long: "批量授权当前用户下载指定的审批钉盘文件。",
|
||||
Example: ` dws oa approval attachment authorize-download --file-infos '[{"spaceId":27827223951,"fileId":"232271651278"}]'`,
|
||||
Server: "oa",
|
||||
Tool: "auth_download_file",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
ResultCall: callOAAttachmentResult,
|
||||
Flags: []LeafFlag{
|
||||
{
|
||||
Name: "file-infos", Usage: "审批钉盘文件信息 JSON 数组 (必填)",
|
||||
Bind: "fileInfos", Trim: true, Required: true, MarkRequired: true,
|
||||
Transform: func(raw string) (any, error) {
|
||||
return parseOAAttachmentFileInfos(raw)
|
||||
},
|
||||
},
|
||||
},
|
||||
Constraints: []LeafConstraint{{
|
||||
Kind: corecmd.Custom, Flags: []string{"file-infos"},
|
||||
Description: "文件信息列表必须包含 1 至 10 项",
|
||||
}},
|
||||
Validate: validateOAAttachmentFileInfos,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "oa",
|
||||
Name: "auth_download_file",
|
||||
CanonicalPath: "oa.auth_download_file",
|
||||
CLIPath: "oa approval attachment authorize-download",
|
||||
PrimaryCLIPath: "oa approval attachment authorize-download",
|
||||
},
|
||||
Description: "批量授权当前用户下载指定的审批钉盘文件",
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"boolean","description":"是否成功为当前用户授予审批钉盘文件下载权限"}`),
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "auth_download_file"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "使用审批钉盘 spaceId/fileId 批量授权当前用户下载文件",
|
||||
UseWhen: []string{"已有一个或多个审批钉盘文件的 spaceId 和 fileId,需要为当前用户取得下载权限时"},
|
||||
AvoidWhen: []string{
|
||||
"需要生成单个审批附件下载链接时使用 attachment download-url",
|
||||
"需要授权在审批单内预览附件时使用 attachment authorize-preview",
|
||||
},
|
||||
Examples: []string{`dws oa approval attachment authorize-download --file-infos '[{"spaceId":27827223951,"fileId":"232271651278"}]'`},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "file-infos", Property: "fileInfos", InterfaceType: "array"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
authorizePreviewCmd := NewLeafCommand(LeafSpec{
|
||||
Use: "authorize-preview",
|
||||
Short: "授权当前用户预览审批附件",
|
||||
Long: "批量授权当前用户预览审批单中的附件。",
|
||||
Example: " dws oa approval attachment authorize-preview --instance-id <processInstanceId> --file-ids <fileId1>,<fileId2>",
|
||||
Server: "oa",
|
||||
Tool: "auth_preview_attachment",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
ResultCall: callOAAttachmentResult,
|
||||
Flags: []LeafFlag{
|
||||
{Name: "instance-id", Usage: "审批实例 ID (必填)", Bind: "processInstanceId", Trim: true, Required: true, MarkRequired: true},
|
||||
{Name: "file-ids", Usage: "附件 ID 列表,多个用逗号分隔 (必填)", Kind: LeafStringSlice, Bind: "fileIdList", Required: true, MarkRequired: true},
|
||||
{Name: "with-comment-attachment", Usage: "是否包含评论中的附件", Kind: LeafBool, Bind: "withCommentAttachment"},
|
||||
},
|
||||
Constraints: []LeafConstraint{{
|
||||
Kind: corecmd.Custom, Flags: []string{"file-ids"},
|
||||
Description: "附件 ID 列表最多包含 20 项且每项不能为空",
|
||||
}},
|
||||
Validate: validateOAPreviewFileIDs,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "oa",
|
||||
Name: "auth_preview_attachment",
|
||||
CanonicalPath: "oa.auth_preview_attachment",
|
||||
CLIPath: "oa approval attachment authorize-preview",
|
||||
PrimaryCLIPath: "oa approval attachment authorize-preview",
|
||||
},
|
||||
Description: "批量授权当前用户预览审批单中的附件",
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"审批附件预览授权信息","properties":{"spaceId":{"type":"integer","description":"审批附件所在钉盘空间 ID"},"agentId":{"type":"integer","description":"审批应用 Agent ID"},"class":{"type":"string","description":"服务端响应类型标识"}},"required":["spaceId","agentId"],"additionalProperties":true}`),
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "auth_preview_attachment"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按审批实例和附件 ID 列表授权当前用户预览附件",
|
||||
UseWhen: []string{"已有 processInstanceId 和附件 fileId 列表,需要在审批场景中批量取得预览权限时"},
|
||||
AvoidWhen: []string{
|
||||
"需要下载权限而不是预览权限时使用 attachment authorize-download",
|
||||
"需要直接获得单个附件临时下载链接时使用 attachment download-url",
|
||||
},
|
||||
Examples: []string{"dws oa approval attachment authorize-preview --instance-id <processInstanceId> --file-ids <fileId1>,<fileId2>"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "instance-id", Property: "processInstanceId", InterfaceType: "string"},
|
||||
{Name: "file-ids", Property: "fileIdList", InterfaceType: "array"},
|
||||
{Name: "with-comment-attachment", Property: "withCommentAttachment", InterfaceType: "boolean"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd)
|
||||
return attachmentCmd
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// dws oa — OA 审批
|
||||
// MCP tools(tools/list): list_pending_approvals, get_processInstance_detail,
|
||||
@@ -49,7 +329,8 @@ func oaFormValues(raw string) ([]map[string]string, error) {
|
||||
// get_processInstance_records, list_initiated_instances, list_pending_tasks,
|
||||
// list_user_visible_process, append_task, search_form, oa_ding_user, revert_task,
|
||||
// get_inst_revert_activities, get_process_schema, forecast_process,
|
||||
// start_process_instance
|
||||
// start_process_instance, get_attachment_download_url, auth_download_file,
|
||||
// auth_preview_attachment
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func newOaCommand() *cobra.Command {
|
||||
@@ -58,9 +339,10 @@ func newOaCommand() *cobra.Command {
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "oa",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "查询和处理 OA 审批实例、任务、记录、抄送与评论",
|
||||
AgentSummary: "查询和处理 OA 审批实例、任务、记录、抄送、评论与附件授权",
|
||||
UseWhen: []string{
|
||||
"查看待审、已办、已发起或抄送审批,并执行同意、拒绝、撤销、转交等审批动作时",
|
||||
"获取审批附件下载链接,或为当前用户授权下载、预览审批附件时",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"不要用于普通待办任务或工作日志;需要实时监听未来的审批任务/实例事件时使用 event consume",
|
||||
@@ -70,7 +352,7 @@ func newOaCommand() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "oa",
|
||||
Short: "OA 审批 / 同意 / 拒绝 / 撤销",
|
||||
Long: `管理钉钉 OA 审批:待办查询、审批详情、同意、拒绝、撤销、操作记录、已发起列表、表单列表。`,
|
||||
Long: `管理钉钉 OA 审批:待办查询、审批详情、同意、拒绝、撤销、操作记录、已发起列表、表单列表与附件授权。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
@@ -1376,6 +1658,7 @@ func newOaCommand() *cobra.Command {
|
||||
approvalForecastCmd,
|
||||
approvalCreateCmd,
|
||||
)
|
||||
approvalCmd.AddCommand(newOAAttachmentCommand())
|
||||
root.AddCommand(approvalCmd)
|
||||
|
||||
return root
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func executeOAAttachmentCommandCapturingOutput(t *testing.T, caller *scriptedToolCaller, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &deps)
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "oa"})
|
||||
InitDeps(caller)
|
||||
var stdout bytes.Buffer
|
||||
deps.Out.w = &stdout
|
||||
deps.Out.errW = io.Discard
|
||||
|
||||
cmd := newOaCommand()
|
||||
cmd.PersistentFlags().Bool("yes", false, "跳过确认")
|
||||
cmd.PersistentFlags().String("format", caller.Format(), "输出格式")
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
cmd.SetContext(ctx)
|
||||
cmd.PersistentPostRunE = func(executed *cobra.Command, _ []string) error {
|
||||
_, _, err := output.EmitStoredResult(executed)
|
||||
return err
|
||||
}
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(args)
|
||||
err := cmd.Execute()
|
||||
return stdout.String(), err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAAttachmentDownloadURLPreservesSignedQuery(t *testing.T) {
|
||||
const response = `{"result":{"downloadUri":"https://example.test/file?Expires=1&OSSAccessKeyId=2&Signature=3"},"success":true}`
|
||||
commandArgs := []string{
|
||||
"approval", "attachment", "download-url",
|
||||
"--instance-id", "instance-1",
|
||||
"--file-id", "file-1",
|
||||
}
|
||||
wantArgs := map[string]any{
|
||||
"processInstanceId": "instance-1",
|
||||
"fileId": "file-1",
|
||||
}
|
||||
|
||||
t.Run("json", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: response}}}
|
||||
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, commandArgs...)
|
||||
if err != nil {
|
||||
t.Fatalf("execute command: %v", err)
|
||||
}
|
||||
if caller.server != "oa" || caller.tool != "get_attachment_download_url" {
|
||||
t.Fatalf("called %s/%s, want oa/get_attachment_download_url", caller.server, caller.tool)
|
||||
}
|
||||
if !reflect.DeepEqual(caller.args, wantArgs) {
|
||||
t.Fatalf("args = %#v, want %#v", caller.args, wantArgs)
|
||||
}
|
||||
if !strings.Contains(stdout, "&OSSAccessKeyId=") || !strings.Contains(stdout, "&Signature=") {
|
||||
t.Fatalf("stdout does not preserve signed query separators: %q", stdout)
|
||||
}
|
||||
if strings.Contains(stdout, `\u0026`) {
|
||||
t.Fatalf("stdout contains escaped query separator: %q", stdout)
|
||||
}
|
||||
var envelope map[string]any
|
||||
if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
|
||||
t.Fatalf("decode unified output: %v", err)
|
||||
}
|
||||
if envelope["ok"] != true || envelope["outcome"] != "success" {
|
||||
t.Fatalf("unified envelope = %#v", envelope)
|
||||
}
|
||||
data, ok := envelope["data"].(map[string]any)
|
||||
if !ok || !strings.Contains(data["downloadUri"].(string), "&Signature=") {
|
||||
t.Fatalf("unified data = %#v", envelope["data"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("raw compatibility", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "raw", steps: []scriptedToolStep{{text: response}}}
|
||||
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, commandArgs...)
|
||||
if err != nil {
|
||||
t.Fatalf("execute command: %v", err)
|
||||
}
|
||||
const want = `{"downloadUri":"https://example.test/file?Expires=1&OSSAccessKeyId=2&Signature=3"}` + "\n"
|
||||
if stdout != want {
|
||||
t.Fatalf("stdout = %q, want %q", stdout, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAAttachmentProjectsUnifiedBusinessData(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
response string
|
||||
wantData any
|
||||
}{
|
||||
{
|
||||
name: "download authorization",
|
||||
args: []string{"approval", "attachment", "authorize-download", "--file-infos", `[{"spaceId":27827223951,"fileId":"file-1"}]`},
|
||||
response: `{"result":true,"success":true,"dingOpenErrcode":0,"errorMsg":"ok"}`,
|
||||
wantData: true,
|
||||
},
|
||||
{
|
||||
name: "preview authorization",
|
||||
args: []string{"approval", "attachment", "authorize-preview", "--instance-id", "instance-1", "--file-ids", "file-1"},
|
||||
response: `{"result":{"spaceId":27827223951,"agentId":4115627346,"class":"com.dingtalk.bpms.oapi.vo.AppSpaceResponse"},"success":true,"dingOpenErrcode":0,"errorMsg":"ok"}`,
|
||||
wantData: map[string]any{
|
||||
"spaceId": float64(27827223951), "agentId": float64(4115627346),
|
||||
"class": "com.dingtalk.bpms.oapi.vo.AppSpaceResponse",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: test.response}}}
|
||||
stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, test.args...)
|
||||
if err != nil {
|
||||
t.Fatalf("execute command: %v", err)
|
||||
}
|
||||
var envelope map[string]any
|
||||
if err := json.Unmarshal([]byte(stdout), &envelope); err != nil {
|
||||
t.Fatalf("decode unified output: %v", err)
|
||||
}
|
||||
if envelope["ok"] != true || envelope["outcome"] != "success" || !reflect.DeepEqual(envelope["data"], test.wantData) {
|
||||
t.Fatalf("unified envelope = %#v, want data %#v", envelope, test.wantData)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAAttachmentRejectsSuccessWithoutResult(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"success":true,"dingOpenErrcode":0,"errorMsg":"ok"}`}}}
|
||||
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
|
||||
"approval", "attachment", "authorize-download",
|
||||
"--file-infos", `[{"spaceId":27827223951,"fileId":"file-1"}]`,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "缺少 result") {
|
||||
t.Fatalf("execute error = %v, want missing result", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAAttachmentRejectsInvalidToolResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
step scriptedToolStep
|
||||
want string
|
||||
}{
|
||||
{name: "non-object response", step: scriptedToolStep{text: `[]`}, want: "不是 JSON 对象"},
|
||||
{name: "tool error", step: scriptedToolStep{err: errors.New("tool unavailable")}, want: "tool unavailable"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{test.step}}
|
||||
_, err := executeOAAttachmentCommandCapturingOutput(t, caller,
|
||||
"approval", "attachment", "authorize-download",
|
||||
"--file-infos", `[{"spaceId":27827223951,"fileId":"file-1"}]`,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("execute error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAAttachmentPayloads(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantTool string
|
||||
check func(*testing.T, map[string]any)
|
||||
}{
|
||||
{
|
||||
name: "download URL",
|
||||
args: []string{
|
||||
"approval", "attachment", "download-url",
|
||||
"--instance-id", "instance-1",
|
||||
"--file-id", "file-1",
|
||||
"--with-comment-attachment=false",
|
||||
},
|
||||
wantTool: "get_attachment_download_url",
|
||||
check: func(t *testing.T, got map[string]any) {
|
||||
t.Helper()
|
||||
if got["processInstanceId"] != "instance-1" || got["fileId"] != "file-1" {
|
||||
t.Fatalf("download identifiers = %#v", got)
|
||||
}
|
||||
if value, ok := got["withCommentAttachment"].(bool); !ok || value {
|
||||
t.Fatalf("withCommentAttachment = %#v, want explicit false", got["withCommentAttachment"])
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "download authorization",
|
||||
args: []string{
|
||||
"approval", "attachment", "authorize-download",
|
||||
"--file-infos", `[{"spaceId":27827223951,"fileId":"file-2"}]`,
|
||||
},
|
||||
wantTool: "auth_download_file",
|
||||
check: func(t *testing.T, got map[string]any) {
|
||||
t.Helper()
|
||||
infos, ok := got["fileInfos"].([]map[string]any)
|
||||
if !ok || len(infos) != 1 {
|
||||
t.Fatalf("fileInfos = %#v", got["fileInfos"])
|
||||
}
|
||||
if infos[0]["spaceId"] != json.Number("27827223951") || infos[0]["fileId"] != "file-2" {
|
||||
t.Fatalf("fileInfos[0] = %#v", infos[0])
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "preview authorization",
|
||||
args: []string{
|
||||
"approval", "attachment", "authorize-preview",
|
||||
"--instance-id", "instance-2",
|
||||
"--file-ids", "file-3,file-4",
|
||||
"--with-comment-attachment",
|
||||
},
|
||||
wantTool: "auth_preview_attachment",
|
||||
check: func(t *testing.T, got map[string]any) {
|
||||
t.Helper()
|
||||
if got["processInstanceId"] != "instance-2" || got["withCommentAttachment"] != true {
|
||||
t.Fatalf("preview scalar payload = %#v", got)
|
||||
}
|
||||
ids, ok := got["fileIdList"].([]string)
|
||||
if !ok || len(ids) != 2 || ids[0] != "file-3" || ids[1] != "file-4" {
|
||||
t.Fatalf("fileIdList = %#v", got["fileIdList"])
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "optional boolean omitted",
|
||||
args: []string{
|
||||
"approval", "attachment", "download-url",
|
||||
"--instance-id", "instance-3",
|
||||
"--file-id", "file-5",
|
||||
},
|
||||
wantTool: "get_attachment_download_url",
|
||||
check: func(t *testing.T, got map[string]any) {
|
||||
t.Helper()
|
||||
if _, exists := got["withCommentAttachment"]; exists {
|
||||
t.Fatalf("optional withCommentAttachment should be omitted: %#v", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{},"success":true}`}}}
|
||||
if _, err := executeOAAttachmentCommandCapturingOutput(t, caller, test.args...); err != nil {
|
||||
t.Fatalf("execute command: %v", err)
|
||||
}
|
||||
if caller.server != "oa" || caller.tool != test.wantTool {
|
||||
t.Fatalf("called %s/%s, want oa/%s", caller.server, caller.tool, test.wantTool)
|
||||
}
|
||||
test.check(t, caller.args)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAAttachmentRejectsInvalidFileInfos(t *testing.T) {
|
||||
valid := `{"spaceId":27827223951,"fileId":"file-1"}`
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{name: "malformed JSON", raw: `[{`},
|
||||
{name: "null", raw: `null`},
|
||||
{name: "not array", raw: `{}`},
|
||||
{name: "empty array", raw: `[]`},
|
||||
{name: "more than ten", raw: `[` + strings.Repeat(valid+`,`, 10) + valid + `]`},
|
||||
{name: "missing space ID", raw: `[{"fileId":"file-1"}]`},
|
||||
{name: "string space ID", raw: `[{"spaceId":"27827223951","fileId":"file-1"}]`},
|
||||
{name: "missing file ID", raw: `[{"spaceId":27827223951}]`},
|
||||
{name: "numeric file ID", raw: `[{"spaceId":27827223951,"fileId":232271651278}]`},
|
||||
{name: "blank file ID", raw: `[{"spaceId":27827223951,"fileId":" "}]`},
|
||||
{name: "unknown property", raw: `[{"spaceId":27827223951,"fileId":"file-1","extra":true}]`},
|
||||
{name: "trailing JSON", raw: `[` + valid + `] {}`},
|
||||
{name: "malformed trailing JSON", raw: `[` + valid + `] {`},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := executeOACommand(t, caller,
|
||||
"approval", "attachment", "authorize-download",
|
||||
"--file-infos", test.raw,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("fileInfos %q unexpectedly succeeded", test.raw)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("invalid fileInfos made %d MCP call(s)", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAAttachmentRejectsInvalidPreviewFileIDs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{name: "separator only", raw: `,`},
|
||||
{name: "blank item", raw: `file-1, ,file-2`},
|
||||
{name: "more than twenty", raw: strings.Repeat("file,", 20) + "file"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := executeOACommand(t, caller,
|
||||
"approval", "attachment", "authorize-preview",
|
||||
"--instance-id", "instance-1",
|
||||
"--file-ids", test.raw,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("file IDs %q unexpectedly succeeded", test.raw)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("invalid file IDs made %d MCP call(s)", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
// 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 helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type nativePrimaryParamCall struct {
|
||||
server string
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type nativePrimaryParamCaller struct {
|
||||
calls []nativePrimaryParamCall
|
||||
}
|
||||
|
||||
func (c *nativePrimaryParamCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copied := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
copied[key] = value
|
||||
}
|
||||
c.calls = append(c.calls, nativePrimaryParamCall{server: server, tool: tool, args: copied})
|
||||
|
||||
switch tool {
|
||||
case "init_conversation_file_upload", "init_todo_file_upload":
|
||||
return textToolResult(`{"resourceUrl":"https://upload.invalid/file","uploadKey":"upload-key"}`), nil
|
||||
case "commit_conversation_file_upload":
|
||||
fileName, _ := args["fileName"].(string)
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"dentryId": 123,
|
||||
"spaceId": 456,
|
||||
"downloadUrl": "https://download.invalid/" + fileName,
|
||||
})
|
||||
return textToolResult(string(payload)), nil
|
||||
case "commit_todo_file_upload":
|
||||
return textToolResult(`{"dentryId":123,"spaceId":456}`), nil
|
||||
default:
|
||||
return textToolResult(`{}`), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (*nativePrimaryParamCaller) Format() string { return "json" }
|
||||
func (*nativePrimaryParamCaller) DryRun() bool { return false }
|
||||
func (*nativePrimaryParamCaller) Fields() string { return "" }
|
||||
func (*nativePrimaryParamCaller) JQ() string { return "" }
|
||||
|
||||
func (c *nativePrimaryParamCaller) lastCall(t *testing.T, tool string) nativePrimaryParamCall {
|
||||
t.Helper()
|
||||
for i := len(c.calls) - 1; i >= 0; i-- {
|
||||
if c.calls[i].tool == tool {
|
||||
return c.calls[i]
|
||||
}
|
||||
}
|
||||
t.Fatalf("tool %q not called; calls = %#v", tool, c.calls)
|
||||
return nativePrimaryParamCall{}
|
||||
}
|
||||
|
||||
func executeNativePrimaryTodo(t *testing.T, caller edition.ToolCaller, args ...string) error {
|
||||
t.Helper()
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "todo"}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
})
|
||||
|
||||
cmd := newTodoCommand()
|
||||
if cmd.PersistentFlags().Lookup("yes") == nil {
|
||||
cmd.PersistentFlags().Bool("yes", false, "confirm execution")
|
||||
}
|
||||
if cmd.PersistentFlags().Lookup("dry-run") == nil {
|
||||
cmd.PersistentFlags().Bool("dry-run", false, "preview without executing")
|
||||
}
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(args)
|
||||
return cmd.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
func findNativePrimaryLeaf(t *testing.T, root *cobra.Command, path ...string) *cobra.Command {
|
||||
t.Helper()
|
||||
leaf, remaining, err := root.Find(path)
|
||||
if err != nil {
|
||||
t.Fatalf("find %v: %v", path, err)
|
||||
}
|
||||
if len(remaining) != 0 {
|
||||
t.Fatalf("find %v left arguments %v", path, remaining)
|
||||
}
|
||||
return leaf
|
||||
}
|
||||
|
||||
func assertNativePrimaryFlagPair(t *testing.T, cmd *cobra.Command, primary, legacy string) {
|
||||
t.Helper()
|
||||
primaryFlag := cmd.Flags().Lookup(primary)
|
||||
if primaryFlag == nil || primaryFlag.Hidden {
|
||||
t.Fatalf("%s --%s = %#v, want visible Primary", cmd.CommandPath(), primary, primaryFlag)
|
||||
}
|
||||
legacyFlag := cmd.Flags().Lookup(legacy)
|
||||
if legacyFlag == nil || !legacyFlag.Hidden {
|
||||
t.Fatalf("%s --%s = %#v, want hidden compatibility alias", cmd.CommandPath(), legacy, legacyFlag)
|
||||
}
|
||||
if got := legacyFlag.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != primary {
|
||||
t.Fatalf("%s --%s alias_of = %#v, want %q", cmd.CommandPath(), legacy, got, primary)
|
||||
}
|
||||
if got := legacyFlag.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
|
||||
t.Fatalf("%s --%s alias_origin = %#v, want %q", cmd.CommandPath(), legacy, got, runtimeannotate.FlagAliasOriginCorecmdV1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativePrimaryParamFlagSurfaces(t *testing.T) {
|
||||
aisearch := newAisearchCommand()
|
||||
assertNativePrimaryFlagPair(t, aisearch, "query", "keyword")
|
||||
person := findNativePrimaryLeaf(t, aisearch, "person")
|
||||
assertNativePrimaryFlagPair(t, person, "query", "keyword")
|
||||
for _, cmd := range []*cobra.Command{aisearch, person} {
|
||||
if got := cmd.Flags().Lookup("keyword").Shorthand; got != "w" {
|
||||
t.Fatalf("%s --keyword shorthand = %q, want preserved -w", cmd.CommandPath(), got)
|
||||
}
|
||||
if got := cmd.Flags().Lookup("query").Shorthand; got != "" {
|
||||
t.Fatalf("%s --query shorthand = %q, want none", cmd.CommandPath(), got)
|
||||
}
|
||||
}
|
||||
|
||||
chat := newChatCommand()
|
||||
send := findNativePrimaryLeaf(t, chat, "message", "send")
|
||||
assertNativePrimaryFlagPair(t, send, "content", "text")
|
||||
assertNativePrimaryFlagPair(t, send, "file", "file-path")
|
||||
sendByWebhook := findNativePrimaryLeaf(t, chat, "message", "send-by-webhook")
|
||||
assertNativePrimaryFlagPair(t, sendByWebhook, "content", "text")
|
||||
sendByBot := findNativePrimaryLeaf(t, chat, "message", "send-by-bot")
|
||||
for _, name := range []string{"text", "file-path"} {
|
||||
flag := sendByBot.Flags().Lookup(name)
|
||||
if flag == nil || flag.Hidden {
|
||||
t.Fatalf("%s --%s = %#v, want unchanged visible Primary", sendByBot.CommandPath(), name, flag)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"content", "file"} {
|
||||
if flag := sendByBot.Flags().Lookup(name); flag != nil && !flag.Hidden {
|
||||
t.Fatalf("%s --%s = %#v, pending migration must not become visible", sendByBot.CommandPath(), name, flag)
|
||||
}
|
||||
}
|
||||
reply := findNativePrimaryLeaf(t, chat, "message", "reply")
|
||||
assertNativePrimaryFlagPair(t, reply, "group", "conversation-id")
|
||||
assertNativePrimaryFlagPair(t, reply, "content", "text")
|
||||
|
||||
doc := newDocCommand()
|
||||
assertNativePrimaryFlagPair(t, findNativePrimaryLeaf(t, doc, "block", "insert"), "content", "text")
|
||||
assertNativePrimaryFlagPair(t, findNativePrimaryLeaf(t, doc, "block", "update"), "content", "text")
|
||||
|
||||
todo := newTodoCommand()
|
||||
assertNativePrimaryFlagPair(t, findNativePrimaryLeaf(t, todo, "task", "add-attachment"), "file", "file-path")
|
||||
}
|
||||
|
||||
type nativePrimaryParamSpelling struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}
|
||||
|
||||
func nativePrimaryParamSpellings(primary, legacy string) []nativePrimaryParamSpelling {
|
||||
return []nativePrimaryParamSpelling{
|
||||
{name: "legacy only", args: []string{"--" + legacy, "legacy-value"}, want: "legacy-value"},
|
||||
{name: "primary only", args: []string{"--" + primary, "primary-value"}, want: "primary-value"},
|
||||
{name: "both legacy wins", args: []string{"--" + primary, "primary-value", "--" + legacy, "legacy-value"}, want: "legacy-value"},
|
||||
}
|
||||
}
|
||||
|
||||
func nativePrimaryJSONField(t *testing.T, raw any, field string) string {
|
||||
t.Helper()
|
||||
encoded, ok := raw.(string)
|
||||
if !ok {
|
||||
t.Fatalf("payload = %#v, want JSON string", raw)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(encoded), &payload); err != nil {
|
||||
t.Fatalf("decode payload %q: %v", encoded, err)
|
||||
}
|
||||
value, _ := payload[field].(string)
|
||||
return value
|
||||
}
|
||||
|
||||
func nativePrimaryParagraphText(t *testing.T, call nativePrimaryParamCall) string {
|
||||
t.Helper()
|
||||
element, ok := call.args["element"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("%s element = %#v", call.tool, call.args["element"])
|
||||
}
|
||||
paragraph, ok := element["paragraph"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("%s paragraph = %#v", call.tool, element["paragraph"])
|
||||
}
|
||||
text, _ := paragraph["text"].(string)
|
||||
return text
|
||||
}
|
||||
|
||||
func TestNativePrimaryParamTextPayloadCompatibility(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
})
|
||||
|
||||
t.Run("chat message send text to content", func(t *testing.T) {
|
||||
for _, spelling := range nativePrimaryParamSpellings("content", "text") {
|
||||
t.Run(spelling.name, func(t *testing.T) {
|
||||
caller := &nativePrimaryParamCaller{}
|
||||
args := []string{"message", "send", "--group", "cid", "--title", "fixed-title"}
|
||||
args = append(args, spelling.args...)
|
||||
if err := runChatCoverageCommand(t, caller, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call := caller.lastCall(t, "send_personal_message")
|
||||
if got := nativePrimaryJSONField(t, call.args["content"], "text"); got != spelling.want {
|
||||
t.Fatalf("content.text = %q, want %q; payload = %#v", got, spelling.want, call.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("chat message send-by-webhook text to content", func(t *testing.T) {
|
||||
for _, spelling := range nativePrimaryParamSpellings("content", "text") {
|
||||
t.Run(spelling.name, func(t *testing.T) {
|
||||
caller := &nativePrimaryParamCaller{}
|
||||
args := []string{"message", "send-by-webhook", "--token", "token", "--title", "fixed-title"}
|
||||
args = append(args, spelling.args...)
|
||||
if err := runChatCoverageCommand(t, caller, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call := caller.lastCall(t, "send_message_by_custom_robot")
|
||||
if got, _ := call.args["text"].(string); got != spelling.want {
|
||||
t.Fatalf("text = %q, want %q; payload = %#v", got, spelling.want, call.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("chat message reply conversation-id to group", func(t *testing.T) {
|
||||
for _, spelling := range nativePrimaryParamSpellings("group", "conversation-id") {
|
||||
t.Run(spelling.name, func(t *testing.T) {
|
||||
caller := &nativePrimaryParamCaller{}
|
||||
args := []string{"message", "reply", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID, "--content", "fixed-content"}
|
||||
args = append(args, spelling.args...)
|
||||
if err := runChatCoverageCommand(t, caller, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call := caller.lastCall(t, "send_personal_message")
|
||||
if got, _ := call.args["openConversationId"].(string); got != spelling.want {
|
||||
t.Fatalf("openConversationId = %q, want %q; payload = %#v", got, spelling.want, call.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("chat message reply text to content", func(t *testing.T) {
|
||||
for _, spelling := range nativePrimaryParamSpellings("content", "text") {
|
||||
t.Run(spelling.name, func(t *testing.T) {
|
||||
caller := &nativePrimaryParamCaller{}
|
||||
args := []string{"message", "reply", "--group", "cid", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID}
|
||||
args = append(args, spelling.args...)
|
||||
if err := runChatCoverageCommand(t, caller, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call := caller.lastCall(t, "send_personal_message")
|
||||
if got := nativePrimaryJSONField(t, call.args["content"], "content"); got != spelling.want {
|
||||
t.Fatalf("reply content = %q, want %q; payload = %#v", got, spelling.want, call.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
for _, command := range []struct {
|
||||
name string
|
||||
path []string
|
||||
tool string
|
||||
}{
|
||||
{name: "doc block insert text to content", path: []string{"block", "insert", "--node", "node"}, tool: "insert_document_block"},
|
||||
{name: "doc block update text to content", path: []string{"block", "update", "--node", "node", "--block-id", "block"}, tool: "update_document_block"},
|
||||
} {
|
||||
command := command
|
||||
t.Run(command.name, func(t *testing.T) {
|
||||
for _, spelling := range nativePrimaryParamSpellings("content", "text") {
|
||||
t.Run(spelling.name, func(t *testing.T) {
|
||||
caller := &nativePrimaryParamCaller{}
|
||||
args := append(append([]string(nil), command.path...), spelling.args...)
|
||||
if err := runDocCoverageCommand(t, caller, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call := caller.lastCall(t, command.tool)
|
||||
if got := nativePrimaryParagraphText(t, call); got != spelling.want {
|
||||
t.Fatalf("paragraph text = %q, want %q; payload = %#v", got, spelling.want, call.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativePrimaryParamFilePayloadCompatibility(t *testing.T) {
|
||||
previousDeps, previousPut, previousArgs := deps, httpPutFile, os.Args
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
httpPutFile = previousPut
|
||||
os.Args = previousArgs
|
||||
})
|
||||
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
|
||||
|
||||
dir := t.TempDir()
|
||||
legacyPath := filepath.Join(dir, "legacy-file.txt")
|
||||
primaryPath := filepath.Join(dir, "primary-file.txt")
|
||||
if err := os.WriteFile(legacyPath, []byte("legacy"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(primaryPath, []byte("primary"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spellings := []nativePrimaryParamSpelling{
|
||||
{name: "legacy only", args: []string{"--file-path", legacyPath}, want: filepath.Base(legacyPath)},
|
||||
{name: "primary only", args: []string{"--file", primaryPath}, want: filepath.Base(primaryPath)},
|
||||
{name: "both legacy wins", args: []string{"--file", primaryPath, "--file-path", legacyPath}, want: filepath.Base(legacyPath)},
|
||||
}
|
||||
|
||||
t.Run("chat message send file-path to file", func(t *testing.T) {
|
||||
os.Args = []string{"dws", "chat"}
|
||||
for _, spelling := range spellings {
|
||||
t.Run(spelling.name, func(t *testing.T) {
|
||||
caller := &nativePrimaryParamCaller{}
|
||||
args := []string{"message", "send", "--group", "cid", "--msg-type", "file"}
|
||||
args = append(args, spelling.args...)
|
||||
if err := runChatCoverageCommand(t, caller, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call := caller.lastCall(t, "send_personal_message")
|
||||
if got := nativePrimaryJSONField(t, call.args["content"], "fileName"); got != spelling.want {
|
||||
t.Fatalf("fileName = %q, want %q; payload = %#v", got, spelling.want, call.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("todo task add-attachment file-path to file", func(t *testing.T) {
|
||||
os.Args = []string{"dws", "todo"}
|
||||
for _, spelling := range spellings {
|
||||
t.Run(spelling.name, func(t *testing.T) {
|
||||
caller := &nativePrimaryParamCaller{}
|
||||
args := []string{"task", "add-attachment", "--task-id", "42"}
|
||||
args = append(args, spelling.args...)
|
||||
if err := executeNativePrimaryTodo(t, caller, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
call := caller.lastCall(t, "add_todo_attachment")
|
||||
request, ok := call.args["todoAttachmentAddRequest"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("request = %#v", call.args)
|
||||
}
|
||||
attachments, ok := request["attachmentList"].([]any)
|
||||
if !ok || len(attachments) != 1 {
|
||||
t.Fatalf("attachments = %#v", request["attachmentList"])
|
||||
}
|
||||
attachment, ok := attachments[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("attachment = %#v", attachments[0])
|
||||
}
|
||||
if got, _ := attachment["fileName"].(string); got != spelling.want {
|
||||
t.Fatalf("fileName = %q, want %q; payload = %#v", got, spelling.want, call.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func nativePrimaryErrorMentionsFlag(message, name string) bool {
|
||||
return strings.Contains(message, "--"+name) || strings.Contains(message, fmt.Sprintf("%q", name))
|
||||
}
|
||||
|
||||
func assertNativePrimaryErrorHint(t *testing.T, err error, primary, legacy string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("missing --%s returned nil", primary)
|
||||
}
|
||||
message := err.Error()
|
||||
if !nativePrimaryErrorMentionsFlag(message, primary) {
|
||||
t.Fatalf("error %q does not recommend --%s", message, primary)
|
||||
}
|
||||
if nativePrimaryErrorMentionsFlag(message, legacy) {
|
||||
t.Fatalf("error %q still recommends legacy --%s", message, legacy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativePrimaryParamMissingErrorsRecommendPrimaryOnly(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
})
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
assertNativePrimaryErrorHint(t, executeFilterCoverage(t, newAisearchCommand(), "person"), "query", "keyword")
|
||||
|
||||
os.Args = []string{"dws", "chat"}
|
||||
assertNativePrimaryErrorHint(t,
|
||||
runChatCoverageCommand(t, &nativePrimaryParamCaller{}, "message", "send", "--group", "cid"),
|
||||
"content", "text")
|
||||
assertNativePrimaryErrorHint(t,
|
||||
runChatCoverageCommand(t, &nativePrimaryParamCaller{}, "message", "send-by-webhook", "--token", "token", "--title", "title"),
|
||||
"content", "text")
|
||||
assertNativePrimaryErrorHint(t,
|
||||
runChatCoverageCommand(t, &nativePrimaryParamCaller{}, "message", "reply", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID, "--content", "body"),
|
||||
"group", "conversation-id")
|
||||
assertNativePrimaryErrorHint(t,
|
||||
runChatCoverageCommand(t, &nativePrimaryParamCaller{}, "message", "reply", "--group", "cid", "--ref-msg-id", "mid", "--ref-sender", helperCurrentDOpenID),
|
||||
"content", "text")
|
||||
|
||||
os.Args = []string{"dws", "doc"}
|
||||
assertNativePrimaryErrorHint(t,
|
||||
runDocCoverageCommand(t, &nativePrimaryParamCaller{}, "block", "insert", "--node", "node"),
|
||||
"content", "text")
|
||||
assertNativePrimaryErrorHint(t,
|
||||
runDocCoverageCommand(t, &nativePrimaryParamCaller{}, "block", "update", "--node", "node", "--block-id", "block"),
|
||||
"content", "text")
|
||||
|
||||
os.Args = []string{"dws", "todo"}
|
||||
assertNativePrimaryErrorHint(t,
|
||||
executeTodoEdge(t, &scriptedToolCaller{}, "task", "add-attachment", "--task-id", "42"),
|
||||
"file", "file-path")
|
||||
}
|
||||
|
||||
func TestNativePrimaryParamPromotionErrorsPropagate(t *testing.T) {
|
||||
chat := newChatCommand()
|
||||
assertPromotionError := func(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil || !strings.Contains(err.Error(), "trying to get string value of flag of type bool") {
|
||||
t.Fatalf("promotion error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("webhook RunE", func(t *testing.T) {
|
||||
target := findNativePrimaryLeaf(t, chat, "message", "send-by-webhook")
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().String("content", "", "")
|
||||
cmd.Flags().Bool("text", false, "")
|
||||
if err := cmd.Flags().Set("text", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPromotionError(t, target.RunE(cmd, nil))
|
||||
})
|
||||
|
||||
reply := findNativePrimaryLeaf(t, chat, "message", "reply")
|
||||
newInvalidGroupCommand := func(t *testing.T) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().String("group", "", "")
|
||||
cmd.Flags().Bool("conversation-id", false, "")
|
||||
if err := cmd.Flags().Set("conversation-id", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
t.Run("reply PreRunE group", func(t *testing.T) {
|
||||
assertPromotionError(t, reply.PreRunE(newInvalidGroupCommand(t), nil))
|
||||
})
|
||||
|
||||
t.Run("reply RunE group", func(t *testing.T) {
|
||||
assertPromotionError(t, reply.RunE(newInvalidGroupCommand(t), nil))
|
||||
})
|
||||
|
||||
t.Run("reply RunE content", func(t *testing.T) {
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().String("group", "cid", "")
|
||||
cmd.Flags().String("conversation-id", "", "")
|
||||
cmd.Flags().String("content", "", "")
|
||||
cmd.Flags().Bool("text", false, "")
|
||||
if err := cmd.Flags().Set("text", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPromotionError(t, reply.RunE(cmd, nil))
|
||||
})
|
||||
}
|
||||
@@ -304,5 +304,6 @@ func newSheetCommentCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
commentCmd.AddCommand(commentListCmd, commentCreateCmd, commentReplyCmd, commentUpdateCmd, commentDeleteCmd)
|
||||
commentCmd.AddCommand(newCommentBaseCommands("sheet")...)
|
||||
return commentCmd
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package helpers
|
||||
|
||||
import "strings"
|
||||
|
||||
// shellQuoteArg 按 POSIX sh 规则把 s 引用成可安全放进可复制命令的单个 argv 元素。
|
||||
// ShellQuoteArg 按 POSIX sh 规则把 s 引用成可安全放进可复制命令的单个 argv 元素。
|
||||
//
|
||||
// 为什么需要它:错误提示里的恢复命令是给用户直接复制到 shell 执行的,其中的查询域取自用户
|
||||
// 输入(--workspace 的常见形态就是带查询串的 URL)。裸拼接下,合法 URL 里的 `&` 就会把命令
|
||||
@@ -21,7 +21,7 @@ import "strings"
|
||||
//
|
||||
// 空串必须显式引用成一对空单引号,否则该参数会从 argv 里整个消失,后面的 token 会被前一个
|
||||
// flag 吞掉。
|
||||
func shellQuoteArg(s string) string {
|
||||
func ShellQuoteArg(s string) string {
|
||||
if s == "" {
|
||||
return "''"
|
||||
}
|
||||
@@ -41,7 +41,7 @@ func shellValueIsBare(s string) bool {
|
||||
// driveLatestPosixScopeValue 是 POSIX shell 下把用户值放进可复制命令的策略:单引号内 sh 不做
|
||||
// 任何展开,因此含元字符的值引用后即可安全内联。第二个返回值恒为 true。
|
||||
func driveLatestPosixScopeValue(value string) (string, bool) {
|
||||
return shellQuoteArg(value), true
|
||||
return ShellQuoteArg(value), true
|
||||
}
|
||||
|
||||
// driveLatestWindowsScopeValue 是 Windows 构建下的策略:只内联本身就安全的值,其余一律不进
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user