Compare commits

...
Author SHA1 Message Date
Dennis bee1a91b74 fix(calendar): keep pagination out of result data 2026-08-18 00:41:34 +08:00
Dennis 92c80f81f9 fix(calendar): align attendee and agenda contracts 2026-08-17 21:30:31 +08:00
Dennis d245ea4c84 Merge remote-tracking branch 'origin/main' into codex/calendar-todo-shortcut-alignment 2026-08-17 20:30:27 +08:00
dxb 9e3a5d6fbd Merge pull request #1029 from DingTalk-Real-AI/fix/chat-sender-identity-contract
fix(chat): preserve unverified sender identity semantics
2026-08-17 19:10:30 +08:00
Dennis 33623d09d9 Merge remote-tracking branch 'origin/main' into codex/calendar-todo-shortcut-alignment 2026-08-17 18:48:47 +08:00
Dennis b20055a0b5 test(shortcut): close calendar todo coverage gaps 2026-08-17 18:48:39 +08:00
栩朝 fc05976d33 fix(chat): align chat message selection intent 2026-08-17 17:30:12 +08:00
栩朝 021da02474 fix(chat): preserve unverified sender identity semantics 2026-08-17 17:30:12 +08:00
github-actions[bot] a5b9e5a13f Merge pull request #928 from Anonymity-0/feat/bot-group-reply
feat(chat): support bot group message replies
2026-08-17 17:25:23 +08:00
Dennis 3dce49020e docs(shortcut): refresh integrated gate counts 2026-08-17 17:21:53 +08:00
Dennis fac92c252e Merge remote-tracking branch 'origin/main' into codex/calendar-todo-shortcut-alignment 2026-08-17 17:10:11 +08:00
Anonymity-0 9f8c525008 Merge branch 'main' into feat/bot-group-reply 2026-08-17 16:59:05 +08:00
github-actions[bot] 207d4dd7e5 Merge pull request #980 from cywan1998/feat/calendar-event-share-info
feat(calendar): add event share-info command
2026-08-17 08:57:50 +00:00
Dennis 8db297fe4b fix(calendar): preserve agenda schema compatibility 2026-08-17 16:53:07 +08:00
Dennis dc2aec7696 fix(calendar): preserve room-find flag compatibility 2026-08-17 16:44:06 +08:00
fengbai 9a6b7d4d41 Merge branch 'main' into feat/calendar-event-share-info 2026-08-17 16:41:08 +08:00
Dennis 404af112b7 fix(release): format shortcut change fragment 2026-08-17 16:09:19 +08:00
前津 5947016cc1 feat(chat): support bot group message replies 2026-08-17 16:09:08 +08:00
Dennis 5425d1565f feat(shortcut): align calendar and todo workflows 2026-08-17 16:01:14 +08:00
github-actions[bot] 386426bb92 Merge pull request #1012 from DingTalk-Real-AI/dws_0814_1723
fix(skill): update doc and drive descriptions for clearer routing
2026-08-17 07:45:22 +00:00
john 3cea671a54 Merge branch 'main' into dws_0814_1723 2026-08-17 15:27:17 +08:00
github-actions[bot] f06ea4d9e2 Merge pull request #960 from DingTalk-Real-AI/codex/doc-reread-audit
fix(doc): harden mutation readback verification
2026-08-17 07:06:53 +00:00
Dennis a82d945f54 fix(doc): reject explicit revert failure states 2026-08-17 14:48:13 +08:00
Dennis 6846326445 fix(doc): reject revert request echo evidence 2026-08-17 14:48:11 +08:00
Dennis 54c2054a5c fix(doc): ignore generated JSONML defaults 2026-08-17 14:48:09 +08:00
Dennis e5bf332b05 fix(doc): address readback review findings 2026-08-17 14:48:06 +08:00
Dennis 9ed55978d9 fix(doc): cancel readback retry waits 2026-08-17 14:48:04 +08:00
Dennis 7ffbbc4a51 test(doc): cover stable pagination identities 2026-08-17 14:48:02 +08:00
Dennis 2db73a8185 fix(doc): distinguish identical pagination pages 2026-08-17 14:48:00 +08:00
Dennis a62332be93 fix(doc): trust only explicit inserted block IDs 2026-08-17 14:47:58 +08:00
Dennis 1083093cbc test(doc): complete readback coverage evidence 2026-08-17 14:47:56 +08:00
Dennis c6ebe307cd fix(doc): verify inline media from jsonml readback 2026-08-17 14:47:54 +08:00
Dennis 3ee66d4373 fix(doc): harden mutation readback verification 2026-08-17 14:47:51 +08:00
github-actions[bot] a0be395ccc Merge pull request #1006 from DingTalk-Real-AI/codex/fix-aitable-pagination-minutes-unshare
fix(shortcut): harden Aitable pagination and Minutes unshare
2026-08-17 06:37:16 +00:00
ruigong 93dbd768f7 fix(skill): add explicit recent-edited route to drive SOP-1 2026-08-17 14:18:03 +08:00
Dennis c1a549cd64 fix: close delete readback continuations 2026-08-17 14:13:51 +08:00
Dennis 5a414999ef fix: validate record query previews 2026-08-17 14:13:49 +08:00
Dennis 7aa8240629 fix: preserve record query preview contract 2026-08-17 14:13:47 +08:00
Dennis 37b9a1dc31 fix: bound exact aitable record queries 2026-08-17 14:13:45 +08:00
Dennis 2ab8748c4d test: use native minutes path separators 2026-08-17 14:13:43 +08:00
Dennis f041275811 fix: make minutes polling portable 2026-08-17 14:13:41 +08:00
Dennis f486105836 fix: bound empty aitable pagination 2026-08-17 14:13:38 +08:00
Dennis e14de2b4c2 test: close shortcut fix review gates 2026-08-17 14:13:36 +08:00
Dennis fe2f3ca92f fix: harden aitable pagination and minutes unshare 2026-08-17 14:13:33 +08:00
github-actions[bot] 8e4519cacd Merge pull request #1014 from FloralTide/codex/fix-windows-event-bus
fix(event): support Windows bus lifecycle
2026-08-17 14:12:46 +08:00
炳昱 16abb481e8 Merge remote-tracking branch 'upstream/main' into codex/fix-windows-event-bus 2026-08-17 13:00:07 +08:00
炳昱 7ad82bbf0a fix(event): accept bus exit at stop timeout boundary 2026-08-17 13:00:07 +08:00
chichuan 104eb715c4 Merge pull request #989 from maoqxxmm/codex/sheet-dropdown-source-range
feat(sheet): support SourceRange dropdowns and read completion
2026-08-17 12:19:00 +08:00
chichuan 97ea887ea5 Merge branch 'main' into codex/sheet-dropdown-source-range 2026-08-17 11:49:42 +08:00
RuiGong01 03838a3430 Merge branch 'main' into dws_0814_1723 2026-08-17 11:39:56 +08:00
github-actions[bot] bfeb9f6af0 chore: update beta formula for v1.0.59-beta.2 [skip ci] 2026-08-17 03:35:41 +00:00
毛球 e26f278112 Merge branch 'main' into codex/sheet-dropdown-source-range 2026-08-17 11:17:45 +08:00
RuiGong01 0d34150333 Merge branch 'main' into dws_0814_1723 2026-08-17 11:16:56 +08:00
chichuan e6b5938bd8 Merge pull request #1025 from DingTalk-Real-AI/codex/changelog-v1.0.59-beta.2
docs: seal changelog for v1.0.59-beta.2
2026-08-17 11:03:27 +08:00
chichuan 4f95373420 docs: seal changelog for v1.0.59-beta.2 2026-08-17 10:59:11 +08:00
炳昱 afb90009f6 Merge remote-tracking branch 'upstream/main' into codex/fix-windows-event-bus 2026-08-17 10:49:25 +08:00
RuiGong01 9e8b58cbb6 Merge branch 'main' into dws_0814_1723 2026-08-17 10:47:33 +08:00
github-actions[bot] 6411d26a95 Merge pull request #1023 from DingTalk-Real-AI/fix/app-partition-parallel-jobs
fix(ci): parallelize app test partitions and drop race from the schema partition
2026-08-17 02:45:57 +00:00
毛球 31117d1b89 Merge branch 'main' into codex/sheet-dropdown-source-range 2026-08-17 10:43:01 +08:00
炳昱 e3553fe7a5 test(event): cover bus ownership validation failures 2026-08-17 10:40:21 +08:00
RuiGong01 fe724e96e8 Merge branch 'main' into dws_0814_1723 2026-08-17 10:39:02 +08:00
炳昱 067aff179f Merge remote-tracking branch 'upstream/main' into codex/fix-windows-event-bus 2026-08-17 10:32:08 +08:00
炳昱 353454abb2 fix(event): verify bus owner before fallback stop 2026-08-17 10:32:04 +08:00
chichuan 96b9cbce02 Merge branch 'main' into fix/app-partition-parallel-jobs 2026-08-17 10:22:20 +08:00
chichuan 36877d00dc Merge pull request #1024 from DingTalk-Real-AI/perf/schema-json-projection
perf: skip redundant JSON validation when projecting typed Schema values
2026-08-17 10:21:48 +08:00
xiatian a9a97c2746 Merge remote-tracking branch 'upstream/main' into codex/sheet-dropdown-source-range 2026-08-17 09:43:54 +08:00
RuiGong01 f72979f4a9 Merge branch 'main' into dws_0814_1723 2026-08-17 09:41:16 +08:00
chichuan 55d94d3b58 perf: skip redundant JSON validation when projecting typed Schema values
typedJSONValue marshaled a typed value and then routed the result through
rawJSONValue, which runs json.Valid before decoding. On that path the input is
whatever json.Marshal has just produced, so the validation scan can only ever
succeed: it re-read every marshaled document for nothing.

The decode step is now shared by both entry points. rawJSONValue keeps its
json.Valid check, because it still accepts untrusted input, while typedJSONValue
decodes what it marshaled directly. Across the 1121-tool set this removes about a
third of the Schema Catalog projection work: the internal/app schema suite goes
from 26.0s to 17.2s uninstrumented, and from 291.1s to 241.0s under -race.

The delivered Catalog is byte-for-byte unchanged. check-generated-drift,
check-schema-catalog and check-schema-binary each regenerate the same
source_hash sha256:93b8d44eb163bd2898c78397d22af92d378e3dc4e20f56b33277b51e4342e2e6,
and the two error contracts are preserved: typedJSONValue still rejects a value
json.Marshal cannot encode, and rawJSONValue still rejects invalid JSON.
2026-08-16 22:21:16 +08:00
chichuan bfd0976b31 fix(ci): run the app test partitions as parallel shards
The five internal/app partitions ran end to end inside one job, so the app
shard's wall clock was the sum of all five: 780s in CI, of which the schema
partition owned 357s. Each partition is now its own matrix shard, so they run
concurrently and the shard's wall clock is set by its slowest partition rather
than by their total. Every partition shard still selects the same single
internal/app package, so the impacted-package query maps the shard name back to
app and the partition only chooses which tests run.

The helper gains a partition argument and a list-partitions mode. APP_PARTITIONS
is the single source of truth for the set, and the discovery pass still runs in
every job, so each one independently verifies that the partition patterns cover
every top-level test exactly once before running the one it was asked for.

Two fail-closed checks guard the split, because the helper's own coverage check
can no longer prove the whole package ran once the partitions are separate jobs:

- The helper cross-checks APP_PARTITIONS against the coverage counters in both
  directions, so a counted partition that nothing dispatches and a dispatchable
  partition with no counter both fail instead of silently skipping tests.
- TestCIAppRacePartitionMatrixMatchesHelper pins the workflow's app-<partition>
  shards to list-partitions output in both directions, so a partition cannot
  lose its job while every job stays green.

The discovery loop variable is renamed from partition to spec: it would
otherwise shadow the partition requested on the command line, which run mode
reads after the discovery pass completes.
2026-08-16 22:18:04 +08:00
chichuan 4a33e7e893 fix(ci): drop race instrumentation from the app schema partition
The schema partition's 52 tests assert structural Schema-to-Cobra contracts over
a single goroutine: none of them call t.Parallel or start a goroutine, so the
race detector has no concurrent access to observe there. The process-global lazy
metadata that does need race coverage (schema_source_root's atomic.Value, the
parameter-binding lazy loaders) is exercised by internal/cli's concurrent tests,
which stay instrumented.

The instrumentation was not free here. The partition shares a single sync.Once
Catalog build whose work is allocation-heavy, and -race made it roughly 11x
slower: 26s -> 291s locally, and 357s of the app shard's 780s in CI. Within that
partition TestFinalSchemaToolsHaveExecutableBaseCommands alone accounted for
262s, not because the test is expensive but because it is the first caller to pay
for the shared snapshot; its 1121 subtests together measure 0.00s.

run_partition now takes the instrumentation mode explicitly and fails closed on
an unrecognized value, so a typo cannot silently drop -race from a partition that
is supposed to carry it.
2026-08-16 22:17:14 +08:00
github-actions[bot] ee74765383 Merge pull request #1019 from DingTalk-Real-AI/feat/help-feedback-entry
feat: add feedback survey entry to root help
2026-08-16 08:09:01 +08:00
xiatian 9fbd8addbe Merge remote-tracking branch 'upstream/main' into codex/sheet-dropdown-source-range 2026-08-15 13:40:55 +08:00
xiatian 92195a58a3 fix(sheet): align SourceRange review contract 2026-08-15 13:40:47 +08:00
RuiGong01 0b012788c7 Merge branch 'main' into dws_0814_1723 2026-08-14 20:15:22 +08:00
炳昱 e742a6c269 Merge remote-tracking branch 'upstream/main' into codex/fix-windows-event-bus 2026-08-14 19:40:58 +08:00
chichuan 05868610f0 Merge branch 'main' into codex/sheet-dropdown-source-range 2026-08-14 18:33:24 +08:00
炳昱 22649e96ef test(event): cover Unix spawn validation on Windows 2026-08-14 18:11:42 +08:00
炳昱 f68a11f11d test(event): cover Windows lifecycle edges 2026-08-14 18:05:34 +08:00
炳昱 abe5129306 fix(event): support Windows bus lifecycle 2026-08-14 17:56:51 +08:00
chichuan 7b7bd556e9 Merge branch 'main' into feat/calendar-event-share-info 2026-08-14 17:43:49 +08:00
ruigong d534ee242c fix(skill): scope doc/drive descriptions to entity-content vs file management 2026-08-14 17:33:54 +08:00
fengbai 90473284b8 fix(calendar): remove shell comment from share-info example
- Move the eventId lookup hint into Long description
- Keep example commands free of shell comments to pass example policy gate
2026-08-14 15:51:20 +08:00
fengbai 07aa2c883a fix(calendar): address CR comments for share-info
- Fix Example indentation (tab -> 2 spaces)
- Remove unsubstantiated default en-US from --language help/docs
- Add test asserting calendarId/language are omitted when only --id is passed
2026-08-14 15:10:20 +08:00
xiatian 8cd2b0259d Merge remote-tracking branch 'upstream/main' into codex/sheet-dropdown-source-range 2026-08-13 18:04:51 +08:00
xiatian 4b8d94c24e ci: retry interrupted app race shard 2026-08-13 17:15:22 +08:00
xiatian 76e5a8c4d9 Merge remote-tracking branch 'upstream/main' into codex/sheet-dropdown-source-range 2026-08-13 16:37:12 +08:00
xiatian 6abffce4e5 fix(sheet): preserve dropdown schema compatibility 2026-08-13 16:13:30 +08:00
xiatian 86b78e45d7 Merge remote-tracking branch 'upstream/main' into codex/sheet-dropdown-source-range 2026-08-13 16:08:28 +08:00
xiatian 5065e4bfb6 Merge remote-tracking branch 'upstream/main' into codex/sheet-dropdown-source-range 2026-08-13 13:49:52 +08:00
xiatian 2778bef5bd feat(sheet): support source range dropdowns and read completion 2026-08-13 11:46:58 +08:00
fengbai 8aee08268d test(calendar): add event share-info dry-run and required-flag tests 2026-08-12 21:17:32 +08:00
fengbai 6a4744073c feat(calendar): add event share-info command 2026-08-12 21:07:59 +08:00
151 changed files with 11919 additions and 1020 deletions
@@ -0,0 +1,11 @@
---
category: 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.
+5
View File
@@ -0,0 +1,5 @@
---
category: 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.
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **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.
+5
View File
@@ -0,0 +1,5 @@
---
category: Added
---
- **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`.
@@ -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,5 @@
---
category: Fixed
---
- **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.
+5
View File
@@ -0,0 +1,5 @@
---
category: Changed
---
- **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.
@@ -0,0 +1,8 @@
---
category: Changed
---
- **Faster Schema Catalog assembly** — projects typed values into payload JSON
without re-running a validation scan over documents `json.Marshal` has just
produced, cutting roughly a third of the projection work across the full tool
set. Untrusted JSON input keeps its existing validation.
+6
View File
@@ -0,0 +1,6 @@
---
category: Added
---
- **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.
+5
View File
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **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.
+63 -22
View File
@@ -523,12 +523,22 @@ jobs:
# shards at full-suite scope; release-scripts is included because its
# dedicated job only runs at full-suite or release-sensitive scope, and
# dropping it here would stop testing test/scripts changes entirely.
# internal/app is carried by one shard per bounded partition rather than a
# single app shard: the partitions used to run end to end inside one job,
# where the Schema partition alone owned most of the wall clock. The
# app-<partition> names are pinned to the helper's partition set by
# TestCIAppRacePartitionMatrixMatchesHelper, so a partition can never lose
# its job silently.
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shard:
- app
- app-schema
- app-a-b
- app-c
- app-d-r
- app-s-z-example-fuzz
- generators
- helpers
- cli
@@ -570,9 +580,16 @@ jobs:
TEST_SHARD: ${{ matrix.shard }}
run: |
set -euo pipefail
# Every app partition shard tests the same single internal/app
# package, so the impacted-package query uses the base shard name and
# the partition only selects which tests run.
package_shard="$TEST_SHARD"
case "$TEST_SHARD" in
app-*) package_shard=app ;;
esac
package_output="$(
./scripts/ci/changed-test-packages.sh \
list-shard "$TEST_SHARD" "$TEST_BASE_REF" "$TEST_HEAD_REF"
list-shard "$package_shard" "$TEST_BASE_REF" "$TEST_HEAD_REF"
)"
if [ -z "$package_output" ]; then
echo "No buildable Go package in shard $TEST_SHARD is affected by this revision." \
@@ -612,14 +629,19 @@ jobs:
exit 1
}
done
if [ "$TEST_SHARD" = "app" ]; then
# A single long-lived app test process retains every constructed
# command tree in framework registries. Isolate Schema assembly and
# bounded name ranges so each process releases that state on exit.
test "${#packages[@]}" -eq 1
./scripts/ci/run-app-race-tests.sh run "${packages[0]}"
exit 0
fi
case "$TEST_SHARD" in
app-*)
# A single long-lived app test process retains every constructed
# command tree in framework registries. Each partition is its own
# job, so that state is released when the process exits and the
# partitions run concurrently instead of end to end. The helper
# still verifies that the partition patterns cover every top-level
# test exactly once before running the one it was asked for.
test "${#packages[@]}" -eq 1
./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}"
exit 0
;;
esac
if [ "$TEST_SHARD" = "release-scripts" ]; then
# Mirror the dedicated release-contract job: these suites shell out
# to archive tooling and are not race-instrumented there.
@@ -640,14 +662,21 @@ jobs:
needs: lint
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' }}
runs-on: ubuntu-latest
# app runs several independently bounded processes; cli/smoke need headroom
# beyond go test -timeout for setup + assembly.
# internal/app is split across one shard per bounded partition so the
# partitions run concurrently and each releases its framework registries
# when the process exits; cli/smoke need headroom beyond go test -timeout for
# setup + assembly. The app-<partition> names are pinned to the helper's
# partition set by TestCIAppRacePartitionMatrixMatchesHelper.
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shard:
- app
- app-schema
- app-a-b
- app-c
- app-d-r
- app-s-z-example-fuzz
- generators
- helpers
- cli
@@ -673,18 +702,30 @@ jobs:
TEST_SHARD: ${{ matrix.shard }}
run: |
set -euo pipefail
package_output="$(./scripts/ci/test-packages.sh list "$TEST_SHARD")"
# Every app partition shard tests the same single internal/app
# package, so the package query uses the base shard name and the
# partition only selects which tests run.
package_shard="$TEST_SHARD"
case "$TEST_SHARD" in
app-*) package_shard=app ;;
esac
package_output="$(./scripts/ci/test-packages.sh list "$package_shard")"
test -n "$package_output"
mapfile -t packages <<< "$package_output"
test "${#packages[@]}" -gt 0
if [ "$TEST_SHARD" = "app" ]; then
# A single long-lived app test process retains every constructed
# command tree in framework registries. Isolate Schema assembly and
# bounded name ranges so each process releases that state on exit.
test "${#packages[@]}" -eq 1
./scripts/ci/run-app-race-tests.sh run "${packages[0]}"
exit 0
fi
case "$TEST_SHARD" in
app-*)
# A single long-lived app test process retains every constructed
# command tree in framework registries. Each partition is its own
# job, so that state is released when the process exits and the
# partitions run concurrently instead of end to end. The helper
# still verifies that the partition patterns cover every top-level
# test exactly once before running the one it was asked for.
test "${#packages[@]}" -eq 1
./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}"
exit 0
;;
esac
# cli/smoke own heavy NewRootCommand / Schema assembly under -race;
# give them a dedicated package timeout on slower hosted runners.
timeout_budget=12m
+31
View File
@@ -6,6 +6,37 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.59-beta.2] - 2026-08-17
### Added
- **Privacy-safe CLI telemetry** (#1009) — reports reviewed command outcomes and profile identity dimensions while excluding command arguments, output, paths, device fingerprints, and automatic system dimensions; `DO_NOT_TRACK=1` disables reporting.
- **Feedback survey entry in root help** (#1019) — `dws --help` now closes with a Feedback section linking the user-experience survey form.
- **Wiki Shortcut workflows** — publishes 20 reviewed space, member, node, and
activity shortcuts with strict collection validation, cursor handling,
write-terminal evidence, safe read-backs where the backend supports them,
task-oriented routing, and documented backend
boundaries.
### Changed
- **Chat IM ID flags** (#954) — standardizes chat command entry points on `--conversation-id` for conversation IDs and `--message-id` for message IDs, so help, Schema, and Agent recommendations use the same canonical flags.
- **Legacy chat flag compatibility** (#954) — keeps older chat IM ID flags such as `--group`, `--id`, `--chat`, `--open-conversation-id`, `--msg-id`, and `--open-message-id` working as compatibility aliases where applicable, while hiding migrated aliases from recommended help and Schema surfaces.
- **Chat group bots target flag** (#954) — keeps `dws chat group bots` on the visible `--group` flag; this command does not register `--group-name`, and `--group` accepts either an openConversationId or a uniquely resolved group name.
- **Faster Schema Catalog assembly** — projects typed values into payload JSON
without re-running a validation scan over documents `json.Marshal` has just
produced, cutting roughly a third of the projection work across the full tool
set. Untrusted JSON input keeps its existing validation.
### Fixed
- **Chat card update evidence** — distinguishes an accepted update request from an independently verified visible update, preserving the real `bizId` and warning callers not to repeat an unverified write.
- **Chat command guidance** — splits message and group references by task and explains that `--from` is ambiguous between sender and time-range intent.
## [1.0.59-beta.1] - 2026-08-14
### Added
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.59-beta.1"
version "1.0.59-beta.2"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-darwin-arm64.tar.gz"
sha256 "36a30f3496e0f759c15c0b09f67dbd23b8ecdfff2eebe572f88125b26485830f"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-darwin-arm64.tar.gz"
sha256 "7f11218d3222f3e93c3b1e94b3a004c061eb0a297b206447ea95fe6b2b1ec674"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-darwin-amd64.tar.gz"
sha256 "e7a04906380efd8da88cd112e6a512bb6470a3956dc370150037ed6e314db445"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-darwin-amd64.tar.gz"
sha256 "72f06a334cf29d23123639fabe13bf2951765066136e47ccc6453667c382f8c2"
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.1/dws-linux-arm64.tar.gz"
sha256 "f59ab055f3e841e4cebc964ae3ef969668475548abaaf8bede44afdca9a3e28d"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-linux-arm64.tar.gz"
sha256 "3edbfabb7718b53914a2d9efe7b1152e9ebd70765ff0b6f19ad3125e4a2458ed"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-linux-amd64.tar.gz"
sha256 "2c8f919489d958c7d49262615e81faac70a9fbcae2d589ab54a0bb3c5700a057"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-linux-amd64.tar.gz"
sha256 "e1c610070a9c1b3763656cbd53c818290f199097adc07cb9fe629ed765c5e1e0"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-skills.zip"
sha256 "25f4a7e1d01fa4d771d79201b34b11ee8a24182bdcdc94bfb98d2bd5845bed3b"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.2/dws-skills.zip"
sha256 "aa2854651eaa2c857b526aaec73fd1358d31b11999fe7fd3e99e5060b2522a1f"
end
def install
File diff suppressed because one or more lines are too long
+327 -64
View File
@@ -1,6 +1,6 @@
{
"generated_at": "2026-08-14T13:50:36.324505",
"count": 418,
"generated_at": "2026-08-17T15:52:13.675461",
"count": 435,
"results": [
{
"suite": "semantic",
@@ -1056,144 +1056,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 +3712,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",
+1
View File
@@ -18,6 +18,7 @@ require (
github.com/muesli/termenv v0.16.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1
github.com/spf13/cobra v1.10.2
github.com/yuin/goldmark v1.8.5
github.com/zalando/go-keyring v0.2.8
gitlab.alibaba-inc.com/aes/aem-go-sdk v0.3.0
golang.org/x/crypto v0.49.0
+2
View File
@@ -105,6 +105,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+2 -1
View File
@@ -1220,7 +1220,8 @@ func newEventStopCommandWithFlags(globalFlags ...*GlobalFlags) *cobra.Command {
editionName := editionNameOrDefault()
clientIDHash := dwsevent.ClientIDHash(clientID)
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
if err := eventStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil {
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
if err := eventStopBus(busctl.StopConfig{WorkDir: workDir, IPCEndpoint: ipcEndpoint}); err != nil {
if errors.Is(err, busctl.ErrNotRunning) {
fmt.Fprintln(c.OutOrStdout(), "bus is not running")
return nil
+1 -1
View File
@@ -1220,7 +1220,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
}
busState := "personal bus stopped"
if err := personalStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil {
if err := personalStopBus(busctl.StopConfig{WorkDir: workDir, IPCEndpoint: ipcEndpoint}); err != nil {
if errors.Is(err, busctl.ErrNotRunning) {
busState = "personal bus is not running"
} else {
+3 -1
View File
@@ -52,6 +52,8 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
switch tool {
case "list_calendar_events":
return `{"result":{"events":[]}}`
case "query_records":
return `{"success":true,"status":"success","error":{},"data":{}}`
case "search_mail_users":
return `{"users":[{"name":"Fixture User","email":"fixture@example.com","id":"fixture-user"}]}`
case "search_dept_by_keyword":
@@ -63,7 +65,7 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
case "list_doc_versions":
return `{"result":{"items":[{"version":3}]}}`
case "revert_doc_version":
return `{"version":3}`
return `{"revertedToVersion":3}`
case "search_doc_templates":
return `{"result":[{"templateId":"fixture-template-id"}]}`
case "create_document":
+47
View File
@@ -462,6 +462,53 @@ func TestCalendarEventListDryRunPreviewsOnly(t *testing.T) {
}
}
func TestCalendarEventShareInfoDryRunPreviewsOnly(t *testing.T) {
got, err := executeRootCaptureStdout(t, []string{
"--dry-run", "calendar", "event", "share-info",
"--id", "EVT_001",
"--language", "zh-CN",
"--calendar-id", "primary",
})
if err != nil {
t.Fatalf("calendar event share-info --dry-run error = %v\n%s", err, got)
}
for _, want := range []string{"get_event_share_info", "eventId", "EVT_001", "zh-CN", "primary"} {
if !strings.Contains(got, want) {
t.Fatalf("calendar event share-info dry-run output missing %q:\n%s", want, got)
}
}
}
func TestCalendarEventShareInfoRequiresEventID(t *testing.T) {
got, err := executeRootCaptureStdout(t, []string{
"--dry-run", "calendar", "event", "share-info",
})
if err == nil {
t.Fatalf("calendar event share-info without --id: expected error, got nil\n%s", got)
}
if strings.Contains(got, "\"executed\": true") {
t.Fatalf("share-info without --id must not execute:\n%s", got)
}
}
func TestCalendarEventShareInfoOmitsOptionalArgs(t *testing.T) {
got, err := executeRootCaptureStdout(t, []string{
"--dry-run", "calendar", "event", "share-info",
"--id", "EVT_001",
})
if err != nil {
t.Fatalf("calendar event share-info --dry-run with only --id error = %v\n%s", err, got)
}
if !strings.Contains(got, "\"eventId\"") {
t.Fatalf("calendar event share-info dry-run output missing eventId:\n%s", got)
}
for _, unwanted := range []string{"\"calendarId\"", "\"language\""} {
if strings.Contains(got, unwanted) {
t.Fatalf("calendar event share-info dry-run with only --id should not contain %q:\n%s", unwanted, got)
}
}
}
func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
root := NewRootCommand()
@@ -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)
}
}
+14
View File
@@ -400,6 +400,7 @@ func schemaContractPayloadForBoundCanonicals(t *testing.T, root *cobra.Command,
func TestChatSchemaSeparatesSendAndReply(t *testing.T) {
snapshot := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(),
"chat.send_personal_message",
"chat.send_robot_message",
"chat.reply_personal_message",
)
@@ -418,6 +419,19 @@ func TestChatSchemaSeparatesSendAndReply(t *testing.T) {
if _, exists := snapshot.Tools["chat.upload_conversation_file"]; exists {
t.Fatal("downlined chat file upload must not be advertised in Schema")
}
botReply := snapshot.Tools["chat.send_robot_message"]
botParams := schemaContractMap(botReply["parameters"])
if got := schemaContractString(botParams["reply"]["property"]); got != "referenceOpenMessageId" {
t.Fatalf("bot --reply property = %q", got)
}
if got := schemaContractString(botParams["ref-sender"]["property"]); got != "srcMsgSendOpenDingTalkId" {
t.Fatalf("bot --ref-sender property = %q", got)
}
if got, ok := botParams["title"]["required"].(bool); !ok || got {
t.Fatalf("bot --title required = %#v, want false for conditional Markdown input", botParams["title"]["required"])
}
assertSchemaContractConstraintGroup(t, botReply, "require_together", []string{"reply", "ref-sender"})
}
func TestCalendarAttendeeDeleteSchemaMatchesRuntimeGate(t *testing.T) {
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 418
publicShortcutCount = 434
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including the hidden historical minutes.shortcut_minutes_search contract.
schemaPublishedShortcutCount = 420
schemaPublishedShortcutCount = 437
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 418
publiclyDeliveredShortcutCount = 434
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
+18 -2
View File
@@ -1127,21 +1127,37 @@ func putRawJSON(payload map[string]any, key string, raw json.RawMessage) error {
return nil
}
// rawJSONValue decodes a JSON value that may come from an untrusted source, so
// it validates before decoding. Callers holding output that json.Marshal just
// produced should use typedJSONValue instead of paying the validation scan.
func rawJSONValue(raw json.RawMessage) (any, error) {
if !json.Valid(raw) {
return nil, fmt.Errorf("invalid JSON value")
}
return decodeValidJSONValue(raw), nil
}
// decodeValidJSONValue decodes JSON whose validity the caller has already
// established, either by json.Valid or by having just marshaled it. Decode
// errors are unreachable under that precondition and are therefore discarded,
// exactly as this path behaved when the decode was inlined into rawJSONValue.
func decodeValidJSONValue(raw json.RawMessage) any {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var value any
_ = decoder.Decode(&value)
return value, nil
return value
}
// typedJSONValue projects a typed value into the generic JSON shape the payload
// renderers consume. json.Marshal output is valid by construction, so this path
// decodes it directly: routing through rawJSONValue re-scanned every marshaled
// document with json.Valid, which measured ~34% of Schema Catalog assembly time
// across the 1121-tool set (26.0s -> 17.2s for the internal/app schema suite).
func typedJSONValue(value any) (any, error) {
data, err := json.Marshal(value)
if err != nil {
return nil, err
}
return rawJSONValue(data)
return decodeValidJSONValue(data), nil
}
+8 -3
View File
@@ -361,13 +361,18 @@ func TestCrossPlatformCoverageDaemonMethodEdges(t *testing.T) {
}
return nil, net.ErrClosed
}}
d := &daemon{listener: l, log: logger, hub: NewHub(1), idleStop: make(chan struct{})}
d := &daemon{listener: l, log: logger, hub: NewHub(1), idleStop: make(chan struct{}), stopReq: make(chan string, 1)}
d.acceptLoop(context.Background())
d.shuttingDown.Store(true)
d.acceptLoop(context.Background())
d.triggerShutdown("test")
if !l.closed {
t.Fatal("trigger did not close listener")
select {
case reason := <-d.stopReq:
if reason != "test" {
t.Fatalf("stop reason = %q", reason)
}
default:
t.Fatal("trigger did not notify lifecycle loop")
}
d = &daemon{listener: &scriptedListener{accept: func() (net.Conn, error) { return nil, net.ErrClosed }}, log: logger, hub: NewHub(1), idleStop: make(chan struct{})}
+14 -7
View File
@@ -186,6 +186,7 @@ func Run(ctx context.Context, cfg Config) error {
dedup: dd,
started: time.Now().UTC(),
idleStop: make(chan struct{}),
stopReq: make(chan string, 1),
}
// 4. Signal ready BEFORE accepting consumers (avoids a slow-fork
@@ -263,6 +264,9 @@ func Run(ctx context.Context, cfg Config) error {
}
case <-d.idleStop:
log.Info("bus: idle timeout reached, shutting down")
case reason := <-d.stopReq:
shutdownReason = reason
log.Info("bus: shutdown requested via IPC", "reason", reason)
}
// 7. Graceful shutdown — cancel runCtx first so all background
@@ -292,6 +296,7 @@ type daemon struct {
shutdownMu sync.Mutex
shuttingDown atomic.Bool
idleStop chan struct{}
stopReq chan string
credentialHandoffMu sync.Mutex
terminalMu sync.RWMutex
@@ -749,15 +754,17 @@ func (d *daemon) idleWatch(ctx context.Context) {
}
}
// triggerShutdown is called from RPC handlers that want to end the bus.
// It works by closing the listener (which unblocks Run's select via the
// source error path, indirectly). For v1 a full ctx-cancellation hook is
// out of scope; busctl/stop also sends SIGTERM which is the authoritative
// shutdown path.
// triggerShutdown is called from RPC handlers that want to end the bus. The
// buffered request wakes Run's lifecycle select, which then cancels the cloud
// source and performs the same graceful shutdown used for signals and idle
// expiry. It is intentionally non-blocking so repeated stop requests cannot
// strand connection handlers.
func (d *daemon) triggerShutdown(reason string) {
d.log.Info("bus: shutdown triggered via IPC", "reason", reason)
_ = d.listener.Close() // unblocks Accept(), but doesn't kill Source
// Best-effort: a future version wires a context.CancelFunc here.
select {
case d.stopReq <- reason:
default:
}
}
// shutdown performs the graceful tear-down sequence:
+61
View File
@@ -141,6 +141,67 @@ func TestDaemon_RunStartsAndShutsDownCleanly(t *testing.T) {
}
}
func TestCrossPlatformCoverageDaemonIPCStopShutsDownCleanly(t *testing.T) {
workDir := shortTempDir(t)
identityHash := dwsevent.IdentityHash(workDir)
endpoint := dwsevent.IPCEndpoint(workDir, "open", dwsevent.SourceKindAppStream, identityHash)
runDone := make(chan error, 1)
go func() {
runDone <- Run(context.Background(), Config{
WorkDir: workDir,
IPCEndpoint: endpoint,
ClientID: "ding_ipc_stop_test",
Edition: "open",
SourceKind: dwsevent.SourceKindAppStream,
IdentityHash: identityHash,
Source: &fakeSource{},
})
}()
var conn net.Conn
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
var err error
conn, err = transport.Dial(endpoint)
if err == nil {
break
}
time.Sleep(10 * time.Millisecond)
}
if conn == nil {
t.Fatal("bus IPC endpoint did not become ready")
}
w := transport.NewWriter(conn)
r := transport.NewReader(conn)
if err := w.WriteJSON(transport.Hello{
Type: transport.FrameTypeHello,
ConsumerPID: os.Getpid(),
Role: transport.HelloRoleStop,
}); err != nil {
t.Fatalf("write stop hello: %v", err)
}
var bye transport.Bye
if err := r.ReadJSON(&bye); err != nil {
t.Fatalf("read stop response: %v", err)
}
_ = conn.Close()
if bye.Type != transport.FrameTypeBye || bye.Reason != "stop_request" {
t.Fatalf("stop response = %#v", bye)
}
select {
case err := <-runDone:
if err != nil {
t.Fatalf("Run returned after IPC stop: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("Run did not return after IPC stop")
}
if pid := ReadHolderPID(filepath.Join(workDir, LockFileName)); pid != 0 {
t.Fatalf("bus lock retained pid %d after IPC stop", pid)
}
}
func TestDaemon_ConsumerReceivesEvents(t *testing.T) {
skipOnWindows(t, "uses Unix socket dial")
workDir := shortTempDir(t)
+41
View File
@@ -114,6 +114,47 @@ func ReadHolderPID(path string) int {
return parsePID(b)
}
// ValidateHolderOwnership reports whether pid still owns the exclusive lock at
// path. If the lock can be acquired, no daemon owns it: the PID payload is
// stale and is cleared while the probe holds the lock. The PID is read again
// after a busy result so a daemon replacement cannot be mistaken for the
// original owner.
//
// Callers use this immediately before sending a process-level termination
// fallback. A live PID by itself is not proof of ownership because operating
// systems may reuse PIDs after an unclean daemon exit.
func ValidateHolderOwnership(path string, pid int) (bool, error) {
if pid <= 0 || ReadHolderPID(path) != pid {
return false, nil
}
probe, err := busTryAcquire(path)
if err != nil {
if errors.Is(err, ErrBusy) {
return ReadHolderPID(path) == pid, nil
}
return false, fmt.Errorf("bus: verify lock owner: %w", err)
}
defer probe.Close()
// We own the lock, so the recorded PID cannot own it. Clear only the PID
// we validated above; a changed payload is left untouched defensively.
f := probe.File()
if _, err := busSeek(f, 0, io.SeekStart); err != nil {
return false, fmt.Errorf("bus: seek stale lock: %w", err)
}
current, err := busReadAll(f)
if err != nil {
return false, fmt.Errorf("bus: read stale lock: %w", err)
}
if parsePID(current) == pid {
if err := truncateAndWritePID(f, 0); err != nil {
return false, fmt.Errorf("bus: clear stale PID: %w", err)
}
}
return false, nil
}
// Close releases the flock and best-effort blanks the PID body so a stale
// reader (e.g. `event status` racing our shutdown) does not see our
// long-dead PID and try to signal it. The lock file itself is NOT removed
+107
View File
@@ -16,9 +16,13 @@ package bus
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"testing"
eventlock "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/lock"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestAcquire_WritesOurPID(t *testing.T) {
@@ -112,6 +116,109 @@ func TestReadHolderPID_MalformedReturnsZero(t *testing.T) {
}
}
func TestCrossPlatformCoverageValidateHolderOwnershipHeldLock(t *testing.T) {
path := filepath.Join(t.TempDir(), LockFileName)
held, err := busTryAcquire(path)
if err != nil {
t.Fatalf("hold lock: %v", err)
}
defer held.Close()
if err := truncateAndWritePID(held.File(), os.Getpid()); err != nil {
t.Fatalf("write holder PID: %v", err)
}
owned, err := ValidateHolderOwnership(path, os.Getpid())
if err != nil {
t.Fatalf("ValidateHolderOwnership: %v", err)
}
if !owned {
t.Fatal("held lock was not attributed to its recorded PID")
}
}
func TestCrossPlatformCoverageValidateHolderOwnershipClearsReusedPID(t *testing.T) {
path := filepath.Join(t.TempDir(), LockFileName)
// The current PID is alive but deliberately does not hold this lock. This
// models a stale daemon PID that the OS has reassigned to another process.
if err := os.WriteFile(path, []byte(fmt.Sprintf("%d\n", os.Getpid())), 0o600); err != nil {
t.Fatalf("write stale PID: %v", err)
}
owned, err := ValidateHolderOwnership(path, os.Getpid())
if err != nil {
t.Fatalf("ValidateHolderOwnership: %v", err)
}
if owned {
t.Fatal("live reused PID without the bus lock was accepted as owner")
}
if got := ReadHolderPID(path); got != 0 {
t.Fatalf("stale PID after validation = %d, want cleared", got)
}
}
func TestCrossPlatformCoverageValidateHolderOwnershipRejectsMismatch(t *testing.T) {
path := filepath.Join(t.TempDir(), LockFileName)
if err := os.WriteFile(path, []byte("123\n"), 0o600); err != nil {
t.Fatal(err)
}
owned, err := ValidateHolderOwnership(path, 456)
if err != nil || owned {
t.Fatalf("ValidateHolderOwnership mismatch = %v, %v", owned, err)
}
}
func TestCrossPlatformCoverageValidateHolderOwnershipErrors(t *testing.T) {
errInjected := errors.New("injected ownership validation failure")
writeLivePID := func(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), LockFileName)
if err := os.WriteFile(path, []byte(fmt.Sprintf("%d\n", os.Getpid())), 0o600); err != nil {
t.Fatal(err)
}
return path
}
t.Run("probe", func(t *testing.T) {
path := writeLivePID(t)
testseam.Swap(t, &busTryAcquire, func(string) (*eventlock.File, error) {
return nil, errInjected
})
if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) {
t.Fatalf("probe error = %v", err)
}
})
t.Run("seek", func(t *testing.T) {
path := writeLivePID(t)
testseam.Swap(t, &busSeek, func(*os.File, int64, int) (int64, error) {
return 0, errInjected
})
if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) {
t.Fatalf("seek error = %v", err)
}
})
t.Run("read", func(t *testing.T) {
path := writeLivePID(t)
testseam.Swap(t, &busReadAll, func(io.Reader) ([]byte, error) {
return nil, errInjected
})
if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) {
t.Fatalf("read error = %v", err)
}
})
t.Run("clear", func(t *testing.T) {
path := writeLivePID(t)
testseam.Swap(t, &busTruncate, func(*os.File, int64) error {
return errInjected
})
if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) {
t.Fatalf("clear error = %v", err)
}
})
}
func TestAcquire_AfterReleaseReclaimable(t *testing.T) {
path := filepath.Join(t.TempDir(), LockFileName)
l1, err := Acquire(path)
@@ -284,11 +284,13 @@ func TestCrossPlatformCoverageQueryStatusAndEntryEdges(t *testing.T) {
func TestCrossPlatformCoverageStopInjectedEdges(t *testing.T) {
origRead := stopReadHolderPID
origValidateOwner := stopValidateHolderOwner
origAlive := stopAlive
origFind := stopFindProcess
origSignal := stopSignalProcess
t.Cleanup(func() {
stopReadHolderPID = origRead
stopValidateHolderOwner = origValidateOwner
stopAlive = origAlive
stopFindProcess = origFind
stopSignalProcess = origSignal
@@ -307,6 +309,7 @@ func TestCrossPlatformCoverageStopInjectedEdges(t *testing.T) {
t.Fatal(err)
}
stopFindProcess = func(int) (*os.Process, error) { return proc, nil }
stopValidateHolderOwner = func(string, int) (bool, error) { return true, nil }
stopSignalProcess = func(*os.Process, os.Signal) error { return os.ErrProcessDone }
if err := Stop(StopConfig{WorkDir: "x"}); err != nil {
t.Fatalf("done signal = %v", err)
+3 -2
View File
@@ -112,8 +112,9 @@ func Discover(cfg DiscoverConfig) (net.Conn, error) {
return nil, fmt.Errorf("busctl: mkdir workdir: %w", err)
}
_, spawnErr := cfg.Spawn(SpawnConfig{
ClientID: cfg.ClientID,
ExtraArgs: cfg.SpawnExtraArgs,
ClientID: cfg.ClientID,
IPCEndpoint: cfg.IPCEndpoint,
ExtraArgs: cfg.SpawnExtraArgs,
})
if spawnErr != nil && !errors.Is(spawnErr, ErrSpawnFailed) {
// Hard error (couldn't even exec the child). Stop here — no bus
+4 -1
View File
@@ -122,7 +122,10 @@ func TestDiscover_NoBus_SpawnSucceeds(t *testing.T) {
closer()
}
})
fakeSpawn := func(SpawnConfig) (int, error) {
fakeSpawn := func(cfg SpawnConfig) (int, error) {
if cfg.IPCEndpoint != sock {
t.Fatalf("spawn IPC endpoint = %q, want %q", cfg.IPCEndpoint, sock)
}
closer = startStubBus(t, sock)
return 12345, nil
}
+15 -14
View File
@@ -43,18 +43,14 @@ var (
spawnPipe = os.Pipe
)
// ErrSpawnFailed is returned when the child reports startup failure via
// the ready pipe ('E' byte). The child's exit error / log file holds the
// actual cause; this sentinel just lets the caller distinguish "ready
// pipe said no" from "ready pipe timed out / closed early".
var ErrSpawnFailed = errors.New("busctl: bus child reported startup failure on ready pipe")
// ErrSpawnFailed is returned when the child reports startup failure through
// the Unix ready pipe or exits before binding the Windows named pipe.
var ErrSpawnFailed = errors.New("busctl: bus child reported startup failure")
// ErrSpawnTimeout is returned when ReadyTimeout elapses without any signal.
var ErrSpawnTimeout = errors.New("busctl: bus child did not signal readiness within deadline")
// SpawnConfig describes one spawn attempt. ClientID is the only field
// inspected by the child; the rest govern process attributes the parent
// applies before exec.
// SpawnConfig describes one spawn attempt.
type SpawnConfig struct {
// ExecPath is the dws binary to exec. Default os.Executable().
ExecPath string
@@ -62,18 +58,23 @@ type SpawnConfig struct {
// ClientID is passed as `--client-id` to `dws event _bus`. Required.
ClientID string
// IPCEndpoint is the bus endpoint the child binds. Windows uses the
// existing named pipe as its readiness handshake because os/exec does not
// support ExtraFiles there. Unix keeps the inherited ready-pipe protocol.
IPCEndpoint string
// ExtraArgs are appended after `--client-id`. Empty for normal use; tests
// pass `--extra-flag-for-test` etc.
ExtraArgs []string
// Env to pass to the child. Defaults to os.Environ(). The ReadyFDEnv
// entry is appended automatically.
// Env to pass to the child. Defaults to os.Environ(). Unix appends the
// ReadyFDEnv entry automatically; Windows removes any inherited copy.
Env []string
}
// Spawn forks a detached `dws event _bus --client-id <id>` child process and
// waits for it to signal readiness via the ready pipe. Returns the child's
// PID on success — the caller can then dial the bus IPC endpoint.
// spawnWithReadyPipe is the Unix implementation behind Spawn. It forks a
// detached `dws event _bus --client-id <id>` child and waits for the inherited
// ready pipe. Windows has a named-pipe implementation in spawn_windows.go.
//
// stdio detach (plan invariant #7):
// - cmd.Stdout / cmd.Stderr set to nil so the child's own writes don't
@@ -89,7 +90,7 @@ type SpawnConfig struct {
// Parent (this function):
// - Holds the read end open until either 1 byte is read or ReadyTimeout
// - Returns ErrSpawnFailed for 'E', ErrSpawnTimeout otherwise
func Spawn(cfg SpawnConfig) (pid int, err error) {
func spawnWithReadyPipe(cfg SpawnConfig) (pid int, err error) {
if cfg.ClientID == "" {
return 0, errors.New("busctl: SpawnConfig.ClientID is required")
}
+24 -1
View File
@@ -22,6 +22,8 @@ import (
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
// Test child mode: when DWS_BUSCTL_TEST_CHILD is set, this test binary
@@ -35,7 +37,8 @@ import (
// env-marker pattern is what Go's own os/exec tests use and stays
// confined to this file.
const (
childEnvMarker = "DWS_BUSCTL_TEST_CHILD"
childEnvMarker = "DWS_BUSCTL_TEST_CHILD"
childEndpointEnv = "DWS_BUSCTL_TEST_ENDPOINT"
// values:
// "ready" — write 'R' then sleep 30s (parent should see ready)
// "fail" — write 'E' then exit (parent should see ErrSpawnFailed)
@@ -70,6 +73,26 @@ func TestMain(m *testing.M) {
writeReady('R')
time.Sleep(30 * time.Second)
os.Exit(0)
case "windows-ready":
listener, err := transport.Listen(os.Getenv(childEndpointEnv))
if err != nil {
os.Exit(3)
}
defer listener.Close()
conn, err := listener.Accept()
if err != nil {
os.Exit(4)
}
_ = conn.Close()
time.Sleep(30 * time.Second)
os.Exit(0)
case "windows-fail":
os.Exit(5)
case "windows-exit":
os.Exit(0)
case "windows-stall":
time.Sleep(30 * time.Second)
os.Exit(0)
}
os.Exit(m.Run())
}
+7
View File
@@ -20,6 +20,13 @@ import (
"syscall"
)
// Spawn starts the detached bus and waits for its inherited ready pipe.
// ExtraFiles is intentionally confined to Unix: Go does not support it on
// Windows.
func Spawn(cfg SpawnConfig) (pid int, err error) {
return spawnWithReadyPipe(cfg)
}
// applyDetach configures the child to live past parent death and not share
// the parent's controlling terminal. Setsid puts the child in a new
// session, so SIGHUP on the parent's controlling tty (e.g. SSH disconnect)
+87
View File
@@ -16,14 +16,101 @@
package busctl
import (
"fmt"
"os"
"os/exec"
"strings"
"syscall"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
// CREATE_NEW_PROCESS_GROUP (0x00000200) prevents the child from receiving
// the parent's Ctrl+C signal, similar in spirit to Setsid on Unix.
const createNewProcessGroup = 0x00000200
var (
spawnWindowsDial = transport.Dial
spawnWindowsPollInterval = 25 * time.Millisecond
)
// Spawn starts a detached Windows bus without cmd.ExtraFiles (unsupported by
// Go on Windows). Readiness is confirmed by dialing the bus's existing named
// pipe. The child is reaped in the background after readiness, while an early
// process exit is surfaced as ErrSpawnFailed.
func Spawn(cfg SpawnConfig) (pid int, err error) {
if strings.TrimSpace(cfg.ClientID) == "" {
return 0, fmt.Errorf("busctl: SpawnConfig.ClientID is required")
}
if strings.TrimSpace(cfg.IPCEndpoint) == "" {
return 0, fmt.Errorf("busctl: SpawnConfig.IPCEndpoint is required on Windows")
}
if cfg.ExecPath == "" {
execPath, err := spawnExecutable()
if err != nil {
return 0, fmt.Errorf("busctl: locate executable: %w", err)
}
cfg.ExecPath = execPath
}
if cfg.Env == nil {
cfg.Env = os.Environ()
}
args := append([]string{"event", "_bus", "--client-id", cfg.ClientID}, cfg.ExtraArgs...)
cmd := exec.Command(cfg.ExecPath, args...)
cmd.Env = withoutReadyFDEnv(cfg.Env)
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = nil
applyDetach(cmd)
if err := cmd.Start(); err != nil {
return 0, fmt.Errorf("busctl: start %s: %w", cfg.ExecPath, err)
}
pid = cmd.Process.Pid
waitDone := make(chan error, 1)
go func() { waitDone <- cmd.Wait() }()
timer := time.NewTimer(ReadyTimeout)
defer timer.Stop()
ticker := time.NewTicker(spawnWindowsPollInterval)
defer ticker.Stop()
for {
if conn, dialErr := spawnWindowsDial(cfg.IPCEndpoint); dialErr == nil {
_ = conn.Close()
return pid, nil
}
select {
case waitErr := <-waitDone:
if waitErr == nil {
return pid, fmt.Errorf("%w: child exited before binding named pipe", ErrSpawnFailed)
}
return pid, fmt.Errorf("%w: %v", ErrSpawnFailed, waitErr)
case <-timer.C:
_ = cmd.Process.Kill()
select {
case <-waitDone:
case <-time.After(time.Second):
}
return pid, ErrSpawnTimeout
case <-ticker.C:
}
}
}
func withoutReadyFDEnv(env []string) []string {
prefix := strings.ToUpper(ReadyFDEnv) + "="
out := make([]string, 0, len(env))
for _, entry := range env {
if strings.HasPrefix(strings.ToUpper(entry), prefix) {
continue
}
out = append(out, entry)
}
return out
}
func applyDetach(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: createNewProcessGroup,
+132
View File
@@ -0,0 +1,132 @@
// 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.
//go:build windows
package busctl
import (
"errors"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageWindowsSpawnUsesNamedPipeReadiness(t *testing.T) {
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-test-%d-%d`, os.Getpid(), time.Now().UnixNano())
pid, err := spawnWithMarker(t, "windows-ready", func(cfg *SpawnConfig) {
cfg.IPCEndpoint = endpoint
cfg.Env = append(cfg.Env, childEndpointEnv+"="+endpoint)
})
if err != nil {
t.Fatalf("Spawn() = %v", err)
}
if pid <= 0 {
t.Fatalf("Spawn() pid = %d", pid)
}
proc, err := os.FindProcess(pid)
if err != nil {
t.Fatal(err)
}
_ = proc.Kill()
}
func TestCrossPlatformCoverageWindowsSpawnReportsEarlyExit(t *testing.T) {
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-fail-%d-%d`, os.Getpid(), time.Now().UnixNano())
_, err := spawnWithMarker(t, "windows-fail", func(cfg *SpawnConfig) {
cfg.IPCEndpoint = endpoint
})
if !errors.Is(err, ErrSpawnFailed) {
t.Fatalf("Spawn() error = %v, want ErrSpawnFailed", err)
}
}
func TestCrossPlatformCoverageWindowsSpawnReportsCleanEarlyExit(t *testing.T) {
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-exit-%d-%d`, os.Getpid(), time.Now().UnixNano())
_, err := spawnWithMarker(t, "windows-exit", func(cfg *SpawnConfig) {
cfg.IPCEndpoint = endpoint
})
if !errors.Is(err, ErrSpawnFailed) {
t.Fatalf("Spawn() error = %v, want ErrSpawnFailed", err)
}
}
func TestCrossPlatformCoverageWindowsSpawnResolvesExecutable(t *testing.T) {
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-resolve-%d-%d`, os.Getpid(), time.Now().UnixNano())
testseam.Swap(t, &spawnExecutable, func() (string, error) { return os.Args[0], nil })
pid, err := spawnWithMarker(t, "windows-ready", func(cfg *SpawnConfig) {
cfg.ExecPath = ""
cfg.IPCEndpoint = endpoint
cfg.Env = append(cfg.Env, childEndpointEnv+"="+endpoint)
})
if err != nil {
t.Fatalf("Spawn() = %v", err)
}
proc, err := os.FindProcess(pid)
if err != nil {
t.Fatal(err)
}
_ = proc.Kill()
}
func TestCrossPlatformCoverageWindowsSpawnValidationAndStartErrors(t *testing.T) {
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-errors-%d-%d`, os.Getpid(), time.Now().UnixNano())
if _, err := Spawn(SpawnConfig{ClientID: "client"}); err == nil {
t.Fatal("missing endpoint unexpectedly succeeded")
}
testseam.Swap(t, &spawnExecutable, func() (string, error) {
return "", errors.New("executable unavailable")
})
if _, err := Spawn(SpawnConfig{ClientID: "client", IPCEndpoint: endpoint}); err == nil {
t.Fatal("executable lookup unexpectedly succeeded")
}
if _, err := Spawn(SpawnConfig{
ClientID: "client",
IPCEndpoint: endpoint,
ExecPath: `C:\\definitely-missing-dws.exe`,
}); err == nil {
t.Fatal("missing executable unexpectedly started")
}
}
func TestCrossPlatformCoverageWindowsSpawnReadyTimeoutTerminatesChild(t *testing.T) {
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-stall-%d-%d`, os.Getpid(), time.Now().UnixNano())
testseam.Swap(t, &ReadyTimeout, 100*time.Millisecond)
_, err := spawnWithMarker(t, "windows-stall", func(cfg *SpawnConfig) {
cfg.IPCEndpoint = endpoint
})
if !errors.Is(err, ErrSpawnTimeout) {
t.Fatalf("Spawn() error = %v, want ErrSpawnTimeout", err)
}
}
func TestCrossPlatformCoverageWindowsSpawnRemovesInheritedReadyFD(t *testing.T) {
env := withoutReadyFDEnv([]string{
"PATH=C:\\Windows",
ReadyFDEnv + "=3",
strings.ToLower(ReadyFDEnv) + "=4",
})
if len(env) != 1 || !strings.HasPrefix(env[0], "PATH=") {
t.Fatalf("withoutReadyFDEnv() = %#v", env)
}
}
func TestCrossPlatformCoverageWindowsUnixReadyPipeValidation(t *testing.T) {
if _, err := spawnWithReadyPipe(SpawnConfig{}); err == nil {
t.Fatal("spawnWithReadyPipe() unexpectedly accepted an empty client ID")
}
}
+95 -24
View File
@@ -17,15 +17,17 @@ import (
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/process"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
// DefaultStopTimeout is the wall-clock budget Stop waits for the bus to
// exit after the signal is sent. 5s covers the bus's own graceful tear-down
// (broadcast Bye → consumer goroutines drain → cleanup) with margin.
// DefaultStopTimeout is the wall-clock budget Stop waits for each graceful or
// fallback termination phase. 5s covers the bus's own tear-down (broadcast
// Bye → consumer goroutines drain → cleanup) with margin.
const DefaultStopTimeout = 5 * time.Second
// ErrNotRunning indicates bus.lock either does not exist or its recorded
@@ -33,20 +35,32 @@ const DefaultStopTimeout = 5 * time.Second
// distinguish "nothing to stop" from "failed to stop".
var ErrNotRunning = errors.New("busctl: bus is not running")
// ErrOwnerUnverified indicates the PID from bus.lock is alive but no longer
// owns that lock. Stop refuses to send a process-level signal in this state
// because the operating system may have reused the stale PID.
var ErrOwnerUnverified = errors.New("busctl: bus process ownership could not be verified")
var (
stopReadHolderPID = bus.ReadHolderPID
stopAlive = process.Alive
stopFindProcess = os.FindProcess
stopSignalProcess = func(proc *os.Process, signal os.Signal) error { return proc.Signal(signal) }
stopReadHolderPID = bus.ReadHolderPID
stopValidateHolderOwner = bus.ValidateHolderOwnership
stopAlive = process.Alive
stopFindProcess = os.FindProcess
stopSignalProcess = func(proc *os.Process, signal os.Signal) error { return proc.Signal(signal) }
stopDial = transport.Dial
stopRequest = requestBusStop
stopWaitForBusExit = waitForBusExit
)
// StopConfig identifies the target bus and tunes timing.
type StopConfig struct {
// WorkDir holds bus.lock; Stop reads the PID from there.
WorkDir string
// Timeout is the total wall-clock budget for graceful exit. After this,
// Stop returns an error; it does NOT escalate to SIGKILL — leave that
// to the operator.
// IPCEndpoint enables the cross-platform graceful stop RPC. Callers that
// know the bus identity should always provide it. Empty preserves the
// legacy signal-only fallback for compatibility and focused tests.
IPCEndpoint string
// Timeout is the wall-clock budget for graceful exit and, if required,
// the subsequent platform termination fallback.
Timeout time.Duration
}
@@ -54,13 +68,12 @@ type StopConfig struct {
// for the process to actually die. Returns ErrNotRunning if no bus is
// running for that work dir.
//
// Implementation note: on Unix we send SIGTERM. The bus daemon's Run loop
// watches its parent ctx for cancellation; the cobra `event _bus` command
// wires signal.NotifyContext so SIGTERM triggers ctx.Done() → graceful
// shutdown path. On Windows we use os.Process.Signal(os.Interrupt) which
// the Go runtime maps to TerminateProcess for processes outside our
// console group; for v1 that's acceptable (Windows graceful shutdown is
// future work — plan §16 v2).
// Stop first asks the bus to shut down through its owner-only IPC endpoint.
// This is the normal path on every platform and lets the daemon cancel its
// cloud source, notify consumers, and release its lock. If the endpoint is
// unavailable (for example an older bus) or graceful shutdown times out, the
// platform signal is used as a compatibility fallback: SIGTERM on Unix and
// TerminateProcess via os.Kill on Windows.
func Stop(cfg StopConfig) error {
if cfg.WorkDir == "" {
return errors.New("busctl: StopConfig.WorkDir is required")
@@ -81,6 +94,32 @@ func Stop(cfg StopConfig) error {
if err != nil {
return fmt.Errorf("busctl: find process %d: %w", pid, err)
}
if strings.TrimSpace(cfg.IPCEndpoint) != "" {
if err := stopRequest(cfg.IPCEndpoint); err == nil && stopWaitForBusExit(pid, cfg.Timeout) {
return nil
}
}
// The bus may finish shutting down immediately after the graceful wait
// reaches its deadline (or after a failed IPC request). Recheck before
// inspecting ownership so a completed stop is not reported as stale.
if !stopAlive(pid) {
return nil
}
owner, err := stopValidateHolderOwner(LockPath(cfg.WorkDir), pid)
if err != nil {
return fmt.Errorf("busctl: verify bus pid=%d ownership: %w", pid, err)
}
if !owner {
// ValidateHolderOwnership observes a released lock both for a stale,
// reused PID and for a bus that exits during the ownership probe. Only
// the still-alive case is unsafe to signal.
if !stopAlive(pid) {
return nil
}
return fmt.Errorf("%w: pid=%d does not own %s; stale PID was not signalled", ErrOwnerUnverified, pid, LockPath(cfg.WorkDir))
}
if err := stopSignalProcess(proc, stopSignal()); err != nil {
// On many Unix platforms Signal returns "process already finished"
// when the bus has just exited on its own — treat that as success.
@@ -90,13 +129,45 @@ func Stop(cfg StopConfig) error {
return fmt.Errorf("busctl: signal bus pid=%d: %w", pid, err)
}
// Poll for actual exit.
deadline := time.Now().Add(cfg.Timeout)
for time.Now().Before(deadline) {
if !stopAlive(pid) {
return nil
}
time.Sleep(50 * time.Millisecond)
if stopWaitForBusExit(pid, cfg.Timeout) {
return nil
}
return fmt.Errorf("busctl: bus pid=%d did not exit within %s", pid, cfg.Timeout)
}
func requestBusStop(endpoint string) error {
conn, err := stopDial(endpoint)
if err != nil {
return fmt.Errorf("busctl: dial bus for stop: %w", err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(DefaultStatusRPCTimeout))
w := transport.NewWriter(conn)
r := transport.NewReader(conn)
if err := w.WriteJSON(transport.Hello{
Type: transport.FrameTypeHello,
ConsumerPID: os.Getpid(),
Role: transport.HelloRoleStop,
}); err != nil {
return fmt.Errorf("busctl: write stop hello: %w", err)
}
var bye transport.Bye
if err := r.ReadJSON(&bye); err != nil {
return fmt.Errorf("busctl: read stop response: %w", err)
}
if bye.Type != transport.FrameTypeBye || bye.Reason != "stop_request" {
return fmt.Errorf("busctl: unexpected stop response type=%q reason=%q", bye.Type, bye.Reason)
}
return nil
}
func waitForBusExit(pid int, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if !stopAlive(pid) {
return true
}
time.Sleep(50 * time.Millisecond)
}
return !stopAlive(pid)
}
+270 -1
View File
@@ -16,6 +16,7 @@ package busctl
import (
"context"
"errors"
"net"
"os"
"os/exec"
"path/filepath"
@@ -23,7 +24,10 @@ import (
"testing"
"time"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestStop_NotRunningWhenLockMissing(t *testing.T) {
@@ -34,7 +38,7 @@ func TestStop_NotRunningWhenLockMissing(t *testing.T) {
}
}
func TestStop_NotRunningWhenPIDDead(t *testing.T) {
func TestCrossPlatformCoverageStopNotRunningWhenPIDDead(t *testing.T) {
dir := shortTempDir(t)
// Write a definitely-dead PID into bus.lock.
if err := os.WriteFile(LockPath(dir), []byte("2147483646\n"), 0o600); err != nil {
@@ -46,6 +50,269 @@ func TestStop_NotRunningWhenPIDDead(t *testing.T) {
}
}
func TestCrossPlatformCoverageStopPrefersGracefulIPC(t *testing.T) {
const pid = 4242
stopped := false
signals := 0
testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid })
testseam.Swap(t, &stopAlive, func(int) bool { return !stopped })
proc, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil })
testseam.Swap(t, &stopRequest, func(endpoint string) error {
if endpoint != "test-endpoint" {
t.Fatalf("stop endpoint = %q", endpoint)
}
stopped = true
return nil
})
testseam.Swap(t, &stopSignalProcess, func(*os.Process, os.Signal) error {
signals++
return nil
})
if err := Stop(StopConfig{
WorkDir: "test-workdir",
IPCEndpoint: "test-endpoint",
Timeout: time.Second,
}); err != nil {
t.Fatalf("Stop() = %v", err)
}
if signals != 0 {
t.Fatalf("graceful IPC stop used %d process signals", signals)
}
}
func TestCrossPlatformCoverageStopDoesNotSignalUnverifiedReusedPID(t *testing.T) {
const pid = 4242
testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid })
testseam.Swap(t, &stopAlive, func(int) bool { return true })
proc, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil })
testseam.Swap(t, &stopRequest, func(string) error { return errors.New("stale endpoint") })
testseam.Swap(t, &stopValidateHolderOwner, func(path string, gotPID int) (bool, error) {
if path != LockPath("test-workdir") || gotPID != pid {
t.Fatalf("ownership check path=%q pid=%d", path, gotPID)
}
return false, nil
})
signals := 0
testseam.Swap(t, &stopSignalProcess, func(*os.Process, os.Signal) error {
signals++
return nil
})
err = Stop(StopConfig{
WorkDir: "test-workdir",
IPCEndpoint: "stale-endpoint",
Timeout: time.Millisecond,
})
if !errors.Is(err, ErrOwnerUnverified) {
t.Fatalf("Stop() error = %v, want ErrOwnerUnverified", err)
}
if signals != 0 {
t.Fatalf("unverified reused PID received %d signals", signals)
}
}
func TestCrossPlatformCoverageStopAcceptsExitAtGracefulTimeoutBoundary(t *testing.T) {
const pid = 4242
t.Run("before ownership validation", func(t *testing.T) {
exited := false
validated := false
testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid })
testseam.Swap(t, &stopAlive, func(int) bool { return !exited })
proc, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil })
testseam.Swap(t, &stopRequest, func(string) error { return nil })
testseam.Swap(t, &stopWaitForBusExit, func(int, time.Duration) bool {
exited = true
return false
})
testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) {
validated = true
return false, nil
})
if err := Stop(StopConfig{WorkDir: "test-workdir", IPCEndpoint: "test-endpoint"}); err != nil {
t.Fatalf("Stop() = %v, want success after bus exit", err)
}
if validated {
t.Fatal("ownership was validated after the bus had already exited")
}
})
t.Run("during ownership validation", func(t *testing.T) {
exited := false
signals := 0
testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid })
testseam.Swap(t, &stopAlive, func(int) bool { return !exited })
proc, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil })
testseam.Swap(t, &stopRequest, func(string) error { return nil })
testseam.Swap(t, &stopWaitForBusExit, func(int, time.Duration) bool { return false })
testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) {
exited = true
return false, nil
})
testseam.Swap(t, &stopSignalProcess, func(*os.Process, os.Signal) error {
signals++
return nil
})
if err := Stop(StopConfig{WorkDir: "test-workdir", IPCEndpoint: "test-endpoint"}); err != nil {
t.Fatalf("Stop() = %v, want success after exit during ownership validation", err)
}
if signals != 0 {
t.Fatalf("exited bus received %d fallback signals", signals)
}
})
}
func TestCrossPlatformCoverageStopReportsOwnershipValidationError(t *testing.T) {
const pid = 4242
errInjected := errors.New("ownership validation failed")
testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid })
testseam.Swap(t, &stopAlive, func(int) bool { return true })
proc, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil })
testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) {
return false, errInjected
})
signals := 0
testseam.Swap(t, &stopSignalProcess, func(*os.Process, os.Signal) error {
signals++
return nil
})
err = Stop(StopConfig{WorkDir: "test-workdir"})
if !errors.Is(err, errInjected) {
t.Fatalf("Stop() error = %v, want injected ownership error", err)
}
if signals != 0 {
t.Fatalf("ownership validation error sent %d signals", signals)
}
}
func TestCrossPlatformCoverageRequestBusStopProtocol(t *testing.T) {
dir := shortTempDir(t)
endpoint := dwsevent.IPCEndpoint(
dir,
"open",
dwsevent.SourceKindAppStream,
dwsevent.IdentityHash(dir),
)
listener, err := transport.Listen(endpoint)
if err != nil {
t.Fatalf("Listen() = %v", err)
}
defer listener.Close()
serverDone := make(chan error, 1)
go func() {
conn, err := listener.Accept()
if err != nil {
serverDone <- err
return
}
defer conn.Close()
r := transport.NewReader(conn)
w := transport.NewWriter(conn)
var hello transport.Hello
if err := r.ReadJSON(&hello); err != nil {
serverDone <- err
return
}
if hello.Type != transport.FrameTypeHello || hello.Role != transport.HelloRoleStop {
serverDone <- errors.New("unexpected stop hello")
return
}
serverDone <- w.WriteJSON(transport.Bye{
Type: transport.FrameTypeBye,
Reason: "stop_request",
})
}()
if err := requestBusStop(endpoint); err != nil {
t.Fatalf("requestBusStop() = %v", err)
}
if err := <-serverDone; err != nil {
t.Fatalf("stop protocol server = %v", err)
}
}
func TestCrossPlatformCoverageRequestBusStopErrors(t *testing.T) {
t.Run("dial", func(t *testing.T) {
testseam.Swap(t, &stopDial, func(string) (net.Conn, error) {
return nil, errors.New("dial failed")
})
if err := requestBusStop("test-endpoint"); err == nil {
t.Fatal("requestBusStop() unexpectedly succeeded")
}
})
t.Run("write", func(t *testing.T) {
testseam.Swap(t, &stopDial, func(string) (net.Conn, error) {
return &queryErrorConn{failAt: 1}, nil
})
if err := requestBusStop("test-endpoint"); err == nil {
t.Fatal("requestBusStop() unexpectedly succeeded")
}
})
t.Run("read", func(t *testing.T) {
testseam.Swap(t, &stopDial, func(string) (net.Conn, error) {
return &queryErrorConn{}, nil
})
if err := requestBusStop("test-endpoint"); err == nil {
t.Fatal("requestBusStop() unexpectedly succeeded")
}
})
t.Run("unexpected response", func(t *testing.T) {
client, server := net.Pipe()
t.Cleanup(func() {
_ = client.Close()
_ = server.Close()
})
testseam.Swap(t, &stopDial, func(string) (net.Conn, error) {
return client, nil
})
serverDone := make(chan error, 1)
go func() {
var hello transport.Hello
if err := transport.NewReader(server).ReadJSON(&hello); err != nil {
serverDone <- err
return
}
serverDone <- transport.NewWriter(server).WriteJSON(transport.Bye{
Type: transport.FrameTypeBye,
Reason: "unexpected",
})
}()
if err := requestBusStop("test-endpoint"); err == nil {
t.Fatal("requestBusStop() unexpectedly succeeded")
}
if err := <-serverDone; err != nil {
t.Fatalf("stop protocol server = %v", err)
}
})
}
func TestStop_SignalsLiveProcess(t *testing.T) {
skipOnWindows(t)
dir := shortTempDir(t)
@@ -63,6 +330,7 @@ func TestStop_SignalsLiveProcess(t *testing.T) {
}
}()
pid := cmd.Process.Pid
testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) { return true, nil })
// Reap the child in background so Wait doesn't leave a zombie.
waited := make(chan error, 1)
@@ -111,6 +379,7 @@ func TestStop_TimeoutWhenChildIgnoresSignal(t *testing.T) {
_, _ = cmd.Process.Wait()
}()
pid := cmd.Process.Pid
testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) { return true, nil })
if err := os.WriteFile(LockPath(dir), []byte(strconv.Itoa(pid)+"\n"), 0o600); err != nil {
t.Fatal(err)
+4 -5
View File
@@ -17,8 +17,7 @@ package busctl
import "os"
// stopSignal returns the graceful-shutdown signal for Windows. The Go
// runtime maps os.Interrupt to TerminateProcess for non-console-group
// processes — not truly graceful, but acceptable for v1 (Windows graceful
// shutdown via Ctrl+Break is in the v2 backlog, plan §16).
func stopSignal() os.Signal { return os.Interrupt }
// stopSignal is only the fallback after the graceful IPC stop path fails or
// times out. Go maps os.Kill to TerminateProcess on Windows; os.Interrupt is
// unsupported and returns syscall.EWINDOWS.
func stopSignal() os.Signal { return os.Kill }
@@ -0,0 +1,27 @@
// 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.
//go:build windows
package busctl
import (
"os"
"testing"
)
func TestCrossPlatformCoverageWindowsStopFallbackUsesKill(t *testing.T) {
if stopSignal() != os.Kill {
t.Fatalf("stopSignal() = %v, want os.Kill", stopSignal())
}
}
+82 -2
View File
@@ -230,7 +230,7 @@ func newCalendarCommand() *cobra.Command {
Long: `管理钉钉日历:日程、参会人、会议室、闲忙、附件、日历本、访问权限。调用前必须先使用 --help 查看参数结构。
命令结构:
dws calendar event [list|get|create|update|delete|suggest|respond|instances] 日程管理
dws calendar event [list|get|create|update|delete|suggest|respond|instances|share-info] 日程管理
dws calendar attendee [list|add|delete] 参会人管理
dws calendar room [search|add|delete|list-groups] 会议室管理
dws calendar busy search 闲忙查询 (可查人、查会议室)
@@ -2259,7 +2259,87 @@ func newCalendarCommand() *cobra.Command {
eventInstancesCmd.Flags().Int("count", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("count")
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd, eventInstancesCmd)
eventShareInfoCmd := &cobra.Command{
Use: "share-info",
Aliases: []string{"share_info"},
Short: "获取日程的分享信息",
Long: `根据日程 ID 获取日程的分享信息,展示日程主题、组织人、地点、入会信息等,用于向他人分享日程。eventId 可通过 dws calendar event list 获取。`,
Example: ` dws calendar event share-info --id EVENT_ID
dws calendar event share-info --id EVENT_ID --language zh-CN
dws calendar event share-info --id EVENT_ID --calendar-id primary`,
RunE: func(cmd *cobra.Command, args []string) error {
eventID, err := mustFlagOrFallback(cmd, "id", "event", "event-id", "eventId")
if err != nil {
return err
}
toolArgs := map[string]any{"eventId": eventID}
if v := flagOrFallback(cmd, "calendar-id", "calendarId", "calendar"); v != "" {
toolArgs["calendarId"] = v
}
if v := flagOrFallback(cmd, "language", "lang"); v != "" {
toolArgs["language"] = v
}
return callMCPTool("get_event_share_info", toolArgs)
},
}
DeclareLeafMetadata(eventShareInfoCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "calendar",
Name: "get_event_share_info",
CanonicalPath: "calendar.get_event_share_info",
CLIPath: "calendar event share-info",
PrimaryCLIPath: "calendar event share-info",
Aliases: []string{"calendar event share_info"},
},
Description: "获取日程的分享信息",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "calendar", RPCName: "get_event_share_info"},
},
Selection: contract.SelectionSpec{
AgentSummary: "获取日程的分享信息(主题、组织人、地点、入会信息等)",
UseWhen: []string{"已知 eventId,需要获取日程分享信息或入会信息用于分享给他人时"},
AvoidWhen: []string{
"要查看日程详情改用 dws calendar event get",
"未知 eventId 时先 dws calendar event list",
},
Examples: []string{
"dws calendar event share-info --id <EVENT_ID>",
"dws calendar event share-info --id <EVENT_ID> --language zh-CN",
},
},
Parameters: []contract.ParamDecl{
{Name: "id", Property: "eventId", Required: boolPtr(true)},
{Name: "calendar-id", Property: "calendarId"},
{Name: "language", Property: "language"},
},
},
})
// EventShareInfo flags
eventShareInfoCmd.Flags().String("id", "", "日程 ID (必填)")
eventShareInfoCmd.Flags().String("event", "", "")
_ = eventShareInfoCmd.Flags().MarkHidden("event")
eventShareInfoCmd.Flags().String("event-id", "", "")
_ = eventShareInfoCmd.Flags().MarkHidden("event-id")
eventShareInfoCmd.Flags().String("eventId", "", "")
_ = eventShareInfoCmd.Flags().MarkHidden("eventId")
eventShareInfoCmd.Flags().String("calendar-id", "", "日历 ID (默认 primary 主日历)")
eventShareInfoCmd.Flags().String("calendarId", "", "")
_ = eventShareInfoCmd.Flags().MarkHidden("calendarId")
eventShareInfoCmd.Flags().String("calendar", "", "")
_ = eventShareInfoCmd.Flags().MarkHidden("calendar")
eventShareInfoCmd.Flags().String("language", "", "语言代码 (可选,如 zh-CN)")
eventShareInfoCmd.Flags().String("lang", "", "")
_ = eventShareInfoCmd.Flags().MarkHidden("lang")
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd, eventInstancesCmd, eventShareInfoCmd)
// participant
participantCmd.PersistentFlags().String("event", "", "日程 ID (必填)")
+54 -8
View File
@@ -3413,10 +3413,11 @@ func newChatCommand() *cobra.Command {
chatMessageSendByBotCmd := &cobra.Command{
Use: "send-by-bot",
Short: "机器人发送消息(--conversation-id 群聊 / --users 单聊)",
Long: `群聊:传 --conversation-id 指定群;单聊:传 --users 或 --open-dingtalk-ids 指定用户列表,与 --conversation-id 只能选其一,不能同时指定。省略 --msg-type 时发送 Markdown;图片使用 --msg-type image --image-url;本地文件使用 --msg-type file --file-path,CLI 完成上传后发送。
Long: `群聊:传 --conversation-id 指定群;单聊:传 --users 或 --open-dingtalk-ids 指定用户列表,与 --conversation-id 只能选其一,不能同时指定。省略 --msg-type 时发送 Markdown;图片使用 --msg-type image --image-url;本地文件使用 --msg-type file --file-path,CLI 完成上传后发送。机器人引用回复仅支持群聊 Markdown,同时传 --reply(原消息 openMessageId)和 --ref-sender(原消息发送者 openDingTalkId);回复可省略 --title,CLI 会从正文生成标题。
⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。`,
Example: ` dws chat message send-by-bot --robot-code <robot-code> --conversation-id <openconversation_id> --title "日报" --text "## 今日完成..."
dws chat message send-by-bot --robot-code <robot-code> --conversation-id <openconversation_id> --reply <openMessageId> --ref-sender <openDingTalkId> --text "收到"
dws chat message send-by-bot --robot-code <robot-code> --conversation-id <openconversation_id> --msg-type image --image-url "https://example.com/image.png"
dws chat message send-by-bot --robot-code <robot-code> --conversation-id <openconversation_id> --msg-type file --file-path ./report.pdf
dws chat message send-by-bot --robot-code <robot-code> --users userId1,userId2 --title "提醒" --text "请提交周报"
@@ -3433,10 +3434,17 @@ func newChatCommand() *cobra.Command {
text := mustGetFlag(cmd, "text")
imageURL := strings.TrimSpace(mustGetFlag(cmd, "image-url"))
filePath := mustGetFlag(cmd, "file-path")
referenceOpenMessageID := strings.TrimSpace(mustGetFlag(cmd, "reply"))
refSender := strings.TrimSpace(mustGetFlag(cmd, "ref-sender"))
replyChanged := cmd.Flags().Changed("reply")
refSenderChanged := cmd.Flags().Changed("ref-sender")
if err := validateRequiredFlags(cmd, "robot-code"); err != nil {
return err
}
if replyChanged != refSenderChanged || (replyChanged && (referenceOpenMessageID == "" || refSender == "")) {
return fmt.Errorf("--reply and --ref-sender must be specified together")
}
if msgType == "" {
if imageURL != "" {
return fmt.Errorf("--msg-type image is required when using --image-url")
@@ -3448,8 +3456,17 @@ func newChatCommand() *cobra.Command {
}
switch msgType {
case "markdown":
if err := validateRequiredFlags(cmd, "title", "text"); err != nil {
return err
if referenceOpenMessageID != "" {
if err := validateRequiredFlags(cmd, "text"); err != nil {
return err
}
if title == "" {
title = sanitizeTitleFromText(text)
}
} else {
if err := validateRequiredFlags(cmd, "title", "text"); err != nil {
return err
}
}
case "image":
if imageURL == "" {
@@ -3475,6 +3492,22 @@ func newChatCommand() *cobra.Command {
return fmt.Errorf("--conversation-id or --users/--open-dingtalk-ids is required")
}
if referenceOpenMessageID != "" {
if chatID == "" {
return fmt.Errorf("--reply and --ref-sender are only supported with --conversation-id")
}
if msgType != "markdown" {
return fmt.Errorf("--reply and --ref-sender only support Markdown group messages")
}
if !isOpenDingTalkID(refSender) {
resolved, err := resolveOpenDingTalkID(cmd.Context(), refSender)
if err != nil {
return err
}
refSender = resolved
}
}
userIDs := splitCommaList(usersStr)
openDingTalkIDs := splitCommaList(openDingtalkIdsStr)
fileURL := ""
@@ -3540,6 +3573,10 @@ func newChatCommand() *cobra.Command {
toolArgs["msgKey"] = "sampleMarkdownDX"
}
toolArgs["openConversationId"] = chatID
if referenceOpenMessageID != "" {
toolArgs["referenceOpenMessageId"] = referenceOpenMessageID
toolArgs["srcMsgSendOpenDingTalkId"] = refSender
}
if len(atUserIds) > 0 {
toolArgs["atUserIds"] = atUserIds
}
@@ -3586,17 +3623,20 @@ func newChatCommand() *cobra.Command {
CLIPath: "chat message send-by-bot",
PrimaryCLIPath: "chat message send-by-bot",
},
Description: "以应用机器人身份发送 Markdown、图片或文件群消息或批量单聊",
Description: "以应用机器人身份发送 Markdown、图片或文件群消息、群聊引用回复或批量单聊",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "命令包含多个 RPC、条件分派或本地 HTTP/文件步骤,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "以应用机器人身份发送 Markdown、图片或文件群消息或批量单聊",
UseWhen: []string{"已有 robotCode 且需要机器人身份投递 Markdown、图片或文件时"},
AgentSummary: "以应用机器人身份发送 Markdown、图片或文件群消息、群聊引用回复或批量单聊",
UseWhen: []string{"已有 robotCode 且需要机器人身份投递 Markdown、图片或文件时", "需要机器人在群聊中引用回复一条已有消息时"},
AvoidWhen: []string{"个人身份发送或自定义 Webhook 告警不要使用"},
Examples: []string{"dws chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title \"日报\" --text \"今日进展\""},
Examples: []string{
"dws chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title \"日报\" --text \"今日进展\"",
"dws chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --reply <openMessageId> --ref-sender <openDingTalkId> --text \"收到\"",
},
},
// Keep title/text required_when out of Schema: the runtime switch above
// enforces Markdown inputs, while adding it breaks merge-base compatibility.
@@ -3605,6 +3645,8 @@ func newChatCommand() *cobra.Command {
{Name: "msg-type", RequiredWhen: "image-url or file-path is provided", Enum: []string{"markdown", "image", "file"}},
{Name: "image-url", RequiredWhen: "msg-type is image"},
{Name: "file-path", RequiredWhen: "msg-type is file"},
{Name: "reply", Property: "referenceOpenMessageId", RequiredWhen: "ref-sender is provided", InterfaceType: "string"},
{Name: "ref-sender", Property: "srcMsgSendOpenDingTalkId", RequiredWhen: "reply is provided", InterfaceType: "string"},
},
},
})
@@ -4768,7 +4810,7 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
chatMessageSendByBotCmd.Flags().String("conversation-id", "", "群聊 openConversationId(群聊时必填)")
chatMessageSendByBotCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔,最多20个(单聊时必填)")
chatMessageSendByBotCmd.Flags().String("msg-type", "", "消息类型: markdown/image/file(省略时为 markdown;图片使用 image --image-url;本地文件使用 file --file-path)")
chatMessageSendByBotCmd.Flags().String("title", "", "Markdown 消息标题(发送 Markdown 时必填)")
chatMessageSendByBotCmd.Flags().String("title", "", "Markdown 消息标题(发送普通 Markdown 时必填;引用回复省略时从正文生成)")
chatMessageSendByBotCmd.Flags().String("text", "", "Markdown 消息内容(发送 Markdown 时必填;稳定换行用空行,转义形式写 \\n\\n,不要只写 \\n)")
chatMessageSendByBotCmd.Flags().String("image-url", "", "公网图片 URL(msgType=image 时必填)")
chatMessageSendByBotCmd.Flags().String("file-path", "", "本地文件路径(msgType=file 时直接上传并按 file 消息发送)")
@@ -4776,9 +4818,13 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
chatMessageSendByBotCmd.Flags().String("open-dingtalk-ids", "", "用户 openDingtalkId 列表,逗号分隔(单聊时可替代 --users,可选)")
chatMessageSendByBotCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingtalkId 列表,逗号分隔(仅群聊时生效,可选)")
chatMessageSendByBotCmd.Flags().Bool("at-all", false, "@所有人(可选),服务端接收字符串 true/false")
chatMessageSendByBotCmd.Flags().String("reply", "", "被引用消息的 openMessageId(仅群聊 Markdown;必须与 --ref-sender 同时使用)")
chatMessageSendByBotCmd.Flags().String("ref-sender", "", "被引用消息发送者的 openDingTalkId(仅群聊 Markdown;必须与 --reply 同时使用)")
chatMessageSendByBotCmd.MarkFlagsRequiredTogether("reply", "ref-sender")
cli.AnnotateRuntimeConstraints(chatMessageSendByBotCmd, cli.RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"group", "users"}},
RequireOneOf: [][]string{{"group", "users"}},
RequireTogether: [][]string{{"reply", "ref-sender"}},
})
cli.AnnotateRuntimeFlagFormat(chatMessageSendByBotCmd, "file-path", "file-path")
@@ -446,6 +446,100 @@ func TestCrossPlatformCoverageChatBotRichMediaCoverage(t *testing.T) {
if caller.tool != "batch_send_robot_msg_to_users" || caller.args["msgType"] != "sampleMarkdownDX" {
t.Fatalf("direct markdown call = %s %#v", caller.tool, caller.args)
}
if _, exists := caller.args["referenceOpenMessageId"]; exists {
t.Fatalf("ordinary direct message unexpectedly contains reply fields: %#v", caller.args)
}
})
t.Run("group Markdown reference reply", func(t *testing.T) {
const senderOpenDingTalkID = "DAAAAAAAAAAAiE"
caller := &scriptedToolCaller{}
if err := runChatCoverageCommand(t, caller,
"message", "send-by-bot", "--robot-code", "robot", "--conversation-id", "group",
"--reply", "message-id", "--ref-sender", senderOpenDingTalkID,
"--text", "received",
); err != nil {
t.Fatal(err)
}
if caller.tool != "send_robot_group_message" ||
caller.args["referenceOpenMessageId"] != "message-id" ||
caller.args["srcMsgSendOpenDingTalkId"] != senderOpenDingTalkID ||
caller.args["title"] != "received" {
t.Fatalf("group reference reply call = %s %#v", caller.tool, caller.args)
}
})
t.Run("group reference reply resolves sender user ID", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"result":[{"userId":"sender-user","openDingTalkId":"D-sender"}]}`},
{text: `{}`},
}}
if err := runChatCoverageCommand(t, caller,
"message", "send-by-bot", "--robot-code", "robot", "--conversation-id", "group",
"--reply", "message-id", "--ref-sender", "sender-user",
"--title", "reply", "--text", "received",
); err != nil {
t.Fatal(err)
}
if caller.tool != "send_robot_group_message" || caller.args["srcMsgSendOpenDingTalkId"] != "D-sender" {
t.Fatalf("resolved group reference reply = %s %#v", caller.tool, caller.args)
}
})
t.Run("direct RunE enforces paired reply flags", func(t *testing.T) {
err := runChatCoverageDirect(t, []string{"message", "send-by-bot"}, map[string]string{
"robot-code": "robot",
"conversation-id": "group",
"reply": "message-id",
"title": "reply",
"text": "received",
})
if err == nil || !strings.Contains(err.Error(), "must be specified together") {
t.Fatalf("direct RunE paired reply flags error = %v", err)
}
})
for _, tc := range []struct {
name string
args []string
want string
}{
{name: "reply requires sender", args: []string{"--reply", "message-id"}, want: "ref-sender"},
{name: "sender requires reply", args: []string{"--ref-sender", "D-sender"}, want: "reply"},
{name: "paired reply flags reject empty values", args: []string{"--reply=", "--ref-sender="}, want: "specified together"},
} {
t.Run(tc.name, func(t *testing.T) {
args := []string{
"message", "send-by-bot", "--robot-code", "robot", "--conversation-id", "group",
"--title", "reply", "--text", "received",
}
err := runChatCoverageCommand(t, &scriptedToolCaller{}, append(args, tc.args...)...)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("paired reply flags error = %v, want %q", err, tc.want)
}
})
}
t.Run("reference reply is group only", func(t *testing.T) {
err := runChatCoverageCommand(t, &scriptedToolCaller{},
"message", "send-by-bot", "--robot-code", "robot", "--users", "user",
"--reply", "message-id", "--ref-sender", "D-sender",
"--title", "reply", "--text", "received",
)
if err == nil || !strings.Contains(err.Error(), "only supported with --conversation-id") {
t.Fatalf("direct reference reply error = %v", err)
}
})
t.Run("reference reply rejects rich media", func(t *testing.T) {
err := runChatCoverageCommand(t, &scriptedToolCaller{},
"message", "send-by-bot", "--robot-code", "robot", "--conversation-id", "group",
"--reply", "message-id", "--ref-sender", "D-sender",
"--msg-type", "image", "--image-url", "https://example.test/image.png",
)
if err == nil || !strings.Contains(err.Error(), "only support Markdown") {
t.Fatalf("rich-media reference reply error = %v", err)
}
})
t.Run("direct image", func(t *testing.T) {
+281 -6
View File
@@ -9,6 +9,7 @@ import (
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -492,10 +493,11 @@ func defaultHTTPGetFile(ctx context.Context, url string, headers map[string]stri
return nil
}
// runMediaInsert implements the three-step flow for inserting an attachment into a document:
// runMediaInsert implements the four-step flow for inserting an attachment into a document:
// 1. get_doc_attachment_upload_info → obtain uploadUrl + resourceId
// 2. HTTP PUT file content to OSS
// 3. insert_document_block with attachment element
// 4. list_document_blocks → prove the uploaded resource is visible in the document
func runMediaInsert(cmd *cobra.Command, _ []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
@@ -551,7 +553,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
// Step 1: get upload credentials (uploadUrl + resourceId)
deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
deps.Out.PrintInfo(fmt.Sprintf("[1/4] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
credText, err := callMCPToolReturnText(ctx, "get_doc_attachment_upload_info", map[string]any{
"nodeId": nodeID,
@@ -569,7 +571,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
}
// Step 2: HTTP PUT file to OSS
deps.Out.PrintInfo("[2/3] 上传文件到 OSS...")
deps.Out.PrintInfo("[2/4] 上传文件到 OSS...")
ossHeaders := map[string]string{
"Content-Type": mimeType,
@@ -592,7 +594,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
}
// Step 3: insert block into document
deps.Out.PrintInfo("[3/3] 插入块到文档...")
deps.Out.PrintInfo("[3/4] 插入块到文档...")
const maxInlineImageSize = 20 * 1024 * 1024 // 20MB
@@ -644,7 +646,8 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
insertArgs["referenceBlockId"] = v
}
if err := callMCPTool("insert_document_block", insertArgs); err != nil {
insertText, err := callMCPToolReturnText(ctx, "insert_document_block", insertArgs)
if err != nil {
return apperrors.NewAPI(
"附件已上传,但正文 block 插入结果未知;请先检查媒体列表,不要重复上传或插入",
apperrors.WithOperation("doc.media_insert"),
@@ -665,6 +668,23 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
apperrors.WithCause(err),
)
}
insertResult := map[string]any{}
if strings.TrimSpace(insertText) != "" {
if err := json.Unmarshal([]byte(insertText), &insertResult); err != nil {
return docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName,
fmt.Errorf("解析 insert_document_block 响应失败: %w", err))
}
}
insertedBlockID := insertedDocBlockID(insertResult)
deps.Out.PrintInfo("[4/4] 回读验证媒体块...")
verifiedBlockID, verifyErr := verifyInsertedDocMedia(ctx, nodeID, insertedBlockID, resourceID, resourceURL)
if verifyErr != nil {
return docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName, verifyErr)
}
if insertedBlockID == "" {
insertedBlockID = verifiedBlockID
}
return deps.Out.PrintJSON(map[string]any{
"contractVersion": "doc.operation.v1",
@@ -674,16 +694,271 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
"operation": "doc.media_insert",
"data": map[string]any{
"nodeId": nodeID, "resourceId": resourceID, "resourceUrl": resourceURL,
"fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize, "inserted": true,
"blockId": insertedBlockID, "fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize,
"inserted": true, "verified": true,
},
"steps": []map[string]any{
{"name": "resolve_upload", "status": "success"},
{"name": "upload_oss", "status": "success"},
{"name": "insert_block", "status": "success"},
{"name": "verify", "status": "success"},
},
})
}
func docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName string, cause error) error {
return apperrors.NewAPI(
"附件已上传且插块请求已执行,但回读未能证明媒体块落库;不要直接重试上传或插入",
apperrors.WithOperation("doc.media_insert"),
apperrors.WithReason("doc_media_insert_verification_failed"),
apperrors.WithFailureStage("verify"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithActions("运行 dws doc +media-list 检查 resourceId", "确认媒体不存在后再决定是否重新执行"),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.operation.v1", "status": "partial_success", "nodeId": nodeID,
"resourceId": resourceID, "resourceUrl": resourceURL, "fileName": fileName, "verified": false,
"steps": []map[string]any{
{"name": "resolve_upload", "status": "success"},
{"name": "upload_oss", "status": "success"},
{"name": "insert_block", "status": "success"},
{"name": "verify", "status": "failed"},
},
}),
apperrors.WithCause(cause),
)
}
var docMediaVerifyWait = waitForDocVerification
func verifyInsertedDocMedia(ctx context.Context, nodeID, blockID, resourceID, resourceURL string) (string, error) {
delays := []time.Duration{250 * time.Millisecond, 500 * time.Millisecond, time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second}
var lastErr error
for attempt := 0; attempt <= len(delays); attempt++ {
blocks, err := readAllDocBlocksForVerification(ctx, nodeID)
if err != nil {
lastErr = err
} else {
lastErr = nil
if found := findVerifiedMediaBlock(blocks, blockID, resourceID, resourceURL); found != "" {
return found, nil
}
}
if attempt < len(delays) {
if err := docMediaVerifyWait(ctx, delays[attempt]); err != nil {
return "", err
}
}
}
if lastErr != nil {
return "", fmt.Errorf("媒体资源在有界回读窗口内仍无法读取: %w", lastErr)
}
return "", fmt.Errorf("媒体资源在有界回读窗口内仍不可见")
}
func waitForDocVerification(ctx context.Context, delay time.Duration) error {
if ctx == nil {
ctx = context.Background()
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func readAllDocBlocksForVerification(ctx context.Context, nodeID string) ([]any, error) {
const pageSize = 50
const maxItems = 5000
all := make([]any, 0, pageSize)
seenPageIdentities := map[string]bool{}
for start := 0; start < maxItems; start += pageSize {
text, err := callMCPToolReturnTextOnServer(ctx, "doc", "list_document_blocks", map[string]any{
"nodeId": nodeID, "format": "jsonml", "startIndex": start, "endIndex": start + pageSize - 1,
})
if err != nil {
return nil, err
}
var payload map[string]any
if err := json.Unmarshal([]byte(text), &payload); err != nil {
return nil, fmt.Errorf("解析 list_document_blocks 回读失败: %w", err)
}
payload = nestedDocMap(payload)
blocks, ok := payload["blocks"].([]any)
if !ok {
return nil, fmt.Errorf("list_document_blocks 回读缺少 blocks 数组")
}
pageIdentity := docBlockPageIdentity(blocks)
if pageIdentity != "" && seenPageIdentities[pageIdentity] {
return nil, fmt.Errorf("list_document_blocks 分页停滞")
}
if pageIdentity != "" {
seenPageIdentities[pageIdentity] = true
}
all = append(all, blocks...)
hasMore, hasMoreKnown := payload["hasMore"].(bool)
if hasMoreKnown && !hasMore {
return all, nil
}
if !hasMoreKnown {
if total, ok := docNumberAsInt(payload["totalCount"]); ok && len(all) >= total {
return all, nil
}
}
if !hasMoreKnown && len(blocks) < pageSize {
return all, nil
}
if len(blocks) == 0 {
return nil, fmt.Errorf("list_document_blocks 声明仍有下一页但当前页为空")
}
}
return nil, fmt.Errorf("文档块超过安全回读上限")
}
func docBlockPageIdentity(blocks []any) string {
if len(blocks) == 0 {
return ""
}
ids := make([]string, 0, len(blocks))
for _, value := range blocks {
id := ""
switch block := value.(type) {
case map[string]any:
id = directDocBlockIdentity(block)
if id == "" {
if element, ok := block["element"].(map[string]any); ok {
id = directDocBlockIdentity(element)
}
}
case []any:
if len(block) > 1 {
if attributes, ok := block[1].(map[string]any); ok {
id = directDocBlockIdentity(attributes)
}
}
}
if id == "" {
return ""
}
ids = append(ids, id)
}
encoded, _ := json.Marshal(ids)
return string(encoded)
}
func directDocBlockIdentity(block map[string]any) string {
for _, key := range []string{"blockId", "id", "uuid", "elementId"} {
if text, ok := block[key].(string); ok && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
return ""
}
func nestedDocMap(data map[string]any) map[string]any {
for _, key := range []string{"result", "data"} {
if nested, ok := data[key].(map[string]any); ok {
return nestedDocMap(nested)
}
}
return data
}
func nestedDocString(value any, keys ...string) string {
switch typed := value.(type) {
case map[string]any:
for _, key := range keys {
if text, ok := typed[key].(string); ok && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
orderedKeys := make([]string, 0, len(typed))
for key := range typed {
orderedKeys = append(orderedKeys, key)
}
sort.Strings(orderedKeys)
for _, key := range orderedKeys {
if text := nestedDocString(typed[key], keys...); text != "" {
return text
}
}
case []any:
for _, child := range typed {
if text := nestedDocString(child, keys...); text != "" {
return text
}
}
}
return ""
}
// insertedDocBlockID only accepts explicit block IDs from the insert result or
// known response wrappers. Arbitrary IDs may belong to the document, operator,
// or request and must not become a hard constraint for the media readback.
func insertedDocBlockID(data map[string]any) string {
for _, key := range []string{"blockId", "elementId"} {
if text, ok := data[key].(string); ok && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
for _, wrapper := range []string{"result", "data", "content"} {
if inner, ok := data[wrapper].(map[string]any); ok {
if text := insertedDocBlockID(inner); text != "" {
return text
}
}
}
return ""
}
func findVerifiedMediaBlock(blocks []any, blockID, resourceID, resourceURL string) string {
for _, value := range blocks {
candidateID := nestedDocString(value, "blockId", "id", "uuid")
if candidateID == "" || (blockID != "" && candidateID != blockID) {
continue
}
mediaValue := docMediaReadbackValue(value)
if resourceID != "" && nestedDocString(mediaValue, "resourceId") == resourceID {
return candidateID
}
if resourceURL != "" && nestedDocString(mediaValue, "resourceUrl", "src") == resourceURL {
return candidateID
}
}
return ""
}
func docMediaReadbackValue(value any) any {
block, ok := value.(map[string]any)
if !ok {
return value
}
encoded, ok := block["jsonml"].(string)
if !ok || strings.TrimSpace(encoded) == "" {
return value
}
var decoded any
if json.Unmarshal([]byte(encoded), &decoded) != nil {
return value
}
return decoded
}
func docNumberAsInt(value any) (int, bool) {
switch typed := value.(type) {
case float64:
if typed >= 0 && typed == float64(int(typed)) {
return int(typed), true
}
case int:
return typed, typed >= 0
}
return 0, false
}
// parseAttachmentUploadInfo extracts uploadUrl, resourceId and resourceUrl from the MCP tool response.
func parseAttachmentUploadInfo(text string) (uploadURL, resourceID, resourceURL string, err error) {
var data map[string]any
+226 -1
View File
@@ -3,7 +3,9 @@ package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -13,6 +15,7 @@ import (
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -74,6 +77,8 @@ func TestCrossPlatformCoverageDocUploadAndMediaErrorEdges(t *testing.T) {
t.Cleanup(func() { os.Args = oldArgs })
oldPut, oldGet := httpPutFile, httpGetFile
t.Cleanup(func() { httpPutFile, httpGetFile = oldPut, oldGet })
testseam.Swap(t, &helperSleep, func(time.Duration) {})
testseam.Swap(t, &docMediaVerifyWait, func(context.Context, time.Duration) error { return nil })
file := filepath.Join(t.TempDir(), "file.txt")
if err := os.WriteFile(file, []byte("content"), 0o600); err != nil {
t.Fatal(err)
@@ -193,6 +198,47 @@ func TestCrossPlatformCoverageDocUploadAndMediaErrorEdges(t *testing.T) {
t.Fatal("media insert failure returned nil")
}
})
t.Run("media insert response parse failure", func(t *testing.T) {
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"uploadUrl":"https://upload","resourceId":"resource"}`},
{text: `{`},
}}
if err := mediaCommand(t, caller, file, "text/plain"); err == nil {
t.Fatal("invalid media insert response returned nil")
}
})
t.Run("small image verifies src readback when upload also returns resource ID", func(t *testing.T) {
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`},
{text: `{"blockId":"media-block"}`},
{text: `{"blocks":[{"blockId":"media-block","jsonml":"[\"p\",{\"uuid\":\"media-block\"},[\"img\",{\"src\":\"https://image\"}]]"}],"hasMore":false}`},
}}
if err := mediaCommand(t, caller, file, "image/png"); err != nil {
t.Fatal(err)
}
if caller.calls != 3 {
t.Fatalf("media insert calls = %d, want credential, insert, and one readback", caller.calls)
}
if caller.args["format"] != "jsonml" {
t.Fatalf("media readback format = %#v, want jsonml", caller.args["format"])
}
})
t.Run("unrelated insert response IDs do not constrain media readback", func(t *testing.T) {
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`},
{text: `{"id":"document-id","operator":{"id":"operator-id"},"data":{"request":{"id":"request-id"}}}`},
{text: `{"blocks":[{"blockId":"media-block","jsonml":"[\"p\",{\"uuid\":\"media-block\"},[\"img\",{\"src\":\"https://image\"}]]"}],"hasMore":false}`},
}}
if err := mediaCommand(t, caller, file, "image/png"); err != nil {
t.Fatal(err)
}
if caller.calls != 3 {
t.Fatalf("media insert calls = %d, want credential, insert, and one readback", caller.calls)
}
})
t.Run("large image becomes attachment", func(t *testing.T) {
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
large := filepath.Join(t.TempDir(), "large.png")
@@ -202,7 +248,11 @@ func TestCrossPlatformCoverageDocUploadAndMediaErrorEdges(t *testing.T) {
if err := os.Truncate(large, 21*1024*1024); err != nil {
t.Fatal(err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`}, {text: `{}`}}}
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"uploadUrl":"https://upload","resourceId":"resource","resourceUrl":"https://image"}`},
{text: `{}`},
{text: `{"blocks":[{"blockId":"media-block","element":{"attachment":{"resourceId":"resource"}}}],"hasMore":false}`},
}}
if err := mediaCommand(t, caller, large, "image/png"); err != nil {
t.Fatal(err)
}
@@ -240,6 +290,181 @@ func TestCrossPlatformCoverageDefaultDocHTTPTransportEdges(t *testing.T) {
}
}
func TestCrossPlatformCoverageDocMediaReadbackDefensiveEdges(t *testing.T) {
testseam.Swap(t, &helperSleep, func(time.Duration) {})
testseam.Swap(t, &docMediaVerifyWait, func(context.Context, time.Duration) error { return nil })
ctx := context.Background()
for _, tc := range []struct {
name string
steps []scriptedToolStep
}{
{"call failure", []scriptedToolStep{{err: errors.New("read")}}},
{"invalid json", []scriptedToolStep{{text: `{`}}},
{"missing blocks", []scriptedToolStep{{text: `{}`}}},
{"stalled page", []scriptedToolStep{{text: `{"blocks":[{"id":"a"}],"hasMore":true}`}, {text: `{"blocks":[{"id":"a"}],"hasMore":true}`}}},
{"empty continued page", []scriptedToolStep{{text: `{"blocks":[],"hasMore":true}`}}},
} {
t.Run(tc.name, func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: tc.steps})
if _, err := readAllDocBlocksForVerification(ctx, "node"); err == nil {
t.Fatal("defensive readback returned nil")
}
})
}
t.Run("total count and nested payload", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"data":{"blocks":[{"id":"a"}],"totalCount":1}}`}}})
blocks, err := readAllDocBlocksForVerification(ctx, "node")
if err != nil || len(blocks) != 1 {
t.Fatalf("blocks=%#v err=%v", blocks, err)
}
})
t.Run("identical adjacent pages advance by requested indexes", func(t *testing.T) {
blocks := make([]any, 50)
for index := range blocks {
blocks[index] = map[string]any{"blockType": "paragraph"}
}
first, err := json.Marshal(map[string]any{"blocks": blocks, "hasMore": true, "totalCount": 100})
if err != nil {
t.Fatal(err)
}
second, err := json.Marshal(map[string]any{"blocks": blocks, "hasMore": false, "totalCount": 100})
if err != nil {
t.Fatal(err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: string(first)}, {text: string(second)}}}
installScriptedCaller(t, caller)
got, err := readAllDocBlocksForVerification(ctx, "node")
if err != nil || len(got) != 100 || caller.calls != 2 {
t.Fatalf("blocks=%d calls=%d err=%v, want 100 blocks from two pages", len(got), caller.calls, err)
}
})
t.Run("explicit has more overrides inconsistent total count", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"blocks":[{"id":"first"}],"hasMore":true,"totalCount":1}`},
{text: `{"blocks":[{"id":"second"}],"hasMore":false,"totalCount":2}`},
}}
installScriptedCaller(t, caller)
got, err := readAllDocBlocksForVerification(ctx, "node")
if err != nil || len(got) != 2 || caller.calls != 2 {
t.Fatalf("blocks=%d calls=%d err=%v, want both explicitly advertised pages", len(got), caller.calls, err)
}
})
t.Run("page identity accepts nested and JSONML block IDs", func(t *testing.T) {
if got := docBlockPageIdentity([]any{map[string]any{"element": map[string]any{"blockId": "nested-block"}}}); got != `["nested-block"]` {
t.Fatalf("nested block page identity = %q", got)
}
if got := docBlockPageIdentity([]any{[]any{"p", map[string]any{"uuid": "jsonml-block"}}}); got != `["jsonml-block"]` {
t.Fatalf("JSONML block page identity = %q", got)
}
})
t.Run("unknown pagination short page", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"id":"a"}]}`}}})
if blocks, err := readAllDocBlocksForVerification(ctx, "node"); err != nil || len(blocks) != 1 {
t.Fatalf("blocks=%#v err=%v", blocks, err)
}
})
t.Run("bounded retry failure", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[],"hasMore":false}`}}})
if _, err := verifyInsertedDocMedia(ctx, "node", "", "missing", ""); err == nil {
t.Fatal("missing media unexpectedly verified")
}
})
t.Run("transient read failure retries", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{err: errors.New("temporary read failure")},
{text: `{"blocks":[{"blockId":"media-block","element":{"attachment":{"resourceId":"resource"}}}],"hasMore":false}`},
}}
installScriptedCaller(t, caller)
blockID, err := verifyInsertedDocMedia(ctx, "node", "media-block", "resource", "")
if err != nil || blockID != "media-block" || caller.calls != 2 {
t.Fatalf("blockID=%q calls=%d err=%v", blockID, caller.calls, err)
}
})
t.Run("block identity alone does not verify media", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"media-block","element":{"attachment":{"resourceId":"other-resource"}}}],"hasMore":false}`}}})
if _, err := verifyInsertedDocMedia(ctx, "node", "media-block", "resource", ""); err == nil {
t.Fatal("matching block ID with a different resource unexpectedly verified")
}
})
t.Run("block read safety limit", func(t *testing.T) {
steps := make([]scriptedToolStep, 100)
for index := range steps {
steps[index] = scriptedToolStep{text: fmt.Sprintf(`{"blocks":[{"id":"block-%d"}],"hasMore":true}`, index)}
}
installScriptedCaller(t, &scriptedToolCaller{steps: steps})
if _, err := readAllDocBlocksForVerification(ctx, "node"); err == nil {
t.Fatal("oversized block read returned nil")
}
})
if got := nestedDocMap(map[string]any{"result": map[string]any{"data": map[string]any{"ok": true}}}); got["ok"] != true {
t.Fatalf("nested map=%#v", got)
}
if nestedDocString(map[string]any{"x": []any{map[string]any{"id": " nested "}}}, "id") != "nested" || nestedDocString(3, "id") != "" {
t.Fatal("nested string traversal failed")
}
if got := nestedDocString(map[string]any{"z": map[string]any{"id": "last"}, "a": map[string]any{"id": "first"}}, "id"); got != "first" {
t.Fatalf("nested string traversal = %q, want deterministic key order", got)
}
if got := insertedDocBlockID(map[string]any{"id": "document", "result": map[string]any{"data": map[string]any{"blockId": " block "}}}); got != "block" {
t.Fatalf("trusted inserted block ID = %q, want block", got)
}
if got := insertedDocBlockID(map[string]any{"id": "document", "operator": map[string]any{"id": "operator"}, "data": map[string]any{"request": map[string]any{"id": "request"}}}); got != "" {
t.Fatalf("untrusted inserted block ID = %q, want empty", got)
}
blocks := []any{map[string]any{"id": "block", "resourceId": "rid"}, map[string]any{"id": "url-block", "resourceUrl": "https://media"}, map[string]any{"id": "src-block", "jsonml": `["p",{},["img",{"src":"https://image"}]]`}}
if findVerifiedMediaBlock(blocks, "block", "rid", "") != "block" || findVerifiedMediaBlock(blocks, "", "rid", "") != "block" || findVerifiedMediaBlock(blocks, "", "", "https://media") != "url-block" || findVerifiedMediaBlock(blocks, "src-block", "upload-resource", "https://image") != "src-block" || findVerifiedMediaBlock(blocks, "other-block", "upload-resource", "https://image") != "" || findVerifiedMediaBlock(blocks, "block", "wrong", "") != "" || findVerifiedMediaBlock(blocks, "", "missing", "") != "" {
t.Fatal("media block matching failed")
}
if findVerifiedMediaBlock([]any{map[string]any{"id": "bad-jsonml", "jsonml": `[`}}, "bad-jsonml", "", "https://image") != "" {
t.Fatal("invalid JSONML unexpectedly verified")
}
if got := docMediaReadbackValue("plain"); got != "plain" {
t.Fatalf("non-object media readback = %#v, want unchanged value", got)
}
for _, tc := range []struct {
value any
want bool
}{
{float64(3), true}, {float64(-1), false}, {1.5, false}, {3, true}, {-1, false}, {"3", false},
} {
_, ok := docNumberAsInt(tc.value)
if ok != tc.want {
t.Fatalf("docNumberAsInt(%#v) ok=%v want=%v", tc.value, ok, tc.want)
}
}
}
func TestCrossPlatformCoverageDocMediaReadbackStopsOnCancellation(t *testing.T) {
if err := waitForDocVerification(nil, time.Nanosecond); err != nil {
t.Fatalf("completed verification wait = %v", err)
}
cancelled, cancel := context.WithCancel(context.Background())
cancel()
if err := waitForDocVerification(cancelled, time.Hour); !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled verification wait = %v, want context.Canceled", err)
}
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[],"hasMore":false}`}}}
installScriptedCaller(t, caller)
if _, err := verifyInsertedDocMedia(cancelled, "node", "", "missing", ""); !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled media verification = %v, want context.Canceled", err)
}
if caller.calls != 1 {
t.Fatalf("cancelled media verification calls = %d, want 1", caller.calls)
}
}
func TestCrossPlatformCoverageDocCreateUpdateAndBlockCommandEdges(t *testing.T) {
oldDeps, oldArgs, oldPut, oldGet := deps, os.Args, httpPutFile, httpGetFile
t.Cleanup(func() {
@@ -9,6 +9,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
@@ -194,6 +195,7 @@ func TestCrossPlatformCoverageDocDeprecationWrappersCoverage(t *testing.T) {
}
func TestCrossPlatformCoverageRunDocUploadDownloadAndMediaCoverage(t *testing.T) {
testseam.Swap(t, &docMediaVerifyWait, func(context.Context, time.Duration) error { return nil })
oldArgs := os.Args
os.Args = []string{"dws", "doc"}
t.Cleanup(func() { os.Args = oldArgs })
+45 -1
View File
@@ -60,6 +60,11 @@ func translateBatchOp(op map[string]any) (map[string]any, error) {
if !ok {
return nil, fmt.Errorf("unsupported toolName %q: must be a CLI command name (e.g. \"range clear\", \"range update\", \"merge-cells\"). Run 'dws sheet batch-update --help' for the full list", toolName)
}
if toolName == "set-dropdown" {
if err := validateBatchSetDropdownInput(input); err != nil {
return nil, err
}
}
return map[string]any{
"toolName": mapping.mcpTool,
@@ -67,6 +72,33 @@ func translateBatchOp(op map[string]any) (map[string]any, error) {
}, nil
}
func validateBatchSetDropdownInput(input map[string]any) error {
if _, exists := input["source-colors"]; exists {
return fmt.Errorf("set-dropdown: 顶层 source-colors 不受支持;Inline 颜色请写入 options[].color,SourceRange 颜色写入暂不支持")
}
if _, exists := input["colors"]; exists {
return fmt.Errorf("set-dropdown: 顶层 colors 不受支持;Inline 颜色请写入 options[].color,SourceRange 颜色写入暂不支持")
}
options, hasOptions := input["options"]
hasOptions = hasOptions && options != nil
sourceRange := batchStr(input, "source-range")
sourceSheetID := batchStr(input, "source-sheet-id")
hasSourceRange := sourceRange != ""
if hasOptions == hasSourceRange {
return fmt.Errorf("set-dropdown: options 与 source-range 必须且只能指定一个")
}
if hasSourceRange != (sourceSheetID != "") {
return fmt.Errorf("set-dropdown: source-range 与 source-sheet-id 必须同时指定")
}
if hasSourceRange {
if err := validateDropdownSourceRangeInput(sourceSheetID, sourceRange); err != nil {
return fmt.Errorf("set-dropdown: %w", err)
}
}
return nil
}
// ── BuildXxxArgs: CLI flag → MCP param 转换函数 ──────────────────────────────────
// 每个函数接收 CLI flag 名(kebab-case)的 map,输出 MCP 参数名(camelCase)的 map。
// 目前集中放在此文件;后续拆分命令文件时可移到各命令所在文件。
@@ -215,7 +247,15 @@ func BuildSetDropdownArgs(input map[string]any) map[string]any {
args := map[string]any{
"sheetId": batchStr(input, "sheet-id"),
"range": batchStr(input, "range"),
"options": input["options"],
}
if options, ok := input["options"]; ok && options != nil {
args["options"] = options
}
if sourceRange := batchStr(input, "source-range"); sourceRange != "" {
args["sourceRange"] = map[string]any{
"sheetId": batchStr(input, "source-sheet-id"),
"a1Notation": sourceRange,
}
}
if v, ok := input["multi-select"]; ok {
args["enableMultiSelect"] = v
@@ -417,6 +457,8 @@ func newBatchUpdateCmd() *cobra.Command {
toolName 使用 CLI 命令名(与原子命令一致),input 的键用 CLI flag 名去掉 --。
CLI 层自动翻译为 MCP toolName + 参数名,无需记忆 MCP 参数名。
source-range 的语义按 toolName 隔离:set-dropdown 中表示下拉候选项来源,
range fill/copy-to/move-to 中表示待填充、复制或移动的数据源区域。
支持的 CLI 命令名:
range clear / range update / merge-cells / unmerge-cells / update-dimension
@@ -425,6 +467,8 @@ CLI 层自动翻译为 MCP toolName + 参数名,无需记忆 MCP 参数名。
set-dropdown / delete-dropdown / csv-put / delete-float-image
其中 csv-put 与独立命令语义一致:CSV 字段值以 = 开头时按公式解析,
前加单引号(例如 "'=1+1")时写入以 = 开头的字面文本。
set-dropdown 不接受顶层 colors/source-colors;Inline 颜色应写在 options[].color,
SourceRange 颜色写入暂不支持。
注意:batch-update 中 group-dimension 适合默认展开分组;需要 --group-state fold 时请使用独立
dws sheet group-dimension 命令。
+9 -1
View File
@@ -23,7 +23,15 @@ func TestCrossPlatformCoverageSheetBatchOperationTranslationCoversEveryMapping(t
"group-state": "fold",
}
for name, mapping := range batchOpDispatch {
got, err := translateBatchOp(map[string]any{"toolName": name, "input": input})
opInput := input
if name == "set-dropdown" {
opInput = make(map[string]any, len(input))
for key, value := range input {
opInput[key] = value
}
delete(opInput, "source-range")
}
got, err := translateBatchOp(map[string]any{"toolName": name, "input": opInput})
if err != nil {
t.Errorf("translateBatchOp(%q): %v", name, err)
continue
+10 -2
View File
@@ -388,7 +388,10 @@ range update 与合并区域冲突时返回 MERGED_CELLS_CONFLICT 的行为。
csv 带 [row=N] 行号前缀的 CSV 文本
colIndices 列字母映射数组(定位列用 colIndices[j],禁止手数逗号)
rowIndices 行号映射数组
hasMore 是否因 maxChars 截断
hasMore 目标范围是否还有未返回数据
truncationReasons 部分返回原因:max_cells / max_chars
resolvedRange 未传 --range 时底层解析出的完整目标范围
returnedRange 本次实际完整返回的范围
取值模式(--value-render-option):
formatted_value 格式化后的展示值(默认),如 ¥1,000.00、2025-06-01
@@ -398,9 +401,14 @@ range update 与合并区域冲突时返回 MERGED_CELLS_CONFLICT 的行为。
与 range read 的区别:
- CSV 格式 token 消耗约为 JSON 的 1/3
- 支持选择取值模式
- 自动防爆(max-chars 截断 + has_more 标志)
- 自动防爆(30,000 单元格 / max-chars 上限 + hasMore 标志)
- [row=N] 前缀防止行号计算错误
当 hasMore=true 时本命令不会自动续读;请结合目标范围和
returnedRange 从下一行显式传 --range。max_cells 不能通过增大
--max-chars 解决。收到 forbidden.document.sizeOverLimit 表示工作簿整体
无法装载,应创建更小副本或拆分工作簿,缩小 --range 不能解决。
注意:csv-get 不返回合并单元格结构。查看合并范围请使用
dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 mergedRanges。`,
Example: ` dws sheet csv-get --node NODE_ID
+93 -22
View File
@@ -6,6 +6,7 @@ import (
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
@@ -18,6 +19,23 @@ func sizeTypeEnumHint(dimension string) string {
return "pixel / standard"
}
func validateDropdownSourceRangeInput(sourceSheetID, sourceRange string) error {
if strings.TrimSpace(sourceSheetID) == "" {
return fmt.Errorf("--source-sheet-id 不能为空")
}
sourceRange = strings.TrimSpace(sourceRange)
if sourceRange == "" {
return fmt.Errorf("--source-range 不能为空")
}
if strings.Contains(sourceRange, "!") {
return fmt.Errorf("--source-range 不能包含工作表前缀;请通过 --source-sheet-id 指定来源工作表")
}
if strings.HasPrefix(sourceRange, "=") || strings.Contains(sourceRange, ",") {
return fmt.Errorf("--source-range 必须是单一连续区域,不能是公式、表达式或多区域引用")
}
return nil
}
// newDimensionCmds creates dimension-related commands: insert/delete/update/move/add-dimension,
// merge-cells, unmerge-cells, and dropdown commands (set/get/delete-dropdown).
func newDimensionCmds() []*cobra.Command {
@@ -775,15 +793,24 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
Short: "设置下拉列表",
Long: `在钉钉表格的指定单元格范围内设置下拉列表。
设置后,用户可以在这些单元格中从预定义的选项列表中选择值。
支持自定义每个选项的颜色和是否允许多选。
Inline 模式通过 --options 提供静态选项,支持选项颜色。
SourceRange 模式通过 --source-sheet-id + --source-range 引用同一工作簿内的来源区域,
支持跨工作表、整行和整列引用;读取配置时不会展开来源区域的当前值或颜色。
两种模式都支持 --multi-select,并且 --options 与 --source-range 必须且只能指定一个。
如果目标范围已存在下拉列表,会被新的配置覆盖。
nodeId 支持传入文档链接 URL 或文档 ID(dentryUuid),系统自动识别。
sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获取。
--options 为 JSON 数组,每个元素包含 value(必填)和 color(可选)。
选项值不能包含英文逗号。`,
选项值不能包含英文逗号。
--source-range 只接受不含工作表前缀的 A1 区域,例如 T1:T3、T:T 或 1:3;
来源工作表由 --source-sheet-id 单独指定。SourceRange 颜色写入暂不支持。
已验证行为:工作表重命名、在引用前插入行/列、删除引用前的行会自动调整引用并保持 valid;
已验证的 move-dimension 场景会使引用变为 invalid。列删除、删除整个来源区域或来源工作表等未覆盖场景不能预设结果;
结构操作后应先回读 sourceRangeStatus,只有 invalid 时才重新选择来源并写入。`,
Example: ` # 设置单选下拉列表
dws sheet set-dropdown --node NODE_ID --sheet-id SHEET_ID --range "A2:A100" \
--options '[{"value":"选项1"},{"value":"选项2"},{"value":"选项3"}]'
@@ -793,32 +820,63 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
--options '[{"value":"高","color":"#ff0000"},{"value":"中","color":"#ffaa00"},{"value":"低","color":"#00ff00"}]' \
--multi-select
# 引用同一工作簿内另一工作表的区域作为候选项来源
dws sheet set-dropdown --node NODE_ID --sheet-id TARGET_SHEET_ID --range "C2:C100" \
--source-sheet-id SOURCE_SHEET_ID --source-range "T1:T3"
# 使用文档链接 URL
dws sheet set-dropdown --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" \
--sheet-id SHEET_ID --range "C1:C10" --options '[{"value":"是"},{"value":"否"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "node", "sheet-id", "range"); err != nil {
return err
}
optionsStr := mustGetFlag(cmd, "options")
var options []map[string]any
if err := json.Unmarshal([]byte(optionsStr), &options); err != nil {
return fmt.Errorf("--options JSON 解析失败: %w", err)
sourceSheetID := mustGetFlag(cmd, "source-sheet-id")
sourceRange := mustGetFlag(cmd, "source-range")
hasOptions := optionsStr != ""
hasSourceRange := sourceRange != ""
if hasOptions == hasSourceRange {
return fmt.Errorf("--options 与 --source-range 必须且只能指定一个")
}
if len(options) == 0 {
return fmt.Errorf("--options 至少包含 1 个选项")
if hasSourceRange && sourceSheetID == "" {
return fmt.Errorf("使用 --source-range 时必须同时指定 --source-sheet-id")
}
for i, opt := range options {
val, ok := opt["value"].(string)
if !ok || val == "" {
return fmt.Errorf("--options[%d] 缺少必填的 value 字段或 value 为空", i)
}
if strings.Contains(val, ",") {
return fmt.Errorf("--options[%d].value 不能包含英文逗号: %q", i, val)
}
if !hasSourceRange && sourceSheetID != "" {
return fmt.Errorf("使用 --source-sheet-id 时必须同时指定 --source-range")
}
toolArgs := map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
"sheetId": mustGetFlag(cmd, "sheet-id"),
"range": mustGetFlag(cmd, "range"),
"options": options,
}
if hasOptions {
var options []map[string]any
if err := json.Unmarshal([]byte(optionsStr), &options); err != nil {
return fmt.Errorf("--options JSON 解析失败: %w", err)
}
if len(options) == 0 {
return fmt.Errorf("--options 至少包含 1 个选项")
}
for i, opt := range options {
val, ok := opt["value"].(string)
if !ok || val == "" {
return fmt.Errorf("--options[%d] 缺少必填的 value 字段或 value 为空", i)
}
if strings.Contains(val, ",") {
return fmt.Errorf("--options[%d].value 不能包含英文逗号: %q", i, val)
}
}
toolArgs["options"] = options
} else {
if err := validateDropdownSourceRangeInput(sourceSheetID, sourceRange); err != nil {
return err
}
toolArgs["sourceRange"] = map[string]any{
"sheetId": sourceSheetID,
"a1Notation": sourceRange,
}
}
if multiSelect, _ := cmd.Flags().GetBool("multi-select"); multiSelect {
toolArgs["enableMultiSelect"] = true
@@ -839,36 +897,49 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
CLIPath: "sheet set-dropdown",
PrimaryCLIPath: "sheet set-dropdown",
},
Description: "为指定范围设置下拉列表(可多选、可带颜色)。",
Description: "为指定范围设置 Inline 或 SourceRange 下拉列表(可多选;颜色仅 Inline 支持)。",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "set_dropdown_lists"},
},
Selection: contract.SelectionSpec{
AgentSummary: "为指定范围设置下拉列表(可多选、可带颜色)。",
AgentSummary: "为指定范围设置 Inline 或 SourceRange 下拉列表;SourceRange 可跨同一工作簿内的工作表。",
UseWhen: []string{"需要给单元格配置可选值下拉约束时"},
AvoidWhen: []string{"查看已有下拉用 get-dropdown;移除下拉用 delete-dropdown"},
Examples: []string{"dws sheet set-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range \"A2:A100\" --options '[{\"value\":\"选项1\"},{\"value\":\"选项2\"}]'"},
Examples: []string{
"dws sheet set-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range \"A2:A100\" --options '[{\"value\":\"选项1\"},{\"value\":\"选项2\"}]'",
"dws sheet set-dropdown --node <NODE_ID> --sheet-id <TARGET_SHEET_ID> --range \"B2:B100\" --source-sheet-id <SOURCE_SHEET_ID> --source-range \"T1:T3\"",
},
},
Parameters: []contract.ParamDecl{
{Name: "multi-select", Property: "enableMultiSelect"},
{Name: "node", Property: "nodeId"},
{Name: "source-sheet-id", Property: "sourceRange.sheetId", Required: boolPtr(false), RequiredWhen: "--source-range is provided"},
{Name: "source-range", Property: "sourceRange.a1Notation", Required: boolPtr(false), RequiredWhen: "exactly one of --options or --source-range must be provided"},
},
},
})
setDropdownCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
setDropdownCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
setDropdownCmd.Flags().String("range", "", "目标单元格范围,A1 表示法,如 A2:A100 (必填)")
setDropdownCmd.Flags().String("options", "", `下拉选项 JSON 数组 (必填),如 '[{"value":"选项1","color":"#ff0000"}]'`)
setDropdownCmd.Flags().String("options", "", `Inline 下拉选项 JSON 数组,与 --source-range 二选一,如 '[{"value":"选项1","color":"#ff0000"}]'`)
setDropdownCmd.Flags().String("source-sheet-id", "", "SourceRange 来源工作表 ID;使用 --source-range 时必填")
setDropdownCmd.Flags().String("source-range", "", "SourceRange 来源区域,与 --options 二选一;不含工作表前缀,如 T1:T3、T:T 或 1:3")
setDropdownCmd.Flags().Bool("multi-select", false, "是否允许多选(默认单选)")
cli.AnnotateRuntimeFlagFormat(setDropdownCmd, "source-range", "a1-range")
setDropdownCmd.MarkFlagsOneRequired("options", "source-range")
setDropdownCmd.MarkFlagsMutuallyExclusive("options", "source-range")
setDropdownCmd.MarkFlagsRequiredTogether("source-sheet-id", "source-range")
getDropdownCmd := &cobra.Command{
Use: "get-dropdown",
Short: "获取下拉列表配置",
Long: `查询钉钉表格指定范围内的下拉列表配置。
返回范围内所有单元格的下拉列表配置信息,包括选项值和颜色。
Inline 配置返回 conditionValues 和 options;SourceRange 配置返回
sourceType/sourceRangeStatus/enableMultiSelect,且仅在 sourceRangeStatus="valid" 时返回
sourceRange。invalid 结果仍保留配置组,但省略 sourceRange;两种状态都不展开来源区域的候选值或颜色。
如果范围内存在多个不同的下拉列表配置,会分别返回每组配置及其覆盖的单元格列表。
如果范围内没有设置下拉列表,返回空。
@@ -0,0 +1,248 @@
package helpers
import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
)
func TestCrossPlatformCoverageSetDropdownSourceRangeCommand(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true}`}}}
installScriptedCaller(t, caller)
err := executeDimensionCoverage(t, "set-dropdown",
"--node", "node-1",
"--sheet-id", "target-sheet",
"--range", "B2:B100",
"--source-sheet-id", "source-sheet",
"--source-range", "$t$1:$t$3",
"--multi-select",
)
if err != nil {
t.Fatalf("set SourceRange dropdown: %v", err)
}
if caller.tool != "set_dropdown_lists" {
t.Fatalf("tool = %q, want set_dropdown_lists", caller.tool)
}
if _, exists := caller.args["options"]; exists {
t.Fatalf("SourceRange request must omit options: %#v", caller.args)
}
source, ok := caller.args["sourceRange"].(map[string]any)
if !ok {
t.Fatalf("sourceRange = %#v", caller.args["sourceRange"])
}
if source["sheetId"] != "source-sheet" || source["a1Notation"] != "$t$1:$t$3" {
t.Fatalf("sourceRange = %#v", source)
}
if caller.args["enableMultiSelect"] != true {
t.Fatalf("enableMultiSelect = %#v", caller.args["enableMultiSelect"])
}
}
func TestCrossPlatformCoverageSetDropdownInlineCommandStillOmitsSourceRange(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true}`}}}
installScriptedCaller(t, caller)
err := executeDimensionCoverage(t, "set-dropdown",
"--node", "node-1",
"--sheet-id", "sheet-1",
"--range", "A1:A3",
"--options", `[{"value":"one","color":"#ff0000"}]`,
)
if err != nil {
t.Fatalf("set inline dropdown: %v", err)
}
if _, exists := caller.args["sourceRange"]; exists {
t.Fatalf("inline request must omit sourceRange: %#v", caller.args)
}
options, ok := caller.args["options"].([]map[string]any)
if !ok || len(options) != 1 || options[0]["value"] != "one" {
t.Fatalf("options = %#v", caller.args["options"])
}
}
func TestCrossPlatformCoverageSetDropdownModeConstraints(t *testing.T) {
base := []string{"--node", "node", "--sheet-id", "sheet", "--range", "A1"}
tests := []struct {
name string
args []string
want string
}{
{name: "neither", want: "one of the flags"},
{name: "both", args: []string{"--options", `[{"value":"one"}]`, "--source-sheet-id", "source", "--source-range", "A1:A3"}, want: "none of the others"},
{name: "source sheet without range", args: []string{"--options", `[{"value":"one"}]`, "--source-sheet-id", "source"}, want: "must all be set"},
{name: "range without source sheet", args: []string{"--source-range", "A1:A3"}, want: "must all be set"},
{name: "sheet prefix", args: []string{"--source-sheet-id", "source", "--source-range", "Sheet2!A1:A3"}, want: "不能包含工作表前缀"},
{name: "formula", args: []string{"--source-sheet-id", "source", "--source-range", "=A1:A3"}, want: "必须是单一连续区域"},
{name: "multi region", args: []string{"--source-sheet-id", "source", "--source-range", "A1:A3,C1:C3"}, want: "必须是单一连续区域"},
{name: "blank source range", args: []string{"--source-sheet-id", "source", "--source-range", " \t "}, want: "--source-range 不能为空"},
{name: "blank source sheet", args: []string{"--source-sheet-id", " \t ", "--source-range", "A1:A3"}, want: "--source-sheet-id 不能为空"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
installScriptedCaller(t, caller)
args := append(append([]string{}, base...), tc.args...)
err := executeDimensionCoverage(t, "set-dropdown", args...)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want contains %q", err, tc.want)
}
if caller.calls != 0 {
t.Fatalf("calls = %d, validation must fail before request", caller.calls)
}
})
}
}
func TestCrossPlatformCoverageSetDropdownRunEValidation(t *testing.T) {
tests := []struct {
name string
options string
source string
rangeID string
want string
}{
{name: "neither mode", want: "必须且只能指定一个"},
{name: "source range without sheet", rangeID: "A1:A3", want: "必须同时指定 --source-sheet-id"},
{name: "source sheet without range", options: `[{"value":"one"}]`, source: "source", want: "必须同时指定 --source-range"},
{name: "invalid options JSON", options: "{", want: "JSON 解析失败"},
{name: "missing option value", options: `[{}]`, want: "value 为空"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cmd := dimensionCoverageCommand(t, "set-dropdown")
for name, value := range map[string]string{
"node": "node", "sheet-id": "sheet", "range": "A1",
"options": tc.options, "source-sheet-id": tc.source, "source-range": tc.rangeID,
} {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
err := cmd.RunE(cmd, nil)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want contains %q", err, tc.want)
}
})
}
}
func TestCrossPlatformCoverageSetDropdownPreservesSchemaConstraints(t *testing.T) {
cmd := dimensionCoverageCommand(t, "set-dropdown")
if constraints := runtimeannotate.CommandConstraints(cmd); !runtimeannotate.ConstraintsEmpty(constraints) {
t.Fatalf("constraints = %#v, want no new schema-level constraints", constraints)
}
}
func TestCrossPlatformCoverageBatchSetDropdownSourceRange(t *testing.T) {
got, err := translateBatchOp(map[string]any{
"toolName": "set-dropdown",
"input": map[string]any{
"sheet-id": "target-sheet",
"range": "B2:B100",
"source-sheet-id": "source-sheet",
"source-range": "T:T",
"multi-select": true,
},
})
if err != nil {
t.Fatalf("translate SourceRange batch op: %v", err)
}
input := got["input"].(map[string]any)
if _, exists := input["options"]; exists {
t.Fatalf("SourceRange batch input must omit options: %#v", input)
}
source := input["sourceRange"].(map[string]any)
if source["sheetId"] != "source-sheet" || source["a1Notation"] != "T:T" {
t.Fatalf("sourceRange = %#v", source)
}
invalid := []map[string]any{
{},
{"options": []any{map[string]any{"value": "one"}}, "source-sheet-id": "source", "source-range": "A1:A3"},
{"source-range": "A1:A3"},
{"source-sheet-id": "source", "source-range": "A1:A3", "colors": []any{"#fff"}},
{"source-sheet-id": "source", "source-range": "A1:A3", "source-colors": []any{"#fff"}},
{"source-sheet-id": "source", "source-range": "Sheet2!A1:A3"},
}
for _, value := range invalid {
if _, err := translateBatchOp(map[string]any{"toolName": "set-dropdown", "input": value}); err == nil {
t.Errorf("invalid batch SourceRange input %#v returned nil", value)
}
}
}
func TestCrossPlatformCoverageBatchSetDropdownValidationGuidance(t *testing.T) {
tests := []struct {
name string
input map[string]any
want string
}{
{
name: "inline top-level colors",
input: map[string]any{"options": []any{map[string]any{"value": "one"}}, "colors": []any{"#fff"}},
want: "Inline 颜色请写入 options[].color",
},
{
name: "source top-level colors",
input: map[string]any{"source-sheet-id": "source", "source-range": "A1:A3", "source-colors": []any{"#fff"}},
want: "SourceRange 颜色写入暂不支持",
},
{
name: "blank source range",
input: map[string]any{"source-sheet-id": "source", "source-range": " \t "},
want: "--source-range 不能为空",
},
{
name: "blank source sheet",
input: map[string]any{"source-sheet-id": " \t ", "source-range": "A1:A3"},
want: "--source-sheet-id 不能为空",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := translateBatchOp(map[string]any{"toolName": "set-dropdown", "input": tc.input})
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %v, want contains %q", err, tc.want)
}
})
}
}
func TestCrossPlatformCoverageDropdownHelpDescribesDynamicAndInvalidContracts(t *testing.T) {
setDropdown := dimensionCoverageCommand(t, "set-dropdown")
if !strings.Contains(setDropdown.Long, "会自动调整引用并保持 valid") ||
!strings.Contains(setDropdown.Long, "只有 invalid 时才重新选择来源并写入") {
t.Fatalf("set-dropdown help does not describe verified structural behavior:\n%s", setDropdown.Long)
}
getDropdown := dimensionCoverageCommand(t, "get-dropdown")
if !strings.Contains(getDropdown.Long, `仅在 sourceRangeStatus="valid" 时返回`) ||
!strings.Contains(getDropdown.Long, "invalid 结果仍保留配置组,但省略 sourceRange") {
t.Fatalf("get-dropdown help does not describe conditional sourceRange readback:\n%s", getDropdown.Long)
}
}
func TestCrossPlatformCoverageValidateSourceRangeDataValidation(t *testing.T) {
valid := []any{
map[string]any{"type": "dropdown", "sourceRange": map[string]any{"sheetId": "source", "a1Notation": "T1:T3"}},
map[string]any{"type": "dropdown", "sourceRange": map[string]any{"sheetId": "missing-sheet", "a1Notation": "not-validated-locally"}, "enableMultiSelect": true},
}
for _, value := range valid {
if err := validateDataValidation(value, "dv"); err != nil {
t.Errorf("valid SourceRange data validation %#v: %v", value, err)
}
}
invalid := []any{
map[string]any{"type": "dropdown", "options": []any{map[string]any{"value": "one"}}, "sourceRange": map[string]any{"sheetId": "source", "a1Notation": "T1:T3"}},
map[string]any{"type": "dropdown", "sourceRange": "T1:T3"},
map[string]any{"type": "dropdown", "sourceRange": map[string]any{"a1Notation": "T1:T3"}},
map[string]any{"type": "dropdown", "sourceRange": map[string]any{"sheetId": "source"}},
map[string]any{"type": "dropdown", "sourceRange": map[string]any{"sheetId": "source", "a1Notation": "T1:T3", "colors": []any{"#fff"}}},
map[string]any{"type": "dropdown", "sourceRange": map[string]any{"sheetId": "source", "a1Notation": "T1:T3"}, "enableMultiSelect": "yes"},
}
for _, value := range invalid {
if err := validateDataValidation(value, "dv"); err == nil {
t.Errorf("invalid SourceRange data validation %#v returned nil", value)
}
}
}
+18 -1
View File
@@ -26,6 +26,16 @@ func newRangeCmd() *cobra.Command {
value 单元格值(内容由 --value-render-option 决定)
dataValidation 数据验证配置(下拉列表/复选框),无则为 null
hyperlink 单元格级超链接(path/sheet/range),无则省略
顶层还会返回 rowIndices / colIndices 与完成度字段:
hasMore 目标范围是否还有未返回数据
truncationReasons 部分返回原因(max_cells)
resolvedRange 未传 --range 时底层解析出的完整目标范围
returnedRange 本次实际完整返回的范围
单次最多返回 30,000 个单元格。hasMore=true 是部分成功,本命令
不会自动续读;请结合目标范围和 returnedRange 从下一行显式传
--range。收到 forbidden.document.sizeOverLimit 则表示工作簿整体无法装载,
应创建更小副本或拆分工作簿,缩小 --range 不能解决。
注意:range read/get 不返回合并单元格结构。查看合并范围请使用
dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 mergedRanges。
@@ -157,7 +167,12 @@ dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 merg
2) {"dataValidation":{"type":"none"}} → 显式清除该单元格 DV
3) {"dataValidation":{"type":"dropdown",...}} → 写新 dropdown(覆盖)
{"dataValidation":{"type":"checkbox",...}} → 写新 checkbox(覆盖)
dropdown: {"dataValidation":{"type":"dropdown","options":[{"value":"选项1"}],"enableMultiSelect":false}}
Inline dropdown:
{"dataValidation":{"type":"dropdown","options":[{"value":"选项1"}],"enableMultiSelect":false}}
SourceRange dropdown(同一工作簿内可跨工作表;不展开来源 values,不支持 colors):
{"dataValidation":{"type":"dropdown","sourceRange":{"sheetId":"SOURCE_SHEET_ID","a1Notation":"T1:T3"},"enableMultiSelect":false}}
dropdown 的 options 与 sourceRange 必须且只能传一个。SourceRange 支持普通区域、整行和整列;
a1Notation 不带工作表前缀,来源工作表单独写在 sheetId。
checkbox: {"dataValidation":{"type":"checkbox","checked":true}}
可与 text/richText 共存,也可单独使用(如 {dataValidation:{type:"none"}} 仅清除 DV 不写值)
@@ -169,6 +184,8 @@ dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 merg
- 只设样式或批量刷整片区域样式请用 dws sheet range set-style;写值同时设置少量 cell 样式可用 cellStyles
- 目标范围与已有合并区域冲突时,range update 会返回 MERGED_CELLS_CONFLICT;先用 sheet info 查看 mergedRanges,取消合并后写入,必要时再重新合并
- csv-put 的合并处理不同:目标区域含合并单元格时会打散合并并写入 CSV 值或公式
- SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;move-dimension 及其他未覆盖的删除/移动场景后先回读 sourceRangeStatus,仅 invalid 时重新选源写入
- 同一 cell 的 value/style 已写入但 SourceRange 校验失败时,服务端可返回 success=true,并通过 message 明确下拉未创建;必须检查 message,必要时重新读取确认
- 清空整片区域请用 dws sheet range clear`,
Example: ` # 写入文本
dws sheet range update --node NODE_ID --sheet-id SHEET_ID --range "A1:B2" \
@@ -0,0 +1,202 @@
package helpers
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
)
func executeSheetReadCompletionCommand(t *testing.T, caller *scriptedToolCaller, args ...string) (*bytes.Buffer, error) {
t.Helper()
installScriptedCaller(t, caller)
installSheetProductArgs(t)
stdout := &bytes.Buffer{}
deps.Out.w = stdout
cmd := newSheetCommand()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs(args)
return stdout, cmd.Execute()
}
func decodeSheetReadOutput(t *testing.T, stdout *bytes.Buffer) map[string]any {
t.Helper()
var got map[string]any
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("decode sheet read output %q: %v", stdout.String(), err)
}
return got
}
func assertSheetReadCompletionMetadata(t *testing.T, got map[string]any) {
t.Helper()
if hasMore, ok := got["hasMore"].(bool); !ok || !hasMore {
t.Fatalf("hasMore = %#v, want true", got["hasMore"])
}
if got["resolvedRange"] != "A1:A30001" {
t.Fatalf("resolvedRange = %#v", got["resolvedRange"])
}
if got["returnedRange"] != "A1:A30000" {
t.Fatalf("returnedRange = %#v", got["returnedRange"])
}
reasons, ok := got["truncationReasons"].([]any)
if !ok || len(reasons) == 0 || reasons[0] != "max_cells" {
t.Fatalf("truncationReasons = %#v", got["truncationReasons"])
}
}
// csv-get intentionally projects the MCP response rather than inventing a
// client-side page model. Keep all completion metadata visible and make one
// request only, even when the server reports a partial success.
func TestSheetCSVGetPreservesCompletionMetadataWithoutAutoPagination(t *testing.T) {
const response = `{"csv":"[row=1]1\n","colIndices":["A"],"rowIndices":[1],"hasMore":true,"truncationReasons":["max_cells","max_chars"],"resolvedRange":"A1:A30001","returnedRange":"A1:A30000","message":"partial"}`
caller := &scriptedToolCaller{
format: "raw",
steps: []scriptedToolStep{{text: response}},
}
stdout, err := executeSheetReadCompletionCommand(t, caller,
"csv-get", "--node", "NODE")
if err != nil {
t.Fatalf("csv-get: %v", err)
}
if caller.calls != 1 || caller.tool != "get_range_as_csv" {
t.Fatalf("calls/tool = %d/%q, want one get_range_as_csv call", caller.calls, caller.tool)
}
if _, exists := caller.args["range"]; exists {
t.Fatalf("omitted range was unexpectedly synthesized by the CLI: %#v", caller.args["range"])
}
if got := strings.TrimSpace(stdout.String()); got != response {
t.Fatalf("raw csv-get response changed:\n got: %s\nwant: %s", got, response)
}
got := decodeSheetReadOutput(t, stdout)
assertSheetReadCompletionMetadata(t, got)
reasons := got["truncationReasons"].([]any)
if len(reasons) != 2 || reasons[1] != "max_chars" {
t.Fatalf("truncationReasons = %#v, want max_cells and max_chars", reasons)
}
}
// range read parses the get_cell_infos envelope to remove empty per-cell
// schema shells. That cleanup must not discard new top-level completion fields.
func TestSheetRangeReadPreservesCompletionMetadataWithoutAutoPagination(t *testing.T) {
const response = `{"cells":[[{"value":"1","dataValidation":{"type":null},"hyperlink":{"url":null}}]],"colIndices":["A"],"rowIndices":[1],"hasMore":true,"truncationReasons":["max_cells"],"resolvedRange":"A1:A30001","returnedRange":"A1:A30000","message":"partial"}`
caller := &scriptedToolCaller{
format: "json",
steps: []scriptedToolStep{{text: response}},
}
stdout, err := executeSheetReadCompletionCommand(t, caller,
"range", "read", "--node", "NODE")
if err != nil {
t.Fatalf("range read: %v", err)
}
if caller.calls != 1 || caller.tool != "get_cell_infos" {
t.Fatalf("calls/tool = %d/%q, want one get_cell_infos call", caller.calls, caller.tool)
}
if _, exists := caller.args["range"]; exists {
t.Fatalf("omitted range was unexpectedly synthesized by the CLI: %#v", caller.args["range"])
}
got := decodeSheetReadOutput(t, stdout)
assertSheetReadCompletionMetadata(t, got)
cells := got["cells"].([]any)
row := cells[0].([]any)
cell := row[0].(map[string]any)
if _, exists := cell["dataValidation"]; exists {
t.Fatalf("empty dataValidation shell was not removed: %#v", cell)
}
if _, exists := cell["hyperlink"]; exists {
t.Fatalf("empty hyperlink shell was not removed: %#v", cell)
}
}
// sheet +read calls get_cell_infos through this direct passthrough instead of
// callMCPToolCellInfos. Lock that third read surface to the same raw envelope
// contract and to one request only.
func TestSheetCellInfosDirectPassthroughPreservesCompletionMetadata(t *testing.T) {
const response = `{"cells":[[{"value":"1"}]],"colIndices":["A"],"rowIndices":[1],"hasMore":true,"truncationReasons":["max_cells"],"resolvedRange":"A1:A30001","returnedRange":"A1:A30000","message":"partial"}`
caller := &scriptedToolCaller{
format: "raw",
steps: []scriptedToolStep{{text: response}},
}
installScriptedCaller(t, caller)
stdout := &bytes.Buffer{}
deps.Out.w = stdout
if err := CallMCPToolOnServer("sheet", "get_cell_infos", map[string]any{"nodeId": "NODE"}); err != nil {
t.Fatalf("direct get_cell_infos: %v", err)
}
if caller.calls != 1 || caller.server != "sheet" || caller.tool != "get_cell_infos" {
t.Fatalf("calls/server/tool = %d/%q/%q", caller.calls, caller.server, caller.tool)
}
if got := strings.TrimSpace(stdout.String()); got != response {
t.Fatalf("raw get_cell_infos response changed:\n got: %s\nwant: %s", got, response)
}
assertSheetReadCompletionMetadata(t, decodeSheetReadOutput(t, stdout))
}
// Large-CP failure is not a pageable success. Both the raw csv-get path and
// both get_cell_infos paths must retain the backend code and safe user message
// exactly, matching the generic get_all_sheets error-classification path.
func TestSheetReadPreservesWorkbookSizeOverLimitError(t *testing.T) {
const response = `{"success":false,"errorCode":"forbidden.document.sizeOverLimit","errorMessage":"The workbook data is too large to process. Use a smaller copy or split the workbook, then try again."}`
tests := []struct {
name string
args []string
tool string
direct bool
}{
{name: "csv-get", args: []string{"csv-get", "--node", "NODE"}, tool: "get_range_as_csv"},
{name: "range-read", args: []string{"range", "read", "--node", "NODE"}, tool: "get_cell_infos"},
{name: "cell-infos-direct", tool: "get_cell_infos", direct: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &scriptedToolCaller{
format: "json",
steps: []scriptedToolStep{{text: response}},
}
var stdout *bytes.Buffer
var err error
if test.direct {
installScriptedCaller(t, caller)
stdout = &bytes.Buffer{}
deps.Out.w = stdout
err = CallMCPToolOnServer("sheet", test.tool, map[string]any{"nodeId": "NODE"})
} else {
stdout, err = executeSheetReadCompletionCommand(t, caller, test.args...)
}
if err == nil {
t.Fatal("large-CP response was accepted as success")
}
var cliErr *CLIError
if !errors.As(err, &cliErr) || cliErr.Code != CodeMCPToolError {
t.Fatalf("error = %T %v, want MCP tool error", err, err)
}
if cliErr.Message != response {
t.Fatalf("backend error payload changed:\n got: %s\nwant: %s", cliErr.Message, response)
}
for _, want := range []string{
"forbidden.document.sizeOverLimit",
"The workbook data is too large to process.",
} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not preserve %q", err, want)
}
}
if stdout.Len() != 0 {
t.Fatalf("failure wrote a success body: %q", stdout.String())
}
if caller.calls != 1 || caller.tool != test.tool {
t.Fatalf("calls/tool = %d/%q, want one %s call", caller.calls, caller.tool, test.tool)
}
})
}
}
+40 -14
View File
@@ -239,22 +239,48 @@ func validateDataValidation(dvRaw any, path string) error {
}
switch dvType {
case "dropdown":
optionsRaw, ok := dv["options"]
if !ok {
return fmt.Errorf("%s: type=dropdown 必须包含 options 数组", path)
optionsRaw, hasOptions := dv["options"]
sourceRangeRaw, hasSourceRange := dv["sourceRange"]
hasOptions = hasOptions && optionsRaw != nil
hasSourceRange = hasSourceRange && sourceRangeRaw != nil
if hasOptions == hasSourceRange {
return fmt.Errorf("%s: type=dropdown 的 options 与 sourceRange 必须且只能包含一个", path)
}
options, ok := optionsRaw.([]any)
if !ok || len(options) == 0 {
return fmt.Errorf("%s.options 必须为非空数组", path)
}
for i, opt := range options {
optMap, ok := opt.(map[string]any)
if !ok {
return fmt.Errorf("%s.options[%d] 必须为 object(如 {\"value\":\"选项\"})", path, i)
if hasOptions {
options, ok := optionsRaw.([]any)
if !ok || len(options) == 0 {
return fmt.Errorf("%s.options 必须为非空数组", path)
}
val, ok := optMap["value"].(string)
if !ok || val == "" {
return fmt.Errorf("%s.options[%d].value 必须为非空字符串", path, i)
for i, opt := range options {
optMap, ok := opt.(map[string]any)
if !ok {
return fmt.Errorf("%s.options[%d] 必须为 object(如 {\"value\":\"选项\"})", path, i)
}
val, ok := optMap["value"].(string)
if !ok || val == "" {
return fmt.Errorf("%s.options[%d].value 必须为非空字符串", path, i)
}
}
} else {
sourceRange, ok := sourceRangeRaw.(map[string]any)
if !ok {
return fmt.Errorf("%s.sourceRange 必须为 object", path)
}
if _, exists := sourceRange["colors"]; exists {
return fmt.Errorf("%s.sourceRange.colors 暂不支持", path)
}
sheetID, ok := sourceRange["sheetId"].(string)
if !ok || strings.TrimSpace(sheetID) == "" {
return fmt.Errorf("%s.sourceRange.sheetId 必须为非空字符串", path)
}
a1Notation, ok := sourceRange["a1Notation"].(string)
if !ok || strings.TrimSpace(a1Notation) == "" {
return fmt.Errorf("%s.sourceRange.a1Notation 必须为非空字符串", path)
}
}
if multi, exists := dv["enableMultiSelect"]; exists {
if _, ok := multi.(bool); !ok {
return fmt.Errorf("%s.enableMultiSelect 必须为 boolean", path)
}
}
case "checkbox":
+1 -1
View File
@@ -733,7 +733,7 @@ var RecordQuery = shortcut.Shortcut{
if rt.Changed("cursor") {
params["cursor"] = rt.Str("cursor")
}
return rt.CallMCP("query_records", params)
return executeRecordQuery(rt, params)
},
}
+24 -11
View File
@@ -98,7 +98,7 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error {
step.Status = "unknown"
step.Error = writeErr.Error()
}
remaining, verifyErr := queryRecordsByIDs(rt, baseID, tableID, batch)
remaining, verifyErr := queryDeletedRecordsByIDs(rt, baseID, tableID, batch)
if verifyErr == nil && len(remaining) > 0 {
verifyErr = fmt.Errorf("read-back still contains deleted record IDs: %s", strings.Join(recordIDs(remaining), ","))
}
@@ -303,20 +303,33 @@ func verifyUpsertBatch(rt *shortcut.RuntimeContext, baseID, tableID string, batc
}
func queryRecordsByIDs(rt *shortcut.RuntimeContext, baseID, tableID string, ids []string) ([]map[string]any, error) {
data, err := rt.CallMCPData(serverMain, "query_records", map[string]any{
"baseId": baseID, "tableId": tableID, "recordIds": ids, "limit": len(ids),
})
window, err := queryRecordWindow(rt, map[string]any{
"baseId": baseID, "tableId": tableID, "recordIds": ids,
}, len(ids))
if err != nil {
return nil, err
}
records, found := findRecords(data)
if !found {
return nil, fmt.Errorf("query_records read-back is missing records")
// Exact-ID verification below compares every requested ID with the returned
// records. The service can publish a continuation even after all requested
// IDs are present, so hasMore is not evidence that this bounded read-back is
// incomplete.
return window.Records, nil
}
func queryDeletedRecordsByIDs(rt *shortcut.RuntimeContext, baseID, tableID string, ids []string) ([]map[string]any, error) {
remaining := make([]map[string]any, 0)
for offset := 0; offset < len(ids); offset += recordQueryServicePageSize {
end := minInt(offset+recordQueryServicePageSize, len(ids))
chunk := ids[offset:end]
window, err := queryRecordWindow(rt, map[string]any{
"baseId": baseID, "tableId": tableID, "recordIds": chunk,
}, len(chunk))
if err != nil {
return nil, err
}
remaining = append(remaining, window.Records...)
}
if responseHasMore(data) {
return nil, fmt.Errorf("query_records read-back is incomplete")
}
return records, nil
return remaining, nil
}
func recordIDs(records []map[string]any) []string {
+233 -1
View File
@@ -10,13 +10,245 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
// query_records is backed by a service pager whose stable typed-envelope
// boundary is 20 records. Asking that layer to aggregate more than one page can
// drop the envelope fields used by the record projector, turning a successful
// read into an empty collection. Keep every remote request on one service page
// and aggregate only after each page has passed the CLI's shape validation.
const (
recordQueryServicePageSize = 20
recordQueryMaxConsecutiveEmptyPages = 3
)
type recordQueryWindow struct {
Records []map[string]any
HasMore bool
NextCursor string
Pages int
TotalCount any
HasTotalCount bool
}
func queryRecordWindow(rt *shortcut.RuntimeContext, params map[string]any, limit int) (recordQueryWindow, error) {
if limit <= 0 {
return recordQueryWindow{}, fmt.Errorf("record query limit must be positive, got %d", limit)
}
request := cloneAnyMap(params)
cursor, _ := request["cursor"].(string)
cursor = strings.TrimSpace(cursor)
seen := map[string]bool{}
if cursor != "" {
seen[cursor] = true
}
window := recordQueryWindow{Records: make([]map[string]any, 0, limit)}
consecutiveEmptyPages := 0
for len(window.Records) < limit {
pageSize := minInt(recordQueryServicePageSize, limit-len(window.Records))
request["limit"] = pageSize
if cursor == "" {
delete(request, "cursor")
} else {
request["cursor"] = cursor
}
data, err := rt.CallMCPData(serverMain, "query_records", request)
if err != nil {
return recordQueryWindow{}, err
}
if !window.HasTotalCount {
window.TotalCount, window.HasTotalCount = responseTotalCount(data)
}
records, found := findRecords(data)
if !found {
if explicitEmptyRecordQuery(data) {
window.Pages++
window.HasMore = false
window.NextCursor = ""
return window, nil
}
return recordQueryWindow{}, fmt.Errorf("query_records page %d is missing the records collection", window.Pages+1)
}
window.Records = append(window.Records, records...)
window.Pages++
if !responseHasMore(data) {
window.HasMore = false
window.NextCursor = ""
return window, nil
}
if len(records) == 0 {
consecutiveEmptyPages++
if consecutiveEmptyPages >= recordQueryMaxConsecutiveEmptyPages {
return recordQueryWindow{}, fmt.Errorf(
"query_records made no progress for %d consecutive pages",
consecutiveEmptyPages,
)
}
} else {
consecutiveEmptyPages = 0
}
next := responseCursor(data)
if next == "" {
return recordQueryWindow{}, fmt.Errorf("query_records page %d reports more data but no next cursor", window.Pages)
}
if seen[next] {
return recordQueryWindow{}, fmt.Errorf("query_records cursor cycle detected at %q", next)
}
seen[next] = true
cursor = next
window.HasMore = true
window.NextCursor = next
}
return window, nil
}
// explicitEmptyRecordQuery recognizes the service's reviewed zero-match wire
// shape. It is deliberately stricter than "records is absent": only a complete
// success envelope with an empty data object is accepted as an empty set.
func explicitEmptyRecordQuery(data map[string]any) bool {
if data == nil || data["success"] != true || data["status"] != "success" {
return false
}
payload, ok := data["data"].(map[string]any)
if !ok || len(payload) != 0 {
return false
}
if rawError, exists := data["error"]; exists && rawError != nil {
errorObject, ok := rawError.(map[string]any)
if !ok || len(errorObject) != 0 {
return false
}
}
return true
}
func executeRecordQuery(rt *shortcut.RuntimeContext, params map[string]any) error {
limit := 100
if rt.Changed("limit") {
limit = rt.Int("limit")
}
if limit < 1 || limit > recordBatchSize {
return fmt.Errorf("--limit must be in [1,%d], got %d", recordBatchSize, limit)
}
requestedIDs, exactIDQuery, err := recordQueryRequestedIDs(params)
if err != nil {
return err
}
if exactIDQuery {
if len(requestedIDs) > recordBatchSize {
return fmt.Errorf("--record-ids accepts at most %d unique IDs, got %d", recordBatchSize, len(requestedIDs))
}
params = cloneAnyMap(params)
params["recordIds"] = requestedIDs
limit = minInt(limit, len(requestedIDs))
}
if rt.DryRun() {
return rt.Output(map[string]any{
"dry_run": true,
"executed": false,
"tool": "query_records",
"arguments": params,
})
}
window, err := queryRecordWindow(rt, params, limit)
if err != nil {
return err
}
if exactIDQuery {
complete, err := validateExactRecordQuery(window.Records, requestedIDs)
if err != nil {
return err
}
if complete {
// The service may advertise unrelated continuation after every
// requested ID is already present. Exact-ID completion is stronger
// evidence than that residual pager state.
window.HasMore = false
window.NextCursor = ""
}
}
records := make([]any, 0, len(window.Records))
for _, record := range window.Records {
records = append(records, record)
}
data := map[string]any{
"records": records,
"hasMore": window.HasMore,
"page": window.Pages,
"size": len(window.Records),
}
if window.NextCursor != "" {
data["nextCursor"] = window.NextCursor
}
if window.HasTotalCount {
data["totalCount"] = window.TotalCount
}
return rt.Output(map[string]any{
"success": true,
"status": "success",
"data": data,
})
}
func responseTotalCount(data map[string]any) (any, bool) {
for {
if totalCount, exists := data["totalCount"]; exists {
return totalCount, true
}
nested, ok := data["data"].(map[string]any)
if !ok {
return nil, false
}
data = nested
}
}
func recordQueryRequestedIDs(params map[string]any) ([]string, bool, error) {
raw, exists := params["recordIds"]
if !exists {
return nil, false, nil
}
values, ok := raw.([]string)
if !ok {
return nil, false, fmt.Errorf("recordIds must be a string list, got %T", raw)
}
ids, err := parseRecordIDs(values)
if err != nil {
return nil, false, err
}
return ids, true, nil
}
func validateExactRecordQuery(records []map[string]any, requestedIDs []string) (bool, error) {
wanted := make(map[string]bool, len(requestedIDs))
for _, id := range requestedIDs {
wanted[id] = true
}
seen := make(map[string]bool, len(records))
for index, record := range records {
id := recordID(record)
if id == "" {
return false, fmt.Errorf("query_records exact-ID result %d is missing recordId", index)
}
if !wanted[id] {
return false, fmt.Errorf("query_records exact-ID result contains unexpected recordId %q", id)
}
if seen[id] {
return false, fmt.Errorf("query_records exact-ID result contains duplicate recordId %q", id)
}
seen[id] = true
}
return len(seen) == len(wanted), nil
}
func queryAllRecords(rt *shortcut.RuntimeContext, params map[string]any, maxRecords int) ([]map[string]any, error) {
all := make([]map[string]any, 0)
cursor := ""
seen := map[string]bool{}
for page := 0; ; page++ {
request := cloneAnyMap(params)
request["limit"] = recordBatchSize
request["limit"] = recordQueryServicePageSize
if cursor != "" {
request["cursor"] = cursor
}
@@ -9,6 +9,7 @@ import (
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"testing"
@@ -265,6 +266,446 @@ func recordListJSON(t *testing.T, records []map[string]any) string {
return string(raw)
}
func pagedRecordQueryResponse(t *testing.T, records []map[string]any, args map[string]any) string {
t.Helper()
limit, ok := args["limit"].(int)
if !ok || limit < 1 || limit > recordQueryServicePageSize {
t.Fatalf("query_records limit = %#v, want 1..%d", args["limit"], recordQueryServicePageSize)
}
offset := 0
if cursor, _ := args["cursor"].(string); cursor != "" {
if _, err := fmt.Sscanf(cursor, "offset-%d", &offset); err != nil {
t.Fatalf("invalid test cursor %q: %v", cursor, err)
}
}
end := minInt(offset+limit, len(records))
pageRecords := records[offset:end]
data := map[string]any{"records": pageRecords, "hasMore": end < len(records), "totalCount": len(records)}
if end < len(records) {
data["nextCursor"] = fmt.Sprintf("offset-%d", end)
}
raw, err := json.Marshal(map[string]any{
"success": true,
"hasMore": end < len(records),
"page": offset/recordQueryServicePageSize + 1,
"size": limit,
"data": data,
})
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func runRecordQueryShortcutCLI(t *testing.T, caller *upsertByKeyCaller, limit int, extra ...string) (map[string]any, error) {
t.Helper()
helpers.InitDepsForTest(t, caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
root.AddCommand(shortcut.Commands()...)
stdout := &bytes.Buffer{}
root.SetOut(stdout)
root.SetErr(&bytes.Buffer{})
args := []string{"aitable", "+record-query", "--base-id", "base", "--table-id", "table", "--limit", fmt.Sprint(limit)}
root.SetArgs(append(args, extra...))
err := root.Execute()
if stdout.Len() == 0 {
return nil, err
}
var payload map[string]any
if decodeErr := json.Unmarshal(stdout.Bytes(), &payload); decodeErr != nil {
t.Fatalf("decode record query output %q: %v", stdout.String(), decodeErr)
}
return payload, err
}
func TestCrossPlatformCoverageRecordQueryServicePageBoundariesE2E(t *testing.T) {
for _, size := range []int{20, 21, 22, 100} {
t.Run(fmt.Sprintf("size_%d", size), func(t *testing.T) {
records := updateFixtureRecords(0, size, "可见")
caller := &upsertByKeyCaller{}
caller.callFn = func(_ int, _, tool string, args map[string]any) (string, error) {
if tool != "query_records" {
return "", fmt.Errorf("unexpected tool %s", tool)
}
return pagedRecordQueryResponse(t, records, args), nil
}
payload, err := runRecordQueryShortcutCLI(t, caller, size)
if err != nil {
t.Fatalf("record query size %d: %v", size, err)
}
data, ok := payload["data"].(map[string]any)
if !ok || payload["success"] != true || data["hasMore"] != false || data["totalCount"] != float64(size) || len(data["records"].([]any)) != size {
t.Fatalf("record query size %d payload = %#v", size, payload)
}
wantCalls := (size + recordQueryServicePageSize - 1) / recordQueryServicePageSize
if len(caller.calls) != wantCalls || data["page"] != float64(wantCalls) || data["size"] != float64(size) {
t.Fatalf("record query size %d calls=%d payload=%#v, want calls=%d", size, len(caller.calls), payload, wantCalls)
}
})
}
}
func TestCrossPlatformCoverageRecordQueryDryRunStopsBeforeTransportE2E(t *testing.T) {
caller := &upsertByKeyCaller{}
payload, err := runRecordQueryShortcutCLI(t, caller, 5, "--query", "needle", "--dry-run")
if err != nil {
t.Fatalf("record query dry-run error = %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("record query dry-run crossed transport: %#v", caller.calls)
}
arguments, _ := payload["arguments"].(map[string]any)
if payload["dry_run"] != true || payload["executed"] != false || payload["tool"] != "query_records" ||
arguments["baseId"] != "base" || arguments["tableId"] != "table" || arguments["keyword"] != "needle" || arguments["limit"] != float64(5) {
t.Fatalf("record query dry-run payload = %#v", payload)
}
}
func TestCrossPlatformCoverageRecordQueryDryRunRejectsInvalidLocalPlanE2E(t *testing.T) {
for _, testCase := range []struct {
name string
limit int
extra []string
want string
}{
{name: "zero limit", limit: 0, extra: []string{"--dry-run"}, want: "--limit must be in [1,100]"},
{name: "excessive limit", limit: recordBatchSize + 1, extra: []string{"--dry-run"}, want: "--limit must be in [1,100]"},
{name: "empty record IDs", limit: 100, extra: []string{"--record-ids", " ", "--dry-run"}, want: "至少包含一个非空 recordId"},
{
name: "excessive record IDs",
limit: 100,
extra: []string{"--record-ids", strings.Join(recordIDFixtures(recordBatchSize+1), ","), "--dry-run"},
want: "at most 100 unique IDs",
},
} {
t.Run(testCase.name, func(t *testing.T) {
caller := &upsertByKeyCaller{}
payload, err := runRecordQueryShortcutCLI(t, caller, testCase.limit, testCase.extra...)
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("record query dry-run error = %v, want %q (payload=%#v)", err, testCase.want, payload)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid record query dry-run crossed transport: %#v", caller.calls)
}
})
}
}
func TestCrossPlatformCoverageRecordQueryExactIDsIgnoreResidualContinuationE2E(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, args map[string]any) (string, error) {
if tool != "query_records" {
return "", fmt.Errorf("unexpected tool %s", tool)
}
if call != 0 {
return "", errors.New("exact-ID query followed residual continuation")
}
if args["limit"] != 2 {
t.Fatalf("exact-ID service limit = %#v, want 2", args["limit"])
}
ids, ok := args["recordIds"].([]string)
if !ok || !slices.Equal(ids, []string{"r1", "r2"}) {
t.Fatalf("exact-ID request IDs = %#v", args["recordIds"])
}
return `{"success":true,"data":{"records":[{"recordId":"r1"},{"recordId":"r2"}],"hasMore":true,"nextCursor":"residual"}}`, nil
}}
payload, err := runRecordQueryShortcutCLI(t, caller, 100, "--record-ids", "r2,r1,r2")
data, _ := payload["data"].(map[string]any)
if err != nil || len(caller.calls) != 1 || data["hasMore"] != false || data["size"] != float64(2) {
t.Fatalf("exact-ID payload=%#v calls=%d err=%v", payload, len(caller.calls), err)
}
if _, exists := data["nextCursor"]; exists {
t.Fatalf("exact-ID payload retained residual cursor: %#v", payload)
}
}
func TestCrossPlatformCoverageRecordQueryFailureAndContinuationBranches(t *testing.T) {
t.Run("non-positive window limit", func(t *testing.T) {
caller := &upsertByKeyCaller{}
helpers.InitDepsForTest(t, caller)
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery)
if _, err := queryRecordWindow(rt, nil, 0); err == nil {
t.Fatal("non-positive record query window limit accepted")
}
})
t.Run("initial cursor cycle", func(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, _ map[string]any) (string, error) {
if tool != "query_records" {
return "", fmt.Errorf("unexpected tool %s", tool)
}
return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":true,"nextCursor":"seed"}}`, nil
}}
helpers.InitDepsForTest(t, caller)
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery)
if _, err := queryRecordWindow(rt, map[string]any{"cursor": " seed "}, 2); err == nil || !strings.Contains(err.Error(), "cursor cycle") {
t.Fatalf("initial cursor cycle error = %v", err)
}
})
t.Run("strict explicit empty shape", func(t *testing.T) {
for _, data := range []map[string]any{
{"success": true, "status": "success", "data": map[string]any{"unexpected": true}},
{"success": true, "status": "success", "data": map[string]any{}, "error": "bad"},
} {
if explicitEmptyRecordQuery(data) {
t.Fatalf("invalid empty-query envelope accepted: %#v", data)
}
}
})
t.Run("explicit empty page", func(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(_ int, _, _ string, _ map[string]any) (string, error) {
return `{"success":true,"status":"success","error":{},"data":{}}`, nil
}}
helpers.InitDepsForTest(t, caller)
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery)
window, err := queryRecordWindow(rt, map[string]any{}, 1)
if err != nil || len(window.Records) != 0 || window.Pages != 1 || window.HasMore || window.NextCursor != "" {
t.Fatalf("explicit empty window=%#v err=%v", window, err)
}
})
t.Run("missing records collection", func(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(_ int, _, _ string, _ map[string]any) (string, error) {
return `{}`, nil
}}
helpers.InitDepsForTest(t, caller)
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery)
if _, err := queryRecordWindow(rt, map[string]any{}, 1); err == nil || !strings.Contains(err.Error(), "missing the records collection") {
t.Fatalf("missing records error = %v", err)
}
})
t.Run("changing cursors cannot hide sustained empty pages", func(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, _ map[string]any) (string, error) {
if tool != "query_records" {
return "", fmt.Errorf("unexpected tool %s", tool)
}
return fmt.Sprintf(
`{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"empty-%d"}}`,
call,
), nil
}}
helpers.InitDepsForTest(t, caller)
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery)
if _, err := queryRecordWindow(rt, map[string]any{}, 1); err == nil || !strings.Contains(err.Error(), "no progress") {
t.Fatalf("sustained empty pages error = %v", err)
}
if len(caller.calls) != recordQueryMaxConsecutiveEmptyPages {
t.Fatalf("query calls = %d, want %d", len(caller.calls), recordQueryMaxConsecutiveEmptyPages)
}
})
t.Run("invalid shortcut limit", func(t *testing.T) {
caller := &upsertByKeyCaller{}
helpers.InitDepsForTest(t, caller)
cmd := &cobra.Command{Use: "query"}
cmd.Flags().Int("limit", 0, "")
if err := cmd.Flags().Set("limit", "0"); err != nil {
t.Fatal(err)
}
rt := shortcut.RuntimeContextForTest(cmd, RecordQuery)
if err := executeRecordQuery(rt, map[string]any{}); err == nil {
t.Fatal("invalid shortcut limit accepted")
}
})
t.Run("invalid exact-ID request", func(t *testing.T) {
for _, testCase := range []struct {
name string
params map[string]any
want string
}{
{name: "wrong type", params: map[string]any{"recordIds": 1}, want: "string list"},
{name: "empty", params: map[string]any{"recordIds": []string{" "}}, want: "至少包含一个非空 recordId"},
{name: "over service limit", params: map[string]any{"recordIds": recordIDFixtures(recordBatchSize + 1)}, want: "at most 100 unique IDs"},
} {
t.Run(testCase.name, func(t *testing.T) {
caller := &upsertByKeyCaller{}
helpers.InitDepsForTest(t, caller)
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery)
err := executeRecordQuery(rt, testCase.params)
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("exact-ID request error = %v, want %q", err, testCase.want)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid exact-ID request made %d service calls", len(caller.calls))
}
})
}
})
t.Run("invalid exact-ID response", func(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, _ map[string]any) (string, error) {
if tool != "query_records" {
return "", fmt.Errorf("unexpected tool %s", tool)
}
return `{"success":true,"data":{"records":[{"recordId":"other"}]}}`, nil
}}
if _, err := runRecordQueryShortcutCLI(t, caller, 100, "--record-ids", "r1"); err == nil || !strings.Contains(err.Error(), "unexpected recordId") {
t.Fatalf("unexpected exact-ID response error = %v", err)
}
if _, err := validateExactRecordQuery([]map[string]any{{}}, []string{"r1"}); err == nil || !strings.Contains(err.Error(), "missing recordId") {
t.Fatalf("missing exact-ID response error = %v", err)
}
duplicate := []map[string]any{{"recordId": "r1"}, {"recordId": "r1"}}
if _, err := validateExactRecordQuery(duplicate, []string{"r1", "r2"}); err == nil || !strings.Contains(err.Error(), "duplicate recordId") {
t.Fatalf("duplicate exact-ID response error = %v", err)
}
})
t.Run("shortcut query failure", func(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(_ int, _, _ string, _ map[string]any) (string, error) {
return "", errors.New("query unavailable")
}}
helpers.InitDepsForTest(t, caller)
rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "query"}, RecordQuery)
if err := executeRecordQuery(rt, map[string]any{}); err == nil || !strings.Contains(err.Error(), "query unavailable") {
t.Fatalf("shortcut query failure = %v", err)
}
})
t.Run("bounded continuation is published", func(t *testing.T) {
records := updateFixtureRecords(0, 21, "visible")
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, args map[string]any) (string, error) {
if tool != "query_records" {
return "", fmt.Errorf("unexpected tool %s", tool)
}
return pagedRecordQueryResponse(t, records, args), nil
}}
payload, err := runRecordQueryShortcutCLI(t, caller, 20)
data, _ := payload["data"].(map[string]any)
if err != nil || data["hasMore"] != true || data["nextCursor"] != "offset-20" {
t.Fatalf("bounded continuation payload=%#v err=%v", payload, err)
}
})
t.Run("delete readback follows active continuation to absence", func(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, args map[string]any) (string, error) {
if tool != "query_records" {
return `{"deletedCount":1}`, nil
}
if call == 1 {
if _, exists := args["cursor"]; exists {
t.Fatalf("first deletion readback unexpectedly has cursor: %#v", args)
}
return `{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c1"}}`, nil
}
if args["cursor"] != "c1" {
t.Fatalf("continued deletion readback cursor = %#v", args["cursor"])
}
return `{"success":true,"data":{"records":[],"hasMore":false}}`, nil
}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err != nil || out == "" || len(caller.calls) != 3 {
t.Fatalf("delete continued absence output=%q error=%v calls=%#v", out, err, caller.calls)
}
})
t.Run("delete readback follows active continuation to remaining record", func(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, _ map[string]any) (string, error) {
if tool != "query_records" {
return `{"deletedCount":1}`, nil
}
if call == 1 {
return `{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c1"}}`, nil
}
return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil
}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err == nil || out != "" || len(caller.calls) != 3 {
t.Fatalf("delete continued remaining output=%q error=%v calls=%#v", out, err, caller.calls)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" {
t.Fatalf("delete continued remaining error = %#v", err)
}
})
t.Run("delete readback fails closed on empty continuation stall", func(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, _ map[string]any) (string, error) {
if tool != "query_records" {
return `{"deletedCount":1}`, nil
}
return fmt.Sprintf(`{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c%d"}}`, call), nil
}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err == nil || out != "" || len(caller.calls) != recordQueryMaxConsecutiveEmptyPages+1 {
t.Fatalf("delete stalled continuation output=%q error=%v calls=%#v", out, err, caller.calls)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" {
t.Fatalf("delete stalled continuation error = %#v", err)
}
})
}
func TestCrossPlatformCoverageRecordWriteReadbackUsesStableServicePagesE2E(t *testing.T) {
for _, operation := range []string{"update", "upsert", "delete"} {
for _, size := range []int{21, 22, 100} {
t.Run(fmt.Sprintf("%s_%d", operation, size), func(t *testing.T) {
records := updateFixtureRecords(0, size, "完成")
caller := &upsertByKeyCaller{}
queryCalls := 0
caller.callFn = func(_ int, _, tool string, args map[string]any) (string, error) {
if tool != "query_records" {
return `{"success":true}`, nil
}
queryCalls++
if operation == "delete" {
ids := args["recordIds"].([]string)
empty := make([]map[string]any, len(ids))
response := pagedRecordQueryResponse(t, empty, args)
var payload map[string]any
if err := json.Unmarshal([]byte(response), &payload); err != nil {
t.Fatal(err)
}
page := payload["data"].(map[string]any)
page["records"] = []any{}
raw, _ := json.Marshal(payload)
return string(raw), nil
}
response := pagedRecordQueryResponse(t, records, args)
var payload map[string]any
if err := json.Unmarshal([]byte(response), &payload); err != nil {
t.Fatal(err)
}
page := payload["data"].(map[string]any)
if len(page["records"].([]any))+recordQueryServicePageSize*(queryCalls-1) >= size {
// The live service can retain a continuation after it has
// returned every requested record ID. Exact-ID verification
// must use ID coverage, not this advisory bit.
page["hasMore"] = true
page["nextCursor"] = "ignored-after-complete-id-set"
payload["hasMore"] = true
}
raw, _ := json.Marshal(payload)
return string(raw), nil
}
var out string
var err error
if operation == "delete" {
out, err = runRecordDeleteCLI(t, caller, recordIDs(records))
} else {
out, err = runRecordBatchCLI(t, caller, "+record-"+operation, records)
}
if err != nil || out == "" {
t.Fatalf("%s size %d false negative: output=%q err=%v", operation, size, out, err)
}
wantQueries := (size + recordQueryServicePageSize - 1) / recordQueryServicePageSize
if queryCalls != wantQueries {
t.Fatalf("%s size %d query calls=%d, want %d", operation, size, queryCalls, wantQueries)
}
})
}
}
}
func TestCrossPlatformCoverageRecordUpdateAutoChunksAndVerifiesE2E(t *testing.T) {
records := updateFixtureRecords(0, 101, "完成")
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
@@ -395,6 +836,10 @@ func TestCrossPlatformCoverageRecordDeleteAutoChunksAndProvesAbsenceE2E(t *testi
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"deletedCount":100}`},
{text: `{"data":{"records":[]}}`},
{text: `{"data":{"records":[]}}`},
{text: `{"data":{"records":[]}}`},
{text: `{"data":{"records":[]}}`},
{text: `{"data":{"records":[]}}`},
{text: `{"deletedCount":1}`},
{text: `{"data":{"records":[]}}`},
}}
@@ -407,7 +852,7 @@ func TestCrossPlatformCoverageRecordDeleteAutoChunksAndProvesAbsenceE2E(t *testi
t.Fatalf("delete output missing %s: %s", want, out)
}
}
if len(caller.calls) != 4 || caller.calls[0].tool != "delete_records" || caller.calls[1].tool != "query_records" {
if len(caller.calls) != 8 || caller.calls[0].tool != "delete_records" || caller.calls[1].tool != "query_records" || caller.calls[6].tool != "delete_records" {
t.Fatalf("delete call sequence = %#v", caller.calls)
}
}
@@ -421,6 +866,17 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t
}
})
t.Run("explicit service success with empty data proves deletion", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"deletedCount":1}`},
{text: `{"success":true,"status":"success","error":{},"data":{}}`},
}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err != nil || !strings.Contains(out, `"status": "verified_absent"`) {
t.Fatalf("delete explicit empty success = output:%q err:%v", out, err)
}
})
t.Run("missing records contract is unknown", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"deletedCount":1}`}, {text: `{"data":{}}`}}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
@@ -450,6 +906,10 @@ func TestCrossPlatformCoverageRecordDeletePartialCheckpointE2E(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"deletedCount":100}`},
{text: `{"records":[]}`},
{text: `{"records":[]}`},
{text: `{"records":[]}`},
{text: `{"records":[]}`},
{text: `{"records":[]}`},
{text: `{"deletedCount":0}`},
{text: `{"records":[{"recordId":"r100","cells":{}}]}`},
}}
+303 -207
View File
@@ -19,6 +19,7 @@ package calendar
import (
"fmt"
"strconv"
"strings"
"time"
@@ -78,95 +79,99 @@ var EventList = shortcut.Shortcut{
},
},
Flags: []shortcut.Flag{
{Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601 (例如 2026-03-10T00:00:00+08:00),默认今天 00:00"},
{Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601,默认今天 23:59"},
{Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601;起止时间必须是 RFC3339/ISO-8601,且 end 晚于 start;默认今天 00:00"},
{Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601;起止时间必须是 RFC3339/ISO-8601,且 end 晚于 start;默认今天 23:59"},
{Name: "calendar-id", Type: shortcut.FlagString, Desc: "日历 ID (默认 primary 主日历)"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上一次返回的 nextCursor)"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回条数 (默认 100,最大 100)"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回条数(服务端默认 100);limit 必须在 1-100 之间"},
},
Tips: []string{
`dws calendar +agenda`,
`dws calendar +agenda --start "2026-03-10T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00" --limit 50`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{}
now := time.Now()
if rt.Changed("start") {
ms, err := parseMillis("start", rt.Str("start"))
if err != nil {
return err
}
params["startTime"] = ms
} else {
params["startTime"] = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).UnixMilli()
}
if rt.Changed("end") {
ms, err := parseMillis("end", rt.Str("end"))
if err != nil {
return err
}
params["endTime"] = ms
} else {
params["endTime"] = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).UnixMilli()
}
if rt.Changed("calendar-id") {
params["calendarId"] = rt.Str("calendar-id")
}
if rt.Changed("cursor") {
params["cursor"] = rt.Str("cursor")
}
if rt.Changed("limit") {
params["limit"] = rt.Int("limit")
params, err := calendarAgendaParams(rt, time.Now())
if err != nil {
return err
}
data, err := rt.CallMCPData("calendar", "list_calendar_events", params)
if err != nil {
return err
}
events := eventListProject(data)
return rt.Output(map[string]any{"count": len(events), "events": events})
events, page, err := eventListProject(data)
if err != nil {
return err
}
out := map[string]any{"count": len(events), "events": events}
return outputCalendarPage(rt, out, page)
},
}
// calendarAgendaParams is the explicit adapter from the published composite
// Shortcut properties (start/end) to list_calendar_events RPC properties
// (startTime/endTime). The public Schema property names predate this adapter
// and remain stable for backwards compatibility.
func calendarAgendaParams(rt *shortcut.RuntimeContext, now time.Time) (map[string]any, error) {
params := map[string]any{}
if rt.Changed("start") {
ms, err := parseMillis("start", rt.Str("start"))
if err != nil {
return nil, err
}
params["startTime"] = ms
} else {
params["startTime"] = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).UnixMilli()
}
if rt.Changed("end") {
ms, err := parseMillis("end", rt.Str("end"))
if err != nil {
return nil, err
}
params["endTime"] = ms
} else {
params["endTime"] = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).UnixMilli()
}
if rt.Changed("calendar-id") {
params["calendarId"] = rt.Str("calendar-id")
}
if rt.Changed("cursor") {
params["cursor"] = rt.Str("cursor")
}
if rt.Changed("limit") {
params["limit"] = rt.Int("limit")
}
if params["endTime"].(int64) <= params["startTime"].(int64) {
return nil, fmt.Errorf("--end 必须晚于 --start")
}
return params, nil
}
// eventListProject reshapes the raw list_calendar_events response into a clean,
// stable event list (eventId/summary/start/end/status/location) — the
// the clean output projection applied to every list command. The
// list container and each field are probed defensively across candidate keys,
// since event payloads may nest under result/data/list/items with aliases.
func eventListProject(data map[string]any) []map[string]any {
if data == nil {
return []map[string]any{}
func eventListProject(data map[string]any) ([]map[string]any, calendarPageEvidence, error) {
items, container, err := requireCalendarCollection(data, "calendar/list_calendar_events", "events")
if err != nil {
return nil, calendarPageEvidence{}, err
}
raw := eventListContainer(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
row := map[string]any{}
if v, ok := eventListFirst(m, "eventId", "event_id", "id"); ok {
row["eventId"] = v
}
if v, ok := eventListFirst(m, "summary", "title", "name"); ok {
row["summary"] = v
}
if v, ok := eventListFirst(m, "start", "startTime", "start_time", "startDateTime", "start_date_time"); ok {
row["start"] = v
}
if v, ok := eventListFirst(m, "end", "endTime", "end_time", "endDateTime", "end_date_time"); ok {
row["end"] = v
}
if v, ok := eventListFirst(m, "status", "eventStatus", "event_status", "responseStatus", "response_status"); ok {
row["status"] = v
}
if v, ok := eventListFirst(m, "location", "locationName", "location_name"); ok {
row["location"] = v
}
if len(row) > 0 {
out = append(out, row)
}
rows, err := projectCalendarRows(items, "calendar/list_calendar_events", map[string][]string{
"eventId": {"eventId", "event_id", "id"},
"summary": {"summary", "title", "name"},
"start": {"start", "startTime", "start_time", "startDateTime", "start_date_time"},
"end": {"end", "endTime", "end_time", "endDateTime", "end_date_time"},
"status": {"status", "eventStatus", "event_status", "responseStatus", "response_status"},
"location": {"location", "locationName", "location_name"},
}, "eventId")
if err != nil {
return nil, calendarPageEvidence{}, err
}
return out
page, err := calendarPagination(container, "calendar/list_calendar_events")
if err == nil && !page.Known {
err = calendarResponseError("calendar/list_calendar_events", "missing_pagination", "日程列表响应缺少 hasMore/nextCursor 分页证据,不能判断结果是否完整")
}
return rows, page, err
}
// eventListContainer locates the event slice across candidate wrapper keys,
@@ -257,7 +262,10 @@ var AttendeeList = shortcut.Shortcut{
if err != nil {
return err
}
attendees := attendeeListProject(data)
attendees, err := attendeeListProject(data)
if err != nil {
return err
}
return rt.Output(map[string]any{"count": len(attendees), "attendees": attendees})
},
}
@@ -267,32 +275,17 @@ var AttendeeList = shortcut.Shortcut{
// the clean output projection applied to every list command.
// The list container and each field are probed defensively across candidate keys,
// since participant payloads may nest under result/data/list/items with aliases.
func attendeeListProject(data map[string]any) []map[string]any {
if data == nil {
return []map[string]any{}
func attendeeListProject(data map[string]any) ([]map[string]any, error) {
items, _, err := requireCalendarCollection(data, "calendar/get_calendar_participants", "attendees", "participants", "items", "list")
if err != nil {
return nil, err
}
raw := attendeeListContainer(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
row := map[string]any{}
if v, ok := attendeeFirst(m, "displayName", "display_name", "name", "userName", "user_name", "nick", "nickName"); ok {
row["displayName"] = v
}
if v, ok := attendeeFirst(m, "userId", "user_id", "id", "staffId", "staff_id", "unionId", "union_id"); ok {
row["userId"] = v
}
if v, ok := attendeeFirst(m, "responseStatus", "response_status", "status", "attendeeStatus", "attendee_status", "responseType", "response"); ok {
row["responseStatus"] = v
}
if len(row) > 0 {
out = append(out, row)
}
}
return out
return projectCalendarRows(items, "calendar/get_calendar_participants", map[string][]string{
"displayName": {"displayName", "display_name", "name", "userName", "user_name", "nick", "nickName"},
"userId": {"userId", "user_id", "id", "staffId", "staff_id", "unionId", "union_id"},
"responseStatus": {"responseStatus", "response_status", "status", "attendeeStatus", "attendee_status", "responseType", "response"},
"self": {"self"},
}, "displayName", "userId")
}
// attendeeListContainer locates the participant slice across candidate wrapper
@@ -375,7 +368,10 @@ var RoomSearch = shortcut.Shortcut{
if err != nil {
return err
}
rooms := roomSearchProject(data)
rooms, err := roomSearchProject(data)
if err != nil {
return err
}
return rt.Output(map[string]any{"count": len(rooms), "rooms": rooms})
},
}
@@ -385,35 +381,17 @@ var RoomSearch = shortcut.Shortcut{
// the framework applies to every list command. The list container and each field
// are probed defensively across candidate keys, since room payloads may nest
// under result/data/list/items with aliases.
func roomSearchProject(data map[string]any) []map[string]any {
if data == nil {
return []map[string]any{}
func roomSearchProject(data map[string]any) ([]map[string]any, error) {
items, _, err := requireCalendarCollection(data, "calendar/search_rooms", "rooms", "roomList", "meetingRooms", "items", "list")
if err != nil {
return nil, err
}
raw := roomSearchContainer(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
row := map[string]any{}
if v, ok := roomSearchFirst(m, "roomId", "room_id", "id"); ok {
row["roomId"] = v
}
if v, ok := roomSearchFirst(m, "roomName", "room_name", "name", "summary"); ok {
row["roomName"] = v
}
if v, ok := roomSearchFirst(m, "capacity", "seats", "seatCount", "seat_count"); ok {
row["capacity"] = v
}
if v, ok := roomSearchFirst(m, "location", "floor", "building", "address"); ok {
row["location"] = v
}
if len(row) > 0 {
out = append(out, row)
}
}
return out
return projectCalendarRows(items, "calendar/search_rooms", map[string][]string{
"roomId": {"roomId", "room_id", "id"},
"roomName": {"roomName", "room_name", "name", "summary"},
"capacity": {"capacity", "seats", "seatCount", "seat_count"},
"location": {"location", "floor", "building", "address"},
}, "roomId")
}
// roomSearchContainer locates the room slice across candidate wrapper keys,
@@ -455,21 +433,48 @@ var RoomFind = shortcut.Shortcut{
Command: "+room-find",
Product: "calendar",
Description: "按时间段搜索可用会议室(不传时间默认当前起 1 小时)",
Intent: "当你要在某个时间段找一间空闲会议室开会时使用;传入 --start/--end 时间段(须为未来时间,缺省为当前起 1 小时),可加 --available 只看空闲、--group-id/--room-name 缩小范围,返回该时段的会议室及其可用性和 roomId,便于据此 +room-add 预定。",
Intent: "当你要在一个未来时间段寻找当前可预定的会议室时使用;--group-id/--room-name 可缩小范围,显式返回当前页和页码信息。该命令只查询单个时间段,后端不支持 Lark 风格多 slot、城市/楼宇/容量联合筛选。",
Risk: shortcut.RiskRead,
Safety: calendarReadSafety(),
Contract: calendarContract(
"+room-find", "按时间段严格搜索可用会议室",
"需要按一个明确的未来时间段查询可用会议室,并保留真实页码信息时使用。",
[]string{"只按名称查会议室而不检查可用性时改用 calendar +room-search", "要预订会议室时先取得 roomId,再使用原子 room add"},
[]string{`dws calendar +room-find --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00"`},
calendarCollectionResult("rooms", "严格校验的可用会议室当前页"), nil,
contract.ParamDecl{Name: "start", Property: "startTime"},
contract.ParamDecl{Name: "end", Property: "endTime"},
contract.ParamDecl{Name: "group-id", Property: "groupId"},
contract.ParamDecl{Name: "room-name", Property: "roomName"},
contract.ParamDecl{Name: "limit", Property: "pageSize"},
contract.ParamDecl{Name: "page", Property: "pageIndex"},
),
Flags: []shortcut.Flag{
{Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601 (必须是未来时间)"},
{Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601"},
{Name: "available", Type: shortcut.FlagBool, Desc: "仅返回可用会议室"},
{Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601;起止时间必须是 RFC3339/ISO-8601,且 end 晚于 start"},
{Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601;起止时间必须是 RFC3339/ISO-8601,且 end 晚于 start"},
{Name: "available", Type: shortcut.FlagBool, Desc: "仅返回可用会议室;保留已发布参数兼容性"},
{Name: "group-id", Type: shortcut.FlagString, Desc: "会议室分组 ID"},
{Name: "room-name", Type: shortcut.FlagString, Desc: "会议室名称过滤"},
{Name: "limit", Type: shortcut.FlagString, Desc: "每页条数 (pageSize)"},
{Name: "page", Type: shortcut.FlagString, Desc: "页码 (pageIndex,从 0 开始)"},
{Name: "limit", Type: shortcut.FlagString, Desc: "每页条数 (pageSize);保留 string 类型兼容性;limit 必须在 1-100 之间"},
{Name: "page", Type: shortcut.FlagString, Desc: "页码 (pageIndex,从 0 开始);保留 string 类型兼容性;page 不能小于 0"},
},
Validate: func(rt *shortcut.RuntimeContext) error {
_, _, err := roomFindPageValues(rt)
return err
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "起止时间必须是 RFC3339/ISO-8601,且 end 晚于 start"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "limit 必须在 1-100 之间"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "page 不能小于 0"},
},
Tips: []string{
`dws calendar +room-find --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00"`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
pageSize, pageIndex, err := roomFindPageValues(rt)
if err != nil {
return err
}
now := time.Now()
startStr := rt.Str("start")
endStr := rt.Str("end")
@@ -487,9 +492,14 @@ var RoomFind = shortcut.Shortcut{
if err != nil {
return err
}
if endMs <= startMs {
return fmt.Errorf("--end 必须晚于 --start")
}
params := map[string]any{
"startTime": startMs,
"endTime": endMs,
"pageSize": strconv.Itoa(pageSize),
"pageIndex": strconv.Itoa(pageIndex),
}
if rt.Bool("available") {
params["needAvailable"] = true
@@ -500,16 +510,54 @@ var RoomFind = shortcut.Shortcut{
if rt.Changed("room-name") {
params["roomName"] = strings.TrimSpace(rt.Str("room-name"))
}
if rt.Changed("limit") {
params["pageSize"] = rt.Str("limit")
data, err := rt.CallMCPData("calendar", "query_available_meeting_room", params)
if err != nil {
return err
}
if rt.Changed("page") {
params["pageIndex"] = rt.Str("page")
items, container, err := requireCalendarCollection(data, "calendar/query_available_meeting_room", "rooms", "roomList", "meetingRoomList", "meetingRooms", "items", "list")
if err != nil {
return err
}
return rt.CallMCP("query_available_meeting_room", params)
rooms, err := projectCalendarRows(items, "calendar/query_available_meeting_room", map[string][]string{
"roomId": {"roomId", "room_id", "id"},
"roomName": {"roomName", "room_name", "name", "summary"},
"capacity": {"capacity", "maxCapacity", "seatCount", "seats"},
"groupId": {"groupId", "group_id"},
"supportRecurring": {"supportRecurring"},
}, "roomId")
if err != nil {
return err
}
out := map[string]any{"count": len(rooms), "rooms": rooms, "page": pageIndex, "pageSize": pageSize, "complete": false}
for _, key := range []string{"hasMore", "totalCount", "total", "pageIndex", "pageSize"} {
if value, ok := container[key]; ok && value != nil {
out[key] = value
}
}
if hasMore, ok := out["hasMore"].(bool); ok {
out["complete"] = !hasMore
}
return rt.Output(out)
},
}
func roomFindPageValues(rt *shortcut.RuntimeContext) (pageSize int, pageIndex int, err error) {
pageSize = 20
if rt.Changed("limit") {
pageSize, err = strconv.Atoi(strings.TrimSpace(rt.Str("limit")))
if err != nil || pageSize < 1 || pageSize > 100 {
return 0, 0, fmt.Errorf("--limit 必须是 1-100 之间的整数")
}
}
if rt.Changed("page") {
pageIndex, err = strconv.Atoi(strings.TrimSpace(rt.Str("page")))
if err != nil || pageIndex < 0 {
return 0, 0, fmt.Errorf("--page 必须是大于或等于 0 的整数")
}
}
return pageSize, pageIndex, nil
}
// RoomAdd → add_meeting_room
// RoomRemove → delete_meeting_room
// RoomGroups → list_meeting_room_groups
@@ -562,7 +610,10 @@ var RoomGroups = shortcut.Shortcut{
if err != nil {
return err
}
groups := roomGroupsProject(data)
groups, err := roomGroupsProject(data)
if err != nil {
return err
}
return rt.Output(map[string]any{"count": len(groups), "groups": groups})
},
}
@@ -572,29 +623,15 @@ var RoomGroups = shortcut.Shortcut{
// the framework applies to every list command. The list container and each field
// are probed defensively across candidate keys, since group payloads may nest
// under result/data/list/items with aliases.
func roomGroupsProject(data map[string]any) []map[string]any {
if data == nil {
return []map[string]any{}
func roomGroupsProject(data map[string]any) ([]map[string]any, error) {
items, _, err := requireCalendarCollection(data, "calendar/list_meeting_room_groups", "groupList", "groups", "items", "list")
if err != nil {
return nil, err
}
raw := roomGroupsContainer(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
row := map[string]any{}
if v, ok := roomGroupsFirst(m, "groupId", "group_id", "id"); ok {
row["groupId"] = v
}
if v, ok := roomGroupsFirst(m, "groupName", "group_name", "name", "summary"); ok {
row["groupName"] = v
}
if len(row) > 0 {
out = append(out, row)
}
}
return out
return projectCalendarRows(items, "calendar/list_meeting_room_groups", map[string][]string{
"groupId": {"groupId", "group_id", "id"},
"groupName": {"groupName", "group_name", "name", "summary"},
}, "groupId")
}
// roomGroupsContainer locates the group slice across candidate wrapper keys,
@@ -699,10 +736,62 @@ var BusySearch = shortcut.Shortcut{
if len(rt.StrSlice("rooms")) > 0 {
params["roomIds"] = rt.StrSlice("rooms")
}
return rt.CallMCP("query_busy_status", params)
data, err := rt.CallMCPData("calendar", "query_busy_status", params)
if err != nil {
return err
}
busy, err := busySearchProject(data)
if err != nil {
return err
}
return rt.Output(map[string]any{"count": len(busy), "busy": busy, "free": len(busy) == 0, "complete": true})
},
}
func busySearchProject(data map[string]any) ([]map[string]any, error) {
entries, _, err := requireCalendarCollection(data, "calendar/query_busy_status", "busy", "entries")
if err != nil {
return nil, err
}
out := make([]map[string]any, 0)
for entryIndex, rawEntry := range entries {
entry := rawEntry.(map[string]any)
rawItems, present := entry["scheduleItems"]
if !present {
return nil, calendarResponseError("calendar/query_busy_status", "missing_schedule_items", fmt.Sprintf("result[%d] 缺少 scheduleItems", entryIndex))
}
items, ok := rawItems.([]any)
if !ok {
return nil, calendarResponseError("calendar/query_busy_status", "malformed_schedule_items", fmt.Sprintf("result[%d].scheduleItems 不是数组", entryIndex))
}
if err := validateCalendarCollectionItems(items, "calendar/query_busy_status", "scheduleItems"); err != nil {
return nil, err
}
for itemIndex, rawItem := range items {
item := rawItem.(map[string]any)
start := calendarBusyDateTime(item["start"])
end := calendarBusyDateTime(item["end"])
if calendarEmptyValue(start) || calendarEmptyValue(end) {
return nil, calendarResponseError("calendar/query_busy_status", "malformed_schedule_time", fmt.Sprintf("scheduleItems[%d] 缺少开始或结束时间", itemIndex))
}
out = append(out, map[string]any{"start": start, "end": end})
}
}
return out, nil
}
func calendarBusyDateTime(value any) any {
if object, ok := value.(map[string]any); ok {
if dateTime, present := object["dateTime"]; present {
return dateTime
}
if date, present := object["date"]; present {
return date
}
}
return value
}
// ── attachment: 附件 ──────────────────────────────────────────
// AttachmentAdd → add_attachments
@@ -752,35 +841,28 @@ var BookList = shortcut.Shortcut{
if err != nil {
return err
}
books := bookListProject(data)
books, err := bookListProject(data)
if err != nil {
return err
}
return rt.Output(map[string]any{"count": len(books), "calendars": books})
},
}
// bookListProject reshapes list_calendars into a clean calendar-book list
// (calendarId/summary/privilege/type) — clean output projection.
func bookListProject(data map[string]any) []map[string]any {
raw, ok := data["result"].([]any)
if !ok {
return []map[string]any{}
func bookListProject(data map[string]any) ([]map[string]any, error) {
items, _, err := requireCalendarCollection(data, "calendar/list_calendars", "calendars", "calendarList", "items", "list")
if err != nil {
return nil, err
}
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
row := map[string]any{}
for _, k := range []string{"calendarId", "summary", "privilege", "type", "description"} {
if v, ok := m[k]; ok {
row[k] = v
}
}
if len(row) > 0 {
out = append(out, row)
}
}
return out
return projectCalendarRows(items, "calendar/list_calendars", map[string][]string{
"calendarId": {"calendarId", "calendar_id", "id"},
"summary": {"summary", "name", "title"},
"privilege": {"privilege", "role", "accessRole"},
"type": {"type", "calendarType", "calendar_type"},
"description": {"description", "desc"},
}, "calendarId")
}
// BookGet → get_calendar
@@ -826,7 +908,10 @@ var BookSearch = shortcut.Shortcut{
if err != nil {
return err
}
calendars := bookSearchProject(data)
calendars, err := bookSearchProject(data)
if err != nil {
return err
}
return rt.Output(map[string]any{"count": len(calendars), "calendars": calendars})
},
}
@@ -835,35 +920,17 @@ var BookSearch = shortcut.Shortcut{
// stable calendar-book list (calendarId/summary/privilege/type) — output-projection
// clean output projection. The list container and each field are probed defensively
// across candidate keys, tolerating nesting under result/data/list/items.
func bookSearchProject(data map[string]any) []map[string]any {
if data == nil {
return []map[string]any{}
func bookSearchProject(data map[string]any) ([]map[string]any, error) {
items, _, err := requireCalendarCollection(data, "calendar/search_calendar", "calendars", "calendarList", "items", "list")
if err != nil {
return nil, err
}
raw := bookSearchContainer(data)
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
row := map[string]any{}
if v, ok := bookSearchFirst(m, "calendarId", "calendar_id", "id"); ok {
row["calendarId"] = v
}
if v, ok := bookSearchFirst(m, "summary", "name", "title"); ok {
row["summary"] = v
}
if v, ok := bookSearchFirst(m, "privilege", "role", "accessRole"); ok {
row["privilege"] = v
}
if v, ok := bookSearchFirst(m, "type", "calendarType", "calendar_type"); ok {
row["type"] = v
}
if len(row) > 0 {
out = append(out, row)
}
}
return out
return projectCalendarRows(items, "calendar/search_calendar", map[string][]string{
"calendarId": {"calendarId", "calendar_id", "id"},
"summary": {"summary", "name", "title"},
"privilege": {"privilege", "role", "accessRole"},
"type": {"type", "calendarType", "calendar_type"},
}, "calendarId")
}
// bookSearchContainer locates the calendar slice across candidate wrapper keys,
@@ -901,8 +968,37 @@ func bookSearchFirst(m map[string]any, keys ...string) (any, bool) {
// BookUpdate → update_calendar
func init() {
EventList.Contract.Parameters = []contract.ParamDecl{
{Name: "start", Property: "start"}, {Name: "end", Property: "end"},
{Name: "calendar-id", Property: "calendarId"}, {Name: "cursor", Property: "cursor"}, {Name: "limit", Property: "limit"},
}
EventList.Validate = func(rt *shortcut.RuntimeContext) error {
if !rt.Changed("limit") {
return nil
}
return rt.RangeInt("limit", 1, 100)
}
EventList.Constraints = []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "起止时间必须是 RFC3339/ISO-8601,且 end 晚于 start"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "limit 必须在 1-100 之间"},
}
finalizeCalendarShortcut(&EventList, calendarCollectionResult("events", "严格校验并保留分页证据的日程当前页"), calendarCursorPagination())
finalizeCalendarShortcut(&AttendeeList, calendarObjectResult("严格校验的日程参会人列表"), nil)
finalizeCalendarShortcut(&RoomSearch, calendarObjectResult("严格校验的按名会议室列表"), nil)
finalizeCalendarShortcut(&RoomFind, RoomFind.Contract.Result, nil)
finalizeCalendarShortcut(&RoomGroups, calendarObjectResult("严格校验的会议室分组当前页"), nil)
finalizeCalendarShortcut(&BusySearch, calendarObjectResult("用户或会议室忙闲结果"), nil)
finalizeCalendarShortcut(&BookList, calendarObjectResult("严格校验的日历本列表"), nil)
finalizeCalendarShortcut(&BookSearch, calendarObjectResult("严格校验的日历本搜索结果"), nil)
EventSearch.Contract.Pagination = calendarCursorPagination()
shortcut.Register(
EventList,
EventGet,
EventCreate,
EventUpdate,
RSVP,
EventSearch,
Suggestion,
AttendeeList,
RoomSearch,
RoomFind,
@@ -0,0 +1,538 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package calendar
import (
"bytes"
"context"
"encoding/json"
"math"
"strconv"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const (
calendarCoverageStart = "2026-08-17T09:00:00+08:00"
calendarCoverageEnd = "2026-08-17T10:00:00+08:00"
calendarCoverageEvent = `{"success":true,"result":{"id":"event-1","summary":"updated","description":"description","start":{"dateTime":"2026-08-17T09:00:00+08:00"},"end":{"dateTime":"2026-08-17T10:00:00+08:00"},"timeZone":"Asia/Shanghai","location":"room","freeBusy":"busy"}}`
)
func calendarRuntimeForTest(t *testing.T, declaration shortcut.Shortcut, values map[string]string) *shortcut.RuntimeContext {
t.Helper()
cmd := corecmd.New(shortcut.FromShortcut(declaration))
for name, value := range values {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
return shortcut.RuntimeContextForTest(cmd, declaration)
}
func TestCrossPlatformCoverageCalendarCommonBranches(t *testing.T) {
if got := calendarExample("+unknown"); !strings.Contains(got, "+unknown") {
t.Fatalf("default example=%q", got)
}
if calendarReadShortcut("+x", "x", "x", "items", nil, nil, nil).Contract.Result == nil {
t.Fatal("collection read shortcut did not build a result")
}
collectionResult := calendarCollectionResult("items", "items")
var collectionSchema struct {
Properties map[string]json.RawMessage `json:"properties"`
}
if err := json.Unmarshal(collectionResult.DataSchema, &collectionSchema); err != nil {
t.Fatalf("decode collection result schema: %v", err)
}
for _, field := range []string{"hasMore", "nextCursor"} {
if _, exists := collectionSchema.Properties[field]; exists {
t.Fatalf("pagination field %q leaked into business Result schema", field)
}
}
if _, exists := collectionSchema.Properties["complete"]; !exists {
t.Fatal("collection Result schema lost business completeness evidence")
}
if calendarWriteShortcut("+x", "x", "x", "dws calendar +x", nil, nil, nil).Safety.Confirmation != "user_required" {
t.Fatal("write shortcut did not build safety")
}
badResponses := []map[string]any{
{"success": "yes"},
{"success": false},
{"result": map[string]any{}},
}
for _, response := range badResponses {
if _, err := requireCalendarObject(response, "calendar/test"); err == nil {
t.Fatalf("bad object accepted: %#v", response)
}
}
if got := calendarContainers(nil); got != nil {
t.Fatalf("nil containers=%#v", got)
}
if !calendarOnlyEnvelopeFields(map[string]any{"success": true, "result": map[string]any{}}) || calendarOnlyEnvelopeFields(map[string]any{"id": "x"}) {
t.Fatal("envelope field classification drift")
}
if _, _, err := requireCalendarCollection(map[string]any{"success": false}, "calendar/test", "items"); err == nil {
t.Fatal("failed response accepted as collection")
}
if _, _, err := requireCalendarCollection(map[string]any{"success": true, "result": []any{"bad"}}, "calendar/test", "items"); err == nil {
t.Fatal("bad wrapper array accepted")
}
items, _, err := requireCalendarCollection(map[string]any{"success": true, "result": []any{map[string]any{"id": "x"}}}, "calendar/test", "items")
if err != nil || len(items) != 1 {
t.Fatalf("wrapper array rejected: %#v %v", items, err)
}
if _, err := projectCalendarRows([]any{map[string]any{"ignored": "x"}}, "calendar/test", map[string][]string{"id": {"id"}}, "id"); err == nil {
t.Fatal("unidentifiable projected row accepted")
}
if !calendarEmptyValue(nil) || !calendarEmptyValue(" ") || calendarEmptyValue(1) {
t.Fatal("calendarEmptyValue classification drift")
}
if page, err := calendarPagination(nil, "calendar/test"); err != nil || page.Known {
t.Fatalf("nil pagination=%+v %v", page, err)
}
for _, container := range []map[string]any{
{"hasMore": "yes"},
{"hasMore": true, "nextCursor": 1},
{"hasMore": false, "nextCursor": "cursor"},
} {
if _, err := calendarPagination(container, "calendar/test"); err == nil {
t.Fatalf("bad pagination accepted: %#v", container)
}
}
if _, err := calendarPagination(map[string]any{"nextToken": "cursor"}, "calendar/test"); err == nil {
t.Fatal("cursor without hasMore accepted")
}
page, err := calendarPagination(map[string]any{"hasMore": true, "nextToken": "cursor"}, "calendar/test")
if err != nil || !page.Known || !page.HasMore || page.NextCursor != "cursor" {
t.Fatalf("cursor-only pagination=%+v %v", page, err)
}
out := map[string]any{}
addCalendarPagination(out, page)
if out["nextCursor"] != "cursor" || out["hasMore"] != true {
t.Fatalf("pagination output=%#v", out)
}
if _, _, err := validateCalendarRange("start", "bad", "end", calendarCoverageEnd); err == nil {
t.Fatal("bad start accepted")
}
if _, _, err := validateCalendarRange("start", calendarCoverageStart, "end", "bad"); err == nil {
t.Fatal("bad end accepted")
}
if _, _, err := validateCalendarRange("start", calendarCoverageEnd, "end", calendarCoverageStart); err == nil {
t.Fatal("reversed range accepted")
}
if got := calendarStringList([]string{" a ", "", "a", "b"}); len(got) != 2 {
t.Fatalf("deduplicated list=%#v", got)
}
want := time.Date(2026, 8, 17, 1, 0, 0, 0, time.UTC)
millis := want.UnixMilli()
for _, actual := range []any{
map[string]any{"value": calendarCoverageStart},
calendarCoverageStart,
strconv.FormatInt(millis, 10),
float64(millis), int64(millis), int(millis),
} {
if !calendarTimeEquivalent(actual, calendarCoverageStart) {
t.Fatalf("equivalent time rejected: %#v", actual)
}
}
for _, actual := range []any{math.NaN(), math.Inf(1), 1.5, struct{}{}} {
if calendarTimeEquivalent(actual, calendarCoverageStart) {
t.Fatalf("bad time accepted: %#v", actual)
}
}
if calendarTimeEquivalent(calendarCoverageStart, "bad") {
t.Fatal("bad expected time accepted")
}
base := map[string]any{"id": "event-1", "summary": "updated", "start": map[string]any{"dateTime": calendarCoverageStart}}
if err := verifyCalendarEvent(base, "wrong", nil); err == nil {
t.Fatal("wrong event id accepted")
}
if err := verifyCalendarEvent(base, "event-1", map[string]any{"unknown": "ignored"}); err != nil {
t.Fatalf("unknown verification field rejected: %v", err)
}
if err := verifyCalendarEvent(base, "event-1", map[string]any{"description": "x"}); err == nil {
t.Fatal("missing readback field accepted")
}
if err := verifyCalendarEvent(base, "event-1", map[string]any{"summary": "wrong"}); err == nil {
t.Fatal("mismatched readback field accepted")
}
if err := verifyCalendarEvent(base, "event-1", map[string]any{"startDateTime": calendarCoverageEnd}); err == nil {
t.Fatal("mismatched readback time accepted")
}
item := shortcut.Shortcut{Description: "description", Intent: "intent"}
finalizeCalendarShortcut(&item, calendarObjectResult("result"), calendarCursorPagination())
if item.Contract.Interface == nil || item.Contract.Result == nil || item.Contract.Pagination == nil {
t.Fatalf("finalized shortcut=%#v", item)
}
pageShortcut := EventGet
pageShortcut.Execute = func(rt *shortcut.RuntimeContext) error {
return outputCalendarPage(rt, map[string]any{"count": 0}, calendarPageEvidence{})
}
if err := runCalendarCoverage(t, pageShortcut, &calendarCoverageCaller{responses: map[string][]string{}}, "--event", "event-1"); err == nil {
t.Fatal("unknown pagination was emitted")
}
pageShortcut.Execute = func(rt *shortcut.RuntimeContext) error {
return outputCalendarPage(rt, map[string]any{"count": 0}, calendarPageEvidence{Known: true, NextCursor: "cursor"})
}
if err := runCalendarCoverage(t, pageShortcut, &calendarCoverageCaller{responses: map[string][]string{}}, "--event", "event-1"); err == nil {
t.Fatal("inconsistent pagination was emitted")
}
legacyPage := EventGet
legacyPage.OutputRollout = output.RolloutLegacyOnly
legacyPage.Execute = func(rt *shortcut.RuntimeContext) error {
return outputCalendarPage(rt, map[string]any{"count": 0}, calendarPageEvidence{Known: true})
}
if err := runCalendarCoverage(t, legacyPage, &calendarCoverageCaller{responses: map[string][]string{}}, "--event", "event-1"); err != nil {
t.Fatalf("legacy pagination output: %v", err)
}
unifiedPage := EventGet
unifiedPage.OutputRollout = output.RolloutUnifiedActive
cmd := corecmd.New(shortcut.FromShortcut(unifiedPage))
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
var stdout bytes.Buffer
cmd.SetOut(&stdout)
rt := shortcut.RuntimeContextForTest(cmd, unifiedPage)
if err := outputCalendarPage(rt, map[string]any{"count": 1, "items": []any{map[string]any{"id": "item-1"}}}, calendarPageEvidence{Known: true, HasMore: true, NextCursor: "cursor-2"}); err != nil {
t.Fatalf("store unified pagination result: %v", err)
}
if code, emitted, err := output.EmitStoredResult(cmd); err != nil || !emitted || code != 0 {
t.Fatalf("emit unified pagination result: code=%d emitted=%v err=%v", code, emitted, err)
}
var envelope struct {
Data map[string]any `json:"data"`
Meta struct {
Pagination *struct {
EndpointExhausted bool `json:"endpoint_exhausted"`
NextToken string `json:"next_token"`
} `json:"pagination"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode unified pagination output: %v\n%s", err, stdout.String())
}
for _, field := range []string{"hasMore", "nextCursor"} {
if _, exists := envelope.Data[field]; exists {
t.Fatalf("pagination field %q leaked into business data: %s", field, stdout.String())
}
}
if envelope.Data["complete"] != false {
t.Fatalf("business completeness=%#v, want false: %s", envelope.Data["complete"], stdout.String())
}
if envelope.Meta.Pagination == nil || envelope.Meta.Pagination.EndpointExhausted || envelope.Meta.Pagination.NextToken != "cursor-2" {
t.Fatalf("pagination meta=%+v: %s", envelope.Meta.Pagination, stdout.String())
}
}
func TestCrossPlatformCoverageCalendarEventGetSearchAndSuggestion(t *testing.T) {
get := &calendarCoverageCaller{responses: map[string][]string{"get_calendar_detail": {`{"success":true,"result":{"id":"event-1"}}`}}}
if err := runCalendarCoverage(t, EventGet, get, "--event", "event-1", "--calendar-id", "primary"); err != nil {
t.Fatal(err)
}
getFailure := &calendarCoverageCaller{responses: map[string][]string{"get_calendar_detail": {"__ERROR__"}}}
if err := runCalendarCoverage(t, EventGet, getFailure, "--event", "event-1"); err == nil {
t.Fatal("get transport failure accepted")
}
search := &calendarCoverageCaller{responses: map[string][]string{"list_calendar_events": {`{"success":true,"result":{"events":[{"id":"event-1","summary":"Needle","description":"body","location":"room"},{"id":"event-2","summary":"other"}],"hasMore":true,"nextCursor":"next"}}`}}}
if err := runCalendarCoverage(t, EventSearch, search, "--query", "needle", "--start", calendarCoverageStart, "--end", calendarCoverageEnd, "--calendar-id", "primary", "--cursor", "cursor", "--limit", "2"); err != nil {
t.Fatal(err)
}
for name, response := range map[string]string{
"call": "__ERROR__",
"collection": `{"success":true,"result":{"hasMore":false}}`,
"item": `{"success":true,"result":{"events":[{"summary":"x"}],"hasMore":false}}`,
"pagination-bad": `{"success":true,"result":{"events":[],"hasMore":"bad"}}`,
"pagination": `{"success":true,"result":{"events":[]}}`,
} {
t.Run("search-"+name, func(t *testing.T) {
caller := &calendarCoverageCaller{responses: map[string][]string{"list_calendar_events": {response}}}
if err := runCalendarCoverage(t, EventSearch, caller, "--query", "x"); err == nil {
t.Fatal("bad search response accepted")
}
})
}
suggestion := &calendarCoverageCaller{responses: map[string][]string{"list_suggested_event_times": {`{"success":true,"result":{"recommendEventTimes":[{"startTime":"2026-08-17T09:00:00+08:00","endTime":"2026-08-17T10:00:00+08:00"}]}}`}}}
if err := runCalendarCoverage(t, Suggestion, suggestion, "--users", " user-1,user-1 ", "--duration", "30", "--start", calendarCoverageStart, "--end", calendarCoverageEnd, "--timezone", "Asia/Shanghai"); err != nil {
t.Fatal(err)
}
for name, args := range map[string][]string{
"duration": {"--users", "u", "--duration", "0"},
"range-pair": {"--users", "u", "--start", calendarCoverageStart},
"range-order": {"--users", "u", "--start", calendarCoverageEnd, "--end", calendarCoverageStart},
} {
t.Run("suggestion-"+name, func(t *testing.T) {
if err := runCalendarCoverage(t, Suggestion, &calendarCoverageCaller{responses: map[string][]string{}}, args...); err == nil {
t.Fatal("bad suggestion arguments accepted")
}
})
}
emptyUsers := Suggestion
for index := range emptyUsers.Flags {
if emptyUsers.Flags[index].Name == "users" {
emptyUsers.Flags[index].Required = false
}
}
if err := runCalendarCoverage(t, emptyUsers, &calendarCoverageCaller{responses: map[string][]string{}}); err == nil {
t.Fatal("empty suggestion users accepted")
}
for name, response := range map[string]string{
"call": "__ERROR__",
"collection": `{"success":true,"result":{}}`,
"projection": `{"success":true,"result":{"recommendEventTimes":[{"endTime":"x"}]}}`,
"missing-end": `{"success":true,"result":{"recommendEventTimes":[{"startTime":"x"}]}}`,
} {
t.Run("suggestion-response-"+name, func(t *testing.T) {
caller := &calendarCoverageCaller{responses: map[string][]string{"list_suggested_event_times": {response}}}
if err := runCalendarCoverage(t, Suggestion, caller, "--users", "u"); err == nil {
t.Fatal("bad suggestion response accepted")
}
})
}
}
func TestCrossPlatformCoverageCalendarCreateAndRSVPBranches(t *testing.T) {
attendees := make([]string, 501)
for index := range attendees {
attendees[index] = "u" + strconv.Itoa(index)
}
for name, args := range map[string][]string{
"title": {"--title", strings.Repeat("x", 2049), "--start", calendarCoverageStart, "--end", calendarCoverageEnd},
"desc": {"--title", "x", "--desc", strings.Repeat("x", 5001), "--start", calendarCoverageStart, "--end", calendarCoverageEnd},
"range": {"--title", "x", "--start", calendarCoverageEnd, "--end", calendarCoverageStart},
"attendees": {"--title", "x", "--attendees", strings.Join(attendees, ","), "--start", calendarCoverageStart, "--end", calendarCoverageEnd},
} {
t.Run("create-args-"+name, func(t *testing.T) {
if err := runCalendarCoverage(t, EventCreate, &calendarCoverageCaller{responses: map[string][]string{}}, args...); err == nil {
t.Fatal("bad create args accepted")
}
})
}
directCases := []map[string]string{
{"title": strings.Repeat("x", 2049), "start": calendarCoverageStart, "end": calendarCoverageEnd},
{"title": "x", "desc": strings.Repeat("x", 5001), "start": calendarCoverageStart, "end": calendarCoverageEnd},
{"title": "x", "start": calendarCoverageEnd, "end": calendarCoverageStart},
{"title": "x", "attendees": strings.Join(attendees, ","), "start": calendarCoverageStart, "end": calendarCoverageEnd},
}
for index, values := range directCases {
rt := calendarRuntimeForTest(t, EventCreate, values)
if _, _, err := calendarCreateParams(rt); err == nil {
t.Fatalf("direct invalid create case %d accepted", index)
}
}
if err := EventCreate.Execute(calendarRuntimeForTest(t, EventCreate, directCases[0])); err == nil {
t.Fatal("create execute accepted invalid params")
}
if err := runCalendarCoverage(t, EventCreate, &calendarCoverageCaller{responses: map[string][]string{}}, "--title", "x", "--start", calendarCoverageStart, "--end", calendarCoverageEnd, "--dry-run", "--yes"); err != nil {
t.Fatalf("create dry-run: %v", err)
}
for name, responses := range map[string]map[string][]string{
"write-call": {"create_calendar_event": {"__ERROR__"}},
"write-shape": {"create_calendar_event": {`{"result":{"id":"event-1"}}`}},
"missing-id": {"create_calendar_event": {`{"success":true,"result":{}}`}},
"read-call": {"create_calendar_event": {`{"success":true,"result":{"id":"event-1"}}`}, "get_calendar_detail": {"__ERROR__"}},
"read-shape": {"create_calendar_event": {`{"success":true,"result":{"id":"event-1"}}`}, "get_calendar_detail": {`{"success":true,"result":{}}`}},
"read-mismatch": {"create_calendar_event": {`{"success":true,"result":{"id":"event-1"}}`}, "get_calendar_detail": {`{"success":true,"result":{"id":"event-1","summary":"wrong","start":{"dateTime":"2026-08-17T09:00:00+08:00"},"end":{"dateTime":"2026-08-17T10:00:00+08:00"}}}`}},
} {
t.Run("create-"+name, func(t *testing.T) {
caller := &calendarCoverageCaller{responses: responses}
if err := runCalendarCoverage(t, EventCreate, caller, "--title", "x", "--start", calendarCoverageStart, "--end", calendarCoverageEnd, "--calendar-id", "primary", "--yes"); err == nil {
t.Fatal("bad create path accepted")
}
})
}
preflight := `{"success":true,"result":{"id":"event-1"}}`
if err := runCalendarCoverage(t, RSVP, &calendarCoverageCaller{responses: map[string][]string{"get_calendar_detail": {preflight}}}, "--event", "event-1", "--status", "accept", "--dry-run", "--yes"); err != nil {
t.Fatalf("rsvp dry-run: %v", err)
}
for name, responses := range map[string]map[string][]string{
"preflight-call": {"get_calendar_detail": {"__ERROR__"}},
"preflight-shape": {"get_calendar_detail": {`{"success":true,"result":{}}`}},
"write-call": {"get_calendar_detail": {preflight}, "respond": {"__ERROR__"}},
"write-shape": {"get_calendar_detail": {preflight}, "respond": {`{"result":{}}`}},
"read-call": {"get_calendar_detail": {preflight, "__ERROR__"}, "respond": {`{"success":true}`}},
"read-shape": {"get_calendar_detail": {preflight, `{"success":true,"result":{}}`}, "respond": {`{"success":true}`}},
"read-mismatch": {"get_calendar_detail": {preflight, `{"success":true,"result":{"id":"event-1","responseStatus":"declined"}}`}, "respond": {`{"success":true}`}},
"write-mismatch": {"get_calendar_detail": {preflight, preflight}, "respond": {`{"success":true,"result":{"status":"declined"}}`}},
} {
t.Run("rsvp-"+name, func(t *testing.T) {
if err := runCalendarCoverage(t, RSVP, &calendarCoverageCaller{responses: responses}, "--event", "event-1", "--status", "accept", "--calendar-id", "primary", "--yes"); err == nil {
t.Fatal("bad RSVP path accepted")
}
})
}
for name, write := range map[string]string{"readback": `{"success":true}`, "terminal": `{"success":true,"result":{"status":"accepted"}}`} {
t.Run("rsvp-success-"+name, func(t *testing.T) {
read := preflight
if name == "readback" {
read = `{"success":true,"result":{"id":"event-1","responseStatus":"accepted"}}`
}
caller := &calendarCoverageCaller{responses: map[string][]string{"get_calendar_detail": {preflight, read}, "respond": {write}}}
if err := runCalendarCoverage(t, RSVP, caller, "--event", "event-1", "--status", "accept", "--yes"); err != nil {
t.Fatal(err)
}
})
}
}
func TestCrossPlatformCoverageCalendarUpdateStages(t *testing.T) {
for name, args := range map[string][]string{
"none": {"--event", "event-1", "--yes"},
"pair": {"--event", "event-1", "--start", calendarCoverageStart, "--yes"},
"range": {"--event", "event-1", "--start", calendarCoverageEnd, "--end", calendarCoverageStart, "--yes"},
"overlap": {"--event", "event-1", "--add-attendees", "u", "--remove-attendees", "u", "--yes"},
} {
t.Run("args-"+name, func(t *testing.T) {
if err := runCalendarCoverage(t, EventUpdate, &calendarCoverageCaller{responses: map[string][]string{}}, args...); err == nil {
t.Fatal("bad update args accepted")
}
})
}
preflight := `{"success":true,"result":{"id":"event-1"}}`
if err := runCalendarCoverage(t, EventUpdate, &calendarCoverageCaller{responses: map[string][]string{"get_calendar_detail": {preflight}}}, "--event", "event-1", "--title", "updated", "--add-attendees", "u-add", "--remove-attendees", "u-remove", "--dry-run", "--yes"); err != nil {
t.Fatalf("update dry-run: %v", err)
}
success := map[string][]string{
"get_calendar_detail": {preflight, calendarCoverageEvent},
"update_calendar_event": {`{"success":true}`},
"remove_calendar_participant": {`{"success":true}`},
"add_calendar_participant": {`{"success":true}`},
"get_calendar_participants": {`{"success":true,"result":{"attendees":[{"displayName":"added","userId":"u-add"}]}}`},
}
if err := runCalendarCoverage(t, EventUpdate, &calendarCoverageCaller{responses: success}, "--event", "event-1", "--title", "updated", "--desc", "description", "--start", calendarCoverageStart, "--end", calendarCoverageEnd, "--timezone", "Asia/Shanghai", "--location", "room", "--free-busy", "busy", "--add-attendees", "u-add", "--remove-attendees", "u-remove", "--calendar-id", "primary", "--yes"); err != nil {
t.Fatalf("full update: %v", err)
}
type updateFailure struct {
name string
responses map[string][]string
args []string
}
baseArgs := []string{"--event", "event-1", "--title", "updated", "--yes"}
cases := []updateFailure{
{"preflight-call", map[string][]string{"get_calendar_detail": {"__ERROR__"}}, baseArgs},
{"preflight-shape", map[string][]string{"get_calendar_detail": {`{"success":true,"result":{}}`}}, baseArgs},
{"update-call", map[string][]string{"get_calendar_detail": {preflight}, "update_calendar_event": {"__ERROR__"}}, baseArgs},
{"update-shape", map[string][]string{"get_calendar_detail": {preflight}, "update_calendar_event": {`{"result":{}}`}}, baseArgs},
{"remove-call", map[string][]string{"get_calendar_detail": {preflight}, "remove_calendar_participant": {"__ERROR__"}}, []string{"--event", "event-1", "--remove-attendees", "u", "--yes"}},
{"remove-shape", map[string][]string{"get_calendar_detail": {preflight}, "remove_calendar_participant": {`{"result":{}}`}}, []string{"--event", "event-1", "--remove-attendees", "u", "--yes"}},
{"add-call", map[string][]string{"get_calendar_detail": {preflight}, "add_calendar_participant": {"__ERROR__"}}, []string{"--event", "event-1", "--add-attendees", "u", "--yes"}},
{"add-shape", map[string][]string{"get_calendar_detail": {preflight}, "add_calendar_participant": {`{"result":{}}`}}, []string{"--event", "event-1", "--add-attendees", "u", "--yes"}},
{"verify-call", map[string][]string{"get_calendar_detail": {preflight, "__ERROR__"}, "update_calendar_event": {`{"success":true}`}}, baseArgs},
{"verify-shape", map[string][]string{"get_calendar_detail": {preflight, `{"success":true,"result":{}}`}, "update_calendar_event": {`{"success":true}`}}, baseArgs},
{"verify-field", map[string][]string{"get_calendar_detail": {preflight, `{"success":true,"result":{"id":"event-1","summary":"wrong"}}`}, "update_calendar_event": {`{"success":true}`}}, baseArgs},
{"attendee-call", map[string][]string{"get_calendar_detail": {preflight, preflight}, "add_calendar_participant": {`{"success":true}`}, "get_calendar_participants": {"__ERROR__"}}, []string{"--event", "event-1", "--add-attendees", "u", "--yes"}},
{"attendee-shape", map[string][]string{"get_calendar_detail": {preflight, preflight}, "add_calendar_participant": {`{"success":true}`}, "get_calendar_participants": {`{"success":true,"result":{}}`}}, []string{"--event", "event-1", "--add-attendees", "u", "--yes"}},
{"attendee-missing", map[string][]string{"get_calendar_detail": {preflight, preflight}, "add_calendar_participant": {`{"success":true}`}, "get_calendar_participants": {`{"success":true,"result":{"attendees":[]}}`}}, []string{"--event", "event-1", "--add-attendees", "u", "--yes"}},
{"attendee-present", map[string][]string{"get_calendar_detail": {preflight, preflight}, "remove_calendar_participant": {`{"success":true}`}, "get_calendar_participants": {`{"success":true,"result":{"attendees":[{"displayName":"still","userId":"u"}]}}`}}, []string{"--event", "event-1", "--remove-attendees", "u", "--yes"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := runCalendarCoverage(t, EventUpdate, &calendarCoverageCaller{responses: tc.responses}, tc.args...); err == nil {
t.Fatal("bad update path accepted")
}
})
}
if err := calendarStageError("stage", []string{"done"}, nil); err == nil || !strings.Contains(err.Error(), "done") {
t.Fatalf("stage error missing ledger: %v", err)
}
}
func TestCrossPlatformCoverageCalendarLegacyStrictBranches(t *testing.T) {
for name, args := range map[string][]string{
"agenda-order": {"--start", calendarCoverageEnd, "--end", calendarCoverageStart},
"room-limit": {"--limit", "bad"},
"room-page": {"--page", "-1"},
"room-start": {"--start", "bad"},
"room-end": {"--start", calendarCoverageStart, "--end", "bad"},
"room-order": {"--start", calendarCoverageEnd, "--end", calendarCoverageStart},
} {
t.Run(name, func(t *testing.T) {
declaration := EventList
if strings.HasPrefix(name, "room-") {
declaration = RoomFind
}
if err := runCalendarCoverage(t, declaration, &calendarCoverageCaller{responses: map[string][]string{}}, args...); err == nil {
t.Fatal("bad input accepted")
}
})
}
roomExecuteValidation := RoomFind
roomExecuteValidation.Validate = nil
if err := runCalendarCoverage(t, roomExecuteValidation, &calendarCoverageCaller{responses: map[string][]string{}}, "--limit", "bad"); err == nil {
t.Fatal("room execute validation accepted bad limit")
}
if err := runCalendarCoverage(t, EventSearch, &calendarCoverageCaller{responses: map[string][]string{}}, "--query", "x", "--limit", "0"); err == nil {
t.Fatal("search accepted bad limit")
}
if err := runCalendarCoverage(t, EventSearch, &calendarCoverageCaller{responses: map[string][]string{}}, "--query", "x", "--start", calendarCoverageEnd, "--end", calendarCoverageStart); err == nil {
t.Fatal("search accepted reversed range")
}
for name, declarationAndTool := range map[string]struct {
declaration shortcut.Shortcut
tool string
args []string
}{
"agenda": {EventList, "list_calendar_events", nil},
"room": {RoomFind, "query_available_meeting_room", []string{"--start", calendarCoverageStart, "--end", calendarCoverageEnd}},
"busy": {BusySearch, "query_busy_status", []string{"--users", "u", "--start", calendarCoverageStart, "--end", calendarCoverageEnd}},
} {
t.Run(name+"-call", func(t *testing.T) {
caller := &calendarCoverageCaller{responses: map[string][]string{declarationAndTool.tool: {"__ERROR__"}}}
if err := runCalendarCoverage(t, declarationAndTool.declaration, caller, declarationAndTool.args...); err == nil {
t.Fatal("transport failure accepted")
}
})
}
room := &calendarCoverageCaller{responses: map[string][]string{"query_available_meeting_room": {`{"success":true,"result":{"rooms":[{"id":"room-1","name":"room"}],"hasMore":true,"totalCount":1,"pageIndex":0,"pageSize":20}}`}}}
if err := runCalendarCoverage(t, RoomFind, room, "--group-id", "g", "--room-name", " room ", "--available"); err != nil {
t.Fatal(err)
}
for _, response := range []string{`{"success":true,"result":{}}`, `{"success":true,"result":{"rooms":[{"name":"missing id"}]}}`} {
caller := &calendarCoverageCaller{responses: map[string][]string{"query_available_meeting_room": {response}}}
if err := runCalendarCoverage(t, RoomFind, caller); err == nil {
t.Fatal("malformed room response accepted")
}
}
agendaProjection := &calendarCoverageCaller{responses: map[string][]string{"list_calendar_events": {`{"success":true,"result":{"events":[{"summary":"missing id"}],"hasMore":false}}`}}}
if err := runCalendarCoverage(t, EventList, agendaProjection); err == nil {
t.Fatal("agenda item without id accepted")
}
for name, response := range map[string]string{
"missing": `{"success":true,"result":{"busy":[{}]}}`,
"bad-list": `{"success":true,"result":{"busy":[{"scheduleItems":{}}]}}`,
"bad-item": `{"success":true,"result":{"busy":[{"scheduleItems":["bad"]}]}}`,
"bad-time": `{"success":true,"result":{"busy":[{"scheduleItems":[{"start":{"date":"2026-08-17"}}]}]}}`,
} {
t.Run("busy-"+name, func(t *testing.T) {
caller := &calendarCoverageCaller{responses: map[string][]string{"query_busy_status": {response}}}
if err := runCalendarCoverage(t, BusySearch, caller, "--users", "u", "--rooms", "r", "--start", calendarCoverageStart, "--end", calendarCoverageEnd); err == nil {
t.Fatal("bad busy response accepted")
}
})
}
validBusy := &calendarCoverageCaller{responses: map[string][]string{"query_busy_status": {`{"success":true,"result":{"busy":[{"scheduleItems":[{"start":{"dateTime":"2026-08-17T09:00:00+08:00"},"end":{"date":"2026-08-17"}}]}]}}`}}}
if err := runCalendarCoverage(t, BusySearch, validBusy, "--users", "u", "--rooms", "r", "--start", calendarCoverageStart, "--end", calendarCoverageEnd); err != nil {
t.Fatal(err)
}
if calendarBusyDateTime("raw") != "raw" {
t.Fatal("raw busy time was not preserved")
}
}
@@ -0,0 +1,329 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package calendar
import (
"context"
"encoding/json"
"errors"
"io"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type calendarCoverageCaller struct {
responses map[string][]string
history []string
arguments []map[string]any
}
func (caller *calendarCoverageCaller) CallTool(_ context.Context, _, tool string, arguments map[string]any) (*edition.ToolResult, error) {
caller.history = append(caller.history, tool)
caller.arguments = append(caller.arguments, arguments)
queue := caller.responses[tool]
if len(queue) == 0 {
return nil, errors.New("missing fake response for " + tool)
}
caller.responses[tool] = queue[1:]
if queue[0] == "__ERROR__" {
return nil, errors.New("injected calendar failure")
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil
}
func (*calendarCoverageCaller) Format() string { return "json" }
func (*calendarCoverageCaller) DryRun() bool { return false }
func (*calendarCoverageCaller) Fields() string { return "" }
func (*calendarCoverageCaller) JQ() string { return "" }
func runCalendarCoverage(t *testing.T, declaration shortcut.Shortcut, caller *calendarCoverageCaller, args ...string) error {
t.Helper()
helpers.InitDepsForTest(t, caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
ctx, _ := output.WithResultStore(context.Background())
root.SetContext(ctx)
service := &cobra.Command{Use: "calendar"}
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
root.AddCommand(service)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(append([]string{"calendar", declaration.Command}, args...))
return root.Execute()
}
func TestCrossPlatformCoverageCalendarListRequiresExplicitCollectionAndPagination(t *testing.T) {
var explicitEmpty map[string]any
if err := json.Unmarshal([]byte(`{"success":true,"result":{"events":[],"hasMore":false}}`), &explicitEmpty); err != nil {
t.Fatal(err)
}
events, page, err := eventListProject(explicitEmpty)
if err != nil || len(events) != 0 || !page.Known || page.HasMore {
t.Fatalf("explicit empty: events=%v page=%+v err=%v", events, page, err)
}
for name, payload := range map[string]string{
"missing collection": `{"success":true,"result":{"hasMore":false}}`,
"bad collection": `{"success":true,"result":{"events":{}}}`,
"bad item": `{"success":true,"result":{"events":["bad"],"hasMore":false}}`,
"empty item": `{"success":true,"result":{"events":[{}],"hasMore":false}}`,
"missing pagination": `{"success":true,"result":{"events":[]}}`,
"missing next cursor": `{"success":true,"result":{"events":[],"hasMore":true}}`,
} {
t.Run(name, func(t *testing.T) {
var data map[string]any
if err := json.Unmarshal([]byte(payload), &data); err != nil {
t.Fatal(err)
}
if _, _, err := eventListProject(data); err == nil {
t.Fatalf("payload unexpectedly accepted: %s", payload)
}
})
}
var nextPage map[string]any
if err := json.Unmarshal([]byte(`{"success":true,"result":{"events":[{"id":"event-1","summary":"review"}],"hasMore":true,"nextCursor":"cursor-2"}}`), &nextPage); err != nil {
t.Fatal(err)
}
events, page, err = eventListProject(nextPage)
if err != nil || len(events) != 1 || events[0]["eventId"] != "event-1" || !page.HasMore || page.NextCursor != "cursor-2" {
t.Fatalf("next page projection: events=%#v page=%+v err=%v", events, page, err)
}
}
func TestCrossPlatformCoverageCalendarObjectsAndWritesFailClosed(t *testing.T) {
for name, payload := range map[string]map[string]any{
"empty": {},
"success false": {"success": false, "errorMsg": "denied"},
"nonterminal receipt": {"result": map[string]any{"id": "event-1"}},
} {
t.Run(name, func(t *testing.T) {
if _, err := requireCalendarWriteResponse(payload, "calendar/write"); err == nil {
t.Fatalf("payload unexpectedly accepted: %#v", payload)
}
})
}
if _, err := requireCalendarWriteResponse(map[string]any{"success": true, "result": map[string]any{"id": "event-1"}}, "calendar/write"); err != nil {
t.Fatalf("terminal write rejected: %v", err)
}
event, err := requireCalendarEvent(map[string]any{"success": true, "result": map[string]any{"id": "event-1", "summary": "review"}}, "calendar/get_calendar_detail", "event-1")
if err != nil || normalizeCalendarEvent(event)["eventId"] != "event-1" {
t.Fatalf("event normalization: event=%#v err=%v", event, err)
}
if _, err := requireCalendarEvent(map[string]any{"success": true, "result": map[string]any{"summary": "missing id"}}, "calendar/get_calendar_detail", "event-1"); err == nil {
t.Fatal("event without id unexpectedly accepted")
}
}
func TestCrossPlatformCoverageCalendarCreateReadsBackTerminalState(t *testing.T) {
caller := &calendarCoverageCaller{responses: map[string][]string{
"create_calendar_event": {`{"success":true,"result":{"id":"event-1"}}`},
"get_calendar_detail": {`{"success":true,"result":{"id":"event-1","summary":"review","start":{"dateTime":"2026-03-10T14:00:00+08:00"},"end":{"dateTime":"2026-03-10T15:00:00+08:00"}}}`},
}}
err := runCalendarCoverage(t, EventCreate, caller,
"--title", "review", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T15:00:00+08:00", "--yes")
if err != nil {
t.Fatal(err)
}
if got := strings.Join(caller.history, ","); got != "create_calendar_event,get_calendar_detail" {
t.Fatalf("history=%s", got)
}
noReceipt := &calendarCoverageCaller{responses: map[string][]string{"create_calendar_event": {`{"result":{"id":"event-2"}}`}}}
err = runCalendarCoverage(t, EventCreate, noReceipt,
"--title", "review", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T15:00:00+08:00", "--yes")
if err == nil || !strings.Contains(err.Error(), "success=true") {
t.Fatalf("non-terminal create error=%v", err)
}
}
func TestCrossPlatformCoverageCalendarAlignedContracts(t *testing.T) {
for _, declaration := range []shortcut.Shortcut{EventGet, EventCreate, EventUpdate, RSVP, EventSearch, Suggestion, RoomFind} {
if declaration.Contract.Empty() || declaration.Contract.Result == nil {
t.Errorf("%s missing contract/result", declaration.Command)
}
if strings.TrimSpace(declaration.Safety.Effect) == "" || strings.TrimSpace(declaration.Safety.Confirmation) == "" {
t.Errorf("%s missing safety", declaration.Command)
}
if declaration.OutputRollout != output.RolloutUnifiedActive {
t.Errorf("%s output rollout=%q", declaration.Command, declaration.OutputRollout)
}
}
if EventSearch.Contract.Pagination == nil || EventList.Contract.Pagination == nil {
t.Fatal("cursor commands must publish pagination contracts")
}
if EventCreate.Safety.Confirmation != "user_required" || EventUpdate.Safety.Confirmation != "user_required" || RSVP.Safety.Confirmation != "user_required" {
t.Fatal("calendar writes must require confirmation")
}
}
func TestCrossPlatformCoverageCalendarRoomFindPreservesPublishedFlags(t *testing.T) {
flags := make(map[string]shortcut.Flag, len(RoomFind.Flags))
for _, flag := range RoomFind.Flags {
flags[flag.Name] = flag
}
if flag := flags["available"]; flag.Type != shortcut.FlagBool || flag.Hidden {
t.Fatalf("available flag=%+v, want visible bool", flag)
}
for _, name := range []string{"limit", "page"} {
if flag := flags[name]; flag.Type != shortcut.FlagString || flag.Hidden {
t.Fatalf("%s flag=%+v, want visible string", name, flag)
}
}
caller := &calendarCoverageCaller{responses: map[string][]string{
"query_available_meeting_room": {`{"success":true,"result":{"rooms":[],"hasMore":false}}`},
}}
err := runCalendarCoverage(t, RoomFind, caller,
"--start", "2026-03-10T14:00:00+08:00",
"--end", "2026-03-10T15:00:00+08:00",
"--available", "--limit", "25", "--page", "2")
if err != nil {
t.Fatal(err)
}
if len(caller.arguments) != 1 {
t.Fatalf("calls=%d, want 1", len(caller.arguments))
}
arguments := caller.arguments[0]
if arguments["pageSize"] != "25" || arguments["pageIndex"] != "2" || arguments["needAvailable"] != true {
t.Fatalf("arguments=%#v", arguments)
}
invalid := &calendarCoverageCaller{responses: map[string][]string{}}
if err := runCalendarCoverage(t, RoomFind, invalid, "--limit", "101"); err == nil || !strings.Contains(err.Error(), "1-100") {
t.Fatalf("invalid limit error=%v", err)
}
if len(invalid.history) != 0 {
t.Fatalf("invalid input made calls: %v", invalid.history)
}
fixedNow := time.Date(2026, time.August, 17, 12, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60))
defaultParams, err := calendarAgendaParams(calendarRuntimeForTest(t, EventList, nil), fixedNow)
if err != nil {
t.Fatal(err)
}
if defaultParams["startTime"] != time.Date(2026, time.August, 17, 0, 0, 0, 0, fixedNow.Location()).UnixMilli() ||
defaultParams["endTime"] != time.Date(2026, time.August, 17, 23, 59, 59, 0, fixedNow.Location()).UnixMilli() {
t.Fatalf("agenda default adapter arguments=%#v", defaultParams)
}
allParams, err := calendarAgendaParams(calendarRuntimeForTest(t, EventList, map[string]string{
"start": calendarCoverageStart,
"end": calendarCoverageEnd,
"calendar-id": "primary",
"cursor": "cursor-1",
"limit": "7",
}), fixedNow)
if err != nil {
t.Fatal(err)
}
if allParams["calendarId"] != "primary" || allParams["cursor"] != "cursor-1" || allParams["limit"] != 7 {
t.Fatalf("agenda optional adapter arguments=%#v", allParams)
}
for name, values := range map[string]map[string]string{
"invalid-start": {"start": "bad"},
"invalid-end": {"end": "bad"},
"reversed": {"start": calendarCoverageEnd, "end": calendarCoverageStart},
} {
t.Run(name, func(t *testing.T) {
if _, err := calendarAgendaParams(calendarRuntimeForTest(t, EventList, values), fixedNow); err == nil {
t.Fatal("invalid agenda adapter input was accepted")
}
})
}
}
func TestCrossPlatformCoverageCalendarAgendaSchemaUsesExplicitRuntimeAdapter(t *testing.T) {
properties := make(map[string]string, len(EventList.Contract.Parameters))
for _, parameter := range EventList.Contract.Parameters {
properties[parameter.Name] = parameter.Property
}
if properties["start"] != "start" || properties["end"] != "end" {
t.Fatalf("agenda time properties=%#v", properties)
}
for _, flag := range EventList.Flags {
if flag.Name == "limit" && flag.Default != "" {
t.Fatalf("agenda limit default=%q, want published empty default", flag.Default)
}
}
caller := &calendarCoverageCaller{responses: map[string][]string{
"list_calendar_events": {`{"success":true,"result":{"events":[],"hasMore":false}}`},
}}
if err := runCalendarCoverage(t, EventList, caller,
"--start", calendarCoverageStart,
"--end", calendarCoverageEnd,
); err != nil {
t.Fatal(err)
}
if len(caller.arguments) != 1 {
t.Fatalf("calls=%d, want 1", len(caller.arguments))
}
if _, ok := caller.arguments[0]["limit"]; ok {
t.Fatalf("unset limit unexpectedly sent: %#v", caller.arguments[0])
}
start, err := parseMillis("start", calendarCoverageStart)
if err != nil {
t.Fatal(err)
}
end, err := parseMillis("end", calendarCoverageEnd)
if err != nil {
t.Fatal(err)
}
if caller.arguments[0]["startTime"] != start || caller.arguments[0]["endTime"] != end {
t.Fatalf("agenda adapter arguments=%#v, published properties=%#v", caller.arguments[0], properties)
}
if _, leaked := caller.arguments[0][properties["start"]]; leaked {
t.Fatalf("published composite property leaked into RPC arguments: %#v", caller.arguments[0])
}
if _, leaked := caller.arguments[0][properties["end"]]; leaked {
t.Fatalf("published composite property leaked into RPC arguments: %#v", caller.arguments[0])
}
invalid := &calendarCoverageCaller{responses: map[string][]string{}}
if err := runCalendarCoverage(t, EventList, invalid, "--limit", "101"); err == nil || !strings.Contains(err.Error(), "1") {
t.Fatalf("invalid limit error=%v", err)
}
if len(invalid.history) != 0 {
t.Fatalf("invalid input made calls: %v", invalid.history)
}
}
func TestCrossPlatformCoverageCalendarAttendeeProjectionAcceptsStableUserID(t *testing.T) {
attendees, err := attendeeListProject(map[string]any{
"success": true,
"result": map[string]any{
"attendees": []any{map[string]any{
"userId": "user-1",
"responseStatus": "accepted",
}},
},
})
if err != nil {
t.Fatalf("userId-only attendee rejected: %v", err)
}
if len(attendees) != 1 || attendees[0]["userId"] != "user-1" {
t.Fatalf("attendees=%#v", attendees)
}
if _, err := attendeeListProject(map[string]any{
"success": true,
"result": map[string]any{
"attendees": []any{map[string]any{"responseStatus": "accepted"}},
},
}); err == nil {
t.Fatal("attendee without displayName or userId was accepted")
}
}
+490
View File
@@ -0,0 +1,490 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package calendar
import (
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
"time"
"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/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const calendarCompositeReason = "Reviewed Calendar Shortcut composite: the executable CLI owns strict response validation, truthful pagination, optional multi-step writes, read-back verification, and confirmation; no single MCP interface represents the complete command contract."
func calendarContract(command, description, useWhen string, avoidWhen, examples []string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params ...contract.ParamDecl) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
cliPath := "calendar " + command
return corecmd.ContractDecl{
Description: description,
Parameters: params,
Result: result,
Pagination: pagination,
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeComposite,
Availability: contract.InterfaceAvailable,
Reason: calendarCompositeReason,
},
Selection: contract.SelectionSpec{
AgentSummary: description,
UseWhen: []string{useWhen},
AvoidWhen: avoidWhen,
Examples: examples,
},
Identity: contract.ToolIdentitySpec{
ProductID: "calendar",
Name: name,
CanonicalPath: "calendar." + name,
CLIPath: cliPath,
PrimaryCLIPath: cliPath,
},
}
}
func calendarObjectResult(description string, outcomes ...contract.ResultOutcome) *contract.ResultSpec {
if len(outcomes) == 0 {
outcomes = []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}
}
return &contract.ResultSpec{
Outcomes: outcomes,
DataSchema: json.RawMessage(fmt.Sprintf(
`{"type":"object","description":%q,"additionalProperties":true}`,
description,
)),
}
}
func calendarCollectionResult(collection, description string) *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(fmt.Sprintf(
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"本页有效业务记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"Calendar 业务条目","additionalProperties":true}},"complete":{"type":"boolean","description":"服务端分页证据是否证明结果已完整"}},"required":["count",%q,"complete"],"additionalProperties":true}`,
description, collection, description, collection,
)),
}
}
func calendarCursorPagination() *contract.PaginationSpec {
return &contract.PaginationSpec{
Kind: contract.PaginationKindCursor,
CursorParameter: "cursor",
MetaPath: contract.PaginationMetaPath,
EndpointExhaustedPath: contract.PaginationExhaustedPath,
NextTokenPath: contract.PaginationNextTokenPath,
}
}
func calendarReadSafety() contract.SafetySpec {
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
}
func calendarWriteSafety() contract.SafetySpec {
return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"}
}
func calendarResponseError(operation, reason, message string) error {
return apperrors.NewAPI(message,
apperrors.WithOperation(operation),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("response_validation"),
apperrors.WithRetryable(false),
apperrors.WithReason(reason),
)
}
func requireCalendarResponse(data map[string]any, operation string) (map[string]any, error) {
if len(data) == 0 {
return nil, calendarResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明操作成功或结果确实为空")
}
for _, candidate := range calendarContainers(data) {
value, present := candidate["success"]
if !present {
continue
}
success, ok := value.(bool)
if !ok {
return nil, calendarResponseError(operation, "malformed_success", "响应 success 字段不是布尔值")
}
if !success {
message := firstCalendarString(candidate, "errorMsg", "errorMessage", "message", "error")
if message == "" {
message = "服务明确返回 success=false"
}
return nil, calendarResponseError(operation, "remote_failure", message)
}
}
return data, nil
}
func requireCalendarWriteResponse(data map[string]any, operation string) (map[string]any, error) {
data, err := requireCalendarResponse(data, operation)
if err != nil {
return nil, err
}
for _, candidate := range calendarContainers(data) {
if success, ok := candidate["success"].(bool); ok && success {
return data, nil
}
}
return nil, calendarResponseError(operation, "missing_terminal_success", "写操作响应没有 success=true 终态证据;远端效果未知")
}
func calendarContainers(data map[string]any) []map[string]any {
if data == nil {
return nil
}
containers := []map[string]any{data}
seen := map[mapIdentity]bool{}
for depth, index := 0, 0; index < len(containers) && depth < 12; index, depth = index+1, depth+1 {
candidate := containers[index]
for _, key := range []string{"content", "result", "data", "event"} {
if nested, ok := candidate[key].(map[string]any); ok {
identity := mapIdentity{parent: index, key: key}
if !seen[identity] {
seen[identity] = true
containers = append(containers, nested)
}
}
}
}
return containers
}
type mapIdentity struct {
parent int
key string
}
func requireCalendarObject(data map[string]any, operation string) (map[string]any, error) {
data, err := requireCalendarResponse(data, operation)
if err != nil {
return nil, err
}
containers := calendarContainers(data)
for index := len(containers) - 1; index >= 0; index-- {
candidate := containers[index]
if len(candidate) == 0 || calendarOnlyEnvelopeFields(candidate) {
continue
}
return candidate, nil
}
return nil, calendarResponseError(operation, "missing_business_result", "响应没有可验证的业务对象")
}
func calendarOnlyEnvelopeFields(candidate map[string]any) bool {
for key := range candidate {
switch key {
case "success", "result", "data", "content", "message", "errorMsg", "errorMessage":
default:
return false
}
}
return true
}
func requireCalendarCollection(data map[string]any, operation string, keys ...string) ([]any, map[string]any, error) {
data, err := requireCalendarResponse(data, operation)
if err != nil {
return nil, nil, err
}
for _, container := range calendarContainers(data) {
for _, key := range keys {
value, present := container[key]
if !present {
continue
}
items, ok := value.([]any)
if !ok {
return nil, nil, calendarResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 字段不是数组", key))
}
if err := validateCalendarCollectionItems(items, operation, key); err != nil {
return nil, nil, err
}
return items, container, nil
}
}
for _, wrapper := range []string{"result", "data"} {
if value, present := data[wrapper]; present {
if items, ok := value.([]any); ok {
if err := validateCalendarCollectionItems(items, operation, wrapper); err != nil {
return nil, nil, err
}
return items, data, nil
}
}
}
return nil, nil, calendarResponseError(operation, "missing_collection", "响应缺少声明的业务数组;不能把缺字段、内部错误或协议漂移投影成空结果")
}
func validateCalendarCollectionItems(items []any, operation, key string) error {
for index, item := range items {
object, ok := item.(map[string]any)
if !ok || len(object) == 0 {
return calendarResponseError(operation, "malformed_collection_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", key, index))
}
}
return nil
}
func projectCalendarRows(items []any, operation string, aliases map[string][]string, requiredAny ...string) ([]map[string]any, error) {
rows := make([]map[string]any, 0, len(items))
for index, item := range items {
source := item.(map[string]any)
row := make(map[string]any)
for canonical, candidates := range aliases {
for _, candidate := range candidates {
if value, ok := source[candidate]; ok && value != nil {
row[canonical] = value
break
}
}
}
valid := len(requiredAny) == 0 && len(row) > 0
for _, required := range requiredAny {
if value, ok := row[required]; ok && !calendarEmptyValue(value) {
valid = true
break
}
}
if !valid {
return nil, calendarResponseError(operation, "malformed_collection_item", fmt.Sprintf("业务数组第 %d 项缺少可识别的标识或时间字段", index))
}
rows = append(rows, row)
}
return rows, nil
}
func calendarEmptyValue(value any) bool {
if value == nil {
return true
}
if text, ok := value.(string); ok {
return strings.TrimSpace(text) == ""
}
return false
}
type calendarPageEvidence struct {
Known bool
HasMore bool
NextCursor string
}
func calendarPagination(container map[string]any, operation string) (calendarPageEvidence, error) {
page := calendarPageEvidence{}
if container == nil {
return page, nil
}
if raw, present := container["hasMore"]; present {
value, ok := raw.(bool)
if !ok {
return page, calendarResponseError(operation, "malformed_pagination", "响应 hasMore 字段不是布尔值")
}
page.Known = true
page.HasMore = value
}
for _, key := range []string{"nextCursor", "nextToken", "pageToken"} {
raw, present := container[key]
if !present || raw == nil {
continue
}
value, ok := raw.(string)
if !ok {
return page, calendarResponseError(operation, "malformed_pagination", fmt.Sprintf("响应 %s 字段不是字符串", key))
}
page.Known = true
page.NextCursor = strings.TrimSpace(value)
break
}
if page.HasMore && page.NextCursor == "" {
return page, calendarResponseError(operation, "missing_next_cursor", "服务端返回 hasMore=true 但没有可续用的 nextCursor,结果不完整")
}
if page.Known && !page.HasMore && page.NextCursor != "" {
return page, calendarResponseError(operation, "inconsistent_pagination", "服务端返回 hasMore=false 但仍携带非空 nextCursor")
}
if page.NextCursor != "" {
page.HasMore = true
}
return page, nil
}
func addCalendarPagination(out map[string]any, page calendarPageEvidence) {
out["complete"] = page.Known && !page.HasMore
if page.Known {
out["hasMore"] = page.HasMore
}
if page.NextCursor != "" {
out["nextCursor"] = page.NextCursor
}
}
// outputCalendarPage keeps legacy JSON compatibility while honoring the
// unified Result contract: cursor controls move to meta.pagination and are not
// duplicated into business data.
func outputCalendarPage(rt *shortcut.RuntimeContext, payload map[string]any, page calendarPageEvidence) error {
if !page.Known {
return calendarResponseError("calendar/pagination", "missing_pagination", "响应缺少分页终态证据")
}
addCalendarPagination(payload, page)
if !output.UsesUnifiedResult(rt.Command()) {
return rt.Output(payload)
}
pagination, err := output.NewPagination(!page.HasMore, page.NextCursor)
if err != nil {
return calendarResponseError("calendar/pagination", "invalid_pagination", err.Error())
}
business := make(map[string]any, len(payload))
for key, value := range payload {
switch key {
case "hasMore", "nextCursor":
default:
business[key] = value
}
}
meta := &output.Meta{Pagination: pagination}
if count, ok := payload["count"].(int); ok {
meta.Count = output.NewCount(count)
}
return output.StoreResult(rt.Command().Context(), output.Success(business, output.WithMeta(meta)))
}
func firstCalendarString(data map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func nestedCalendarString(data map[string]any, keys ...string) string {
for _, candidate := range calendarContainers(data) {
if value := firstCalendarString(candidate, keys...); value != "" {
return value
}
}
return ""
}
func validateCalendarRange(startName, startValue, endName, endValue string) (time.Time, time.Time, error) {
start, err := time.Parse(time.RFC3339, strings.TrimSpace(startValue))
if err != nil {
return time.Time{}, time.Time{}, apperrors.NewValidation(fmt.Sprintf("--%s 时间格式无效,请使用 RFC3339/ISO-8601", startName))
}
end, err := time.Parse(time.RFC3339, strings.TrimSpace(endValue))
if err != nil {
return time.Time{}, time.Time{}, apperrors.NewValidation(fmt.Sprintf("--%s 时间格式无效,请使用 RFC3339/ISO-8601", endName))
}
if !end.After(start) {
return time.Time{}, time.Time{}, apperrors.NewValidation(fmt.Sprintf("--%s 必须晚于 --%s", endName, startName))
}
return start, end, nil
}
func calendarStringList(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" && !seen[value] {
seen[value] = true
out = append(out, value)
}
}
return out
}
func calendarEventID(event map[string]any) string {
return firstCalendarString(event, "eventId", "event_id", "id")
}
func calendarTimeEquivalent(actual any, expected string) bool {
want, err := time.Parse(time.RFC3339, expected)
if err != nil {
return false
}
if object, ok := actual.(map[string]any); ok {
for _, key := range []string{"dateTime", "date_time", "value", "timestamp"} {
if nested, exists := object[key]; exists {
return calendarTimeEquivalent(nested, expected)
}
}
}
switch value := actual.(type) {
case string:
if parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value)); err == nil {
return parsed.Equal(want)
}
millis, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return err == nil && time.UnixMilli(millis).Equal(want)
case float64:
return !math.IsNaN(value) && !math.IsInf(value, 0) && value == math.Trunc(value) && time.UnixMilli(int64(value)).Equal(want)
case int64:
return time.UnixMilli(value).Equal(want)
case int:
return time.UnixMilli(int64(value)).Equal(want)
default:
return false
}
}
func verifyCalendarEvent(event map[string]any, eventID string, requested map[string]any) error {
if got := calendarEventID(event); got == "" || got != eventID {
return calendarResponseError("calendar/get_calendar_detail", "readback_id_mismatch", "写后读回的 eventId 缺失或不一致")
}
aliases := map[string][]string{
"summary": {"summary", "title", "name"},
"description": {"description", "desc"},
"startDateTime": {"startDateTime", "start_time", "startTime", "start"},
"endDateTime": {"endDateTime", "end_time", "endTime", "end"},
"timeZone": {"timeZone", "timezone", "time_zone"},
"location": {"location", "locationName", "location_name"},
"freeBusy": {"freeBusy", "free_busy"},
}
for property, expected := range requested {
candidates := aliases[property]
if len(candidates) == 0 {
continue
}
var actual any
var present bool
for _, candidate := range candidates {
actual, present = event[candidate]
if present {
break
}
}
if !present {
return calendarResponseError("calendar/get_calendar_detail", "readback_field_missing", fmt.Sprintf("写后读回缺少字段 %s", property))
}
if property == "startDateTime" || property == "endDateTime" {
if !calendarTimeEquivalent(actual, fmt.Sprint(expected)) {
return calendarResponseError("calendar/get_calendar_detail", "readback_field_mismatch", fmt.Sprintf("写后读回字段 %s 与请求不一致", property))
}
continue
}
if strings.TrimSpace(fmt.Sprint(actual)) != strings.TrimSpace(fmt.Sprint(expected)) {
return calendarResponseError("calendar/get_calendar_detail", "readback_field_mismatch", fmt.Sprintf("写后读回字段 %s 与请求不一致", property))
}
}
return nil
}
func finalizeCalendarShortcut(item *shortcut.Shortcut, result *contract.ResultSpec, pagination *contract.PaginationSpec) {
item.OutputRollout = output.RolloutUnifiedActive
item.Contract.Result = result
item.Contract.Pagination = pagination
item.Contract.Selection.AgentSummary = item.Description
item.Contract.Selection.UseWhen = []string{item.Intent}
if item.Contract.Interface == nil {
item.Contract.Interface = &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: calendarCompositeReason}
}
}
@@ -0,0 +1,604 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package calendar
import (
"fmt"
"strings"
"time"
"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/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func calendarReadShortcut(command, description, intent, collection string, flags []shortcut.Flag, params []contract.ParamDecl, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut {
result := calendarObjectResult(description)
if collection != "" {
result = calendarCollectionResult(collection, description)
}
return shortcut.Shortcut{
OutputRollout: output.RolloutUnifiedActive,
Service: "calendar",
Command: command,
Product: "calendar",
Description: description,
Intent: intent,
Risk: shortcut.RiskRead,
Safety: calendarReadSafety(),
Contract: calendarContract(command, description, intent,
[]string{"需要未公开底层参数或原始 MCP 响应时改用对应原子命令;缺失业务对象或数组不是合法空结果"},
[]string{calendarExample(command)}, result, nil, params...),
Flags: flags,
Execute: execute,
}
}
func calendarWriteShortcut(command, description, intent, example string, flags []shortcut.Flag, params []contract.ParamDecl, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut {
return shortcut.Shortcut{
OutputRollout: output.RolloutUnifiedActive,
Service: "calendar",
Command: command,
Product: "calendar",
Description: description,
Intent: intent,
Risk: shortcut.RiskWrite,
Safety: calendarWriteSafety(),
Contract: calendarContract(command, description, intent,
[]string{"只需查看日程或尚未确认目标 eventId/影响范围时不要执行写操作"},
[]string{example}, calendarObjectResult(description, contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure), nil, params...),
Flags: flags,
Execute: execute,
}
}
func calendarExample(command string) string {
switch command {
case "+get":
return "dws calendar +get --event <EVENT_ID> --format json"
case "+search-event":
return `dws calendar +search-event --query "评审" --format json`
case "+suggestion":
return "dws calendar +suggestion --users <USER_ID> --duration 30 --format json"
default:
return "dws calendar " + command + " --format json"
}
}
// EventGet is the strict shortcut counterpart of calendar event get. A missing
// result object and a result object without an id are protocol failures.
var EventGet = calendarReadShortcut(
"+get", "严格获取日程详情",
"已知 eventId 时读取完整日程;把后端稳定 id 规范化为 eventId,并拒绝空响应、缺失对象或错误对象。",
"",
[]shortcut.Flag{
{Name: "event", Type: shortcut.FlagString, Required: true, Desc: "日程 eventId"},
{Name: "calendar-id", Type: shortcut.FlagString, Desc: "日历 ID(默认 primary)"},
},
[]contract.ParamDecl{{Name: "event", Property: "eventId"}, {Name: "calendar-id", Property: "calendarId"}},
func(rt *shortcut.RuntimeContext) error {
params := calendarEventTarget(rt)
data, err := rt.CallMCPData("calendar", "get_calendar_detail", params)
if err != nil {
return err
}
event, err := requireCalendarEvent(data, "calendar/get_calendar_detail", rt.Str("event"))
if err != nil {
return err
}
return rt.Output(map[string]any{"event": normalizeCalendarEvent(event)})
},
)
// EventCreate creates one event and requires an event id plus a successful
// get_calendar_detail read-back before reporting success.
var EventCreate = calendarWriteShortcut(
"+create", "创建日程并读回验证",
"创建单次日程,可同时传 userId 参会人和 roomId;只有创建响应给出 eventId 且详情读回与标题、起止时间一致时才报告成功。",
`dws calendar +create --title "项目评审" --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" --format json`,
[]shortcut.Flag{
{Name: "title", Type: shortcut.FlagString, Required: true, Desc: "日程标题,最多 2048 字符"},
{Name: "start", Type: shortcut.FlagString, Required: true, Desc: "开始时间 RFC3339/ISO-8601"},
{Name: "end", Type: shortcut.FlagString, Required: true, Desc: "结束时间 RFC3339/ISO-8601"},
{Name: "desc", Type: shortcut.FlagString, Desc: "日程描述,最多 5000 字符"},
{Name: "attendees", Type: shortcut.FlagStringSlice, Desc: "参会人 userId,最多 500 个"},
{Name: "rooms", Type: shortcut.FlagStringSlice, Desc: "会议室 roomId"},
{Name: "timezone", Type: shortcut.FlagString, Desc: "IANA 时区,如 Asia/Shanghai"},
{Name: "location", Type: shortcut.FlagString, Desc: "地点文本"},
{Name: "free-busy", Type: shortcut.FlagString, Desc: "忙闲状态", Enum: []string{"busy", "free"}},
{Name: "calendar-id", Type: shortcut.FlagString, Desc: "目标日历 ID(默认 primary)"},
},
[]contract.ParamDecl{
{Name: "title", Property: "summary"}, {Name: "start", Property: "startDateTime"}, {Name: "end", Property: "endDateTime"},
{Name: "desc", Property: "description"}, {Name: "attendees", Property: "attendees"}, {Name: "rooms", Property: "roomIds"},
{Name: "timezone", Property: "timeZone"}, {Name: "location", Property: "location"}, {Name: "free-busy", Property: "freeBusy"},
{Name: "calendar-id", Property: "calendarId"},
},
func(rt *shortcut.RuntimeContext) error {
params, verify, err := calendarCreateParams(rt)
if err != nil {
return err
}
if rt.DryRun() {
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "calendar/create_calendar_event", "arguments": params})
}
written, err := rt.CallMCPWriteDataStrict("calendar", "create_calendar_event", params)
if err != nil {
return err
}
written, err = requireCalendarWriteResponse(written, "calendar/create_calendar_event")
if err != nil {
return err
}
eventID := nestedCalendarString(written, "eventId", "event_id", "id")
if eventID == "" {
return calendarResponseError("calendar/create_calendar_event", "missing_created_id", "创建响应没有 eventId/id;远端效果未知")
}
readParams := map[string]any{"eventId": eventID}
if calendarID := rt.Str("calendar-id"); calendarID != "" {
readParams["calendarId"] = calendarID
}
readback, err := rt.CallMCPData("calendar", "get_calendar_detail", readParams)
if err != nil {
return err
}
event, err := requireCalendarEvent(readback, "calendar/get_calendar_detail", eventID)
if err != nil {
return err
}
if err := verifyCalendarEvent(event, eventID, verify); err != nil {
return err
}
return rt.Output(map[string]any{"success": true, "eventId": eventID, "verified": true, "event": normalizeCalendarEvent(event)})
},
)
// EventUpdate applies event fields and attendee changes as explicit stages.
// It never rolls back or hides a later-stage failure; errors name completed
// stages so callers can safely inspect state before retrying.
var EventUpdate = calendarWriteShortcut(
"+update", "分阶段更新日程并读回验证",
"已知 eventId 时更新标题、描述、完整起止时间或参会人;多阶段写不是事务,任一步失败都会列出已经完成的步骤,成功则读回字段和参会人集合。",
`dws calendar +update --event <EVENT_ID> --title "新标题" --format json`,
[]shortcut.Flag{
{Name: "event", Type: shortcut.FlagString, Required: true, Desc: "日程 eventId"},
{Name: "title", Type: shortcut.FlagString, Desc: "新标题"},
{Name: "desc", Type: shortcut.FlagString, Desc: "新描述"},
{Name: "start", Type: shortcut.FlagString, Desc: "新开始时间;必须与 --end 同时传"},
{Name: "end", Type: shortcut.FlagString, Desc: "新结束时间;必须与 --start 同时传"},
{Name: "timezone", Type: shortcut.FlagString, Desc: "IANA 时区"},
{Name: "location", Type: shortcut.FlagString, Desc: "新地点文本"},
{Name: "free-busy", Type: shortcut.FlagString, Desc: "忙闲状态", Enum: []string{"busy", "free"}},
{Name: "add-attendees", Type: shortcut.FlagStringSlice, Desc: "新增参会人 userId"},
{Name: "remove-attendees", Type: shortcut.FlagStringSlice, Desc: "移除参会人 userId"},
{Name: "calendar-id", Type: shortcut.FlagString, Desc: "日历 ID(默认 primary)"},
},
[]contract.ParamDecl{
{Name: "event", Property: "eventId"}, {Name: "title", Property: "summary"}, {Name: "desc", Property: "description"},
{Name: "start", Property: "startDateTime"}, {Name: "end", Property: "endDateTime"}, {Name: "timezone", Property: "timeZone"},
{Name: "location", Property: "location"}, {Name: "free-busy", Property: "freeBusy"},
{Name: "add-attendees", Property: "attendeesToAdd"}, {Name: "remove-attendees", Property: "attendeesToRemove"},
{Name: "calendar-id", Property: "calendarId"},
},
func(rt *shortcut.RuntimeContext) error {
fields, add, remove, err := calendarUpdateParams(rt)
if err != nil {
return err
}
target := calendarEventTarget(rt)
preflight, err := rt.CallMCPData("calendar", "get_calendar_detail", target)
if err != nil {
return err
}
if _, err := requireCalendarEvent(preflight, "calendar/get_calendar_detail", rt.Str("event")); err != nil {
return err
}
if rt.DryRun() {
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "calendar/update", "eventId": rt.Str("event"), "fields": fields, "addAttendees": add, "removeAttendees": remove})
}
completed := make([]string, 0, 3)
if len(fields) > 0 {
params := calendarEventTarget(rt)
for key, value := range fields {
params[key] = value
}
written, callErr := rt.CallMCPWriteDataStrict("calendar", "update_calendar_event", params)
if callErr != nil {
return calendarStageError("update_event", completed, callErr)
}
if _, callErr = requireCalendarWriteResponse(written, "calendar/update_calendar_event"); callErr != nil {
return calendarStageError("update_event", completed, callErr)
}
completed = append(completed, "update_event")
}
if len(remove) > 0 {
params := calendarEventTarget(rt)
params["attendeesToRemove"] = remove
written, callErr := rt.CallMCPWriteDataStrict("calendar", "remove_calendar_participant", params)
if callErr != nil {
return calendarStageError("remove_attendees", completed, callErr)
}
if _, callErr = requireCalendarWriteResponse(written, "calendar/remove_calendar_participant"); callErr != nil {
return calendarStageError("remove_attendees", completed, callErr)
}
completed = append(completed, "remove_attendees")
}
if len(add) > 0 {
params := calendarEventTarget(rt)
params["attendeesToAdd"] = add
written, callErr := rt.CallMCPWriteDataStrict("calendar", "add_calendar_participant", params)
if callErr != nil {
return calendarStageError("add_attendees", completed, callErr)
}
if _, callErr = requireCalendarWriteResponse(written, "calendar/add_calendar_participant"); callErr != nil {
return calendarStageError("add_attendees", completed, callErr)
}
completed = append(completed, "add_attendees")
}
readback, err := rt.CallMCPData("calendar", "get_calendar_detail", target)
if err != nil {
return calendarStageError("verify_event", completed, err)
}
event, err := requireCalendarEvent(readback, "calendar/get_calendar_detail", rt.Str("event"))
if err != nil {
return calendarStageError("verify_event", completed, err)
}
if err := verifyCalendarEvent(event, rt.Str("event"), fields); err != nil {
return calendarStageError("verify_event", completed, err)
}
if len(add)+len(remove) > 0 {
participants, callErr := rt.CallMCPData("calendar", "get_calendar_participants", target)
if callErr != nil {
return calendarStageError("verify_attendees", completed, callErr)
}
rows, callErr := attendeeListProject(participants)
if callErr != nil {
return calendarStageError("verify_attendees", completed, callErr)
}
if callErr := verifyCalendarAttendees(rows, add, remove); callErr != nil {
return calendarStageError("verify_attendees", completed, callErr)
}
}
return rt.Output(map[string]any{"success": true, "eventId": rt.Str("event"), "completedSteps": completed, "verified": true, "event": normalizeCalendarEvent(event)})
},
)
// RSVP maps familiar verbs onto the DingTalk responseStatus values. It always
// reads the event after the write; responseStatus is verified when the backend
// exposes it, otherwise success is explicitly labelled terminal-response-only.
var RSVP = calendarWriteShortcut(
"+rsvp", "响应日程邀请并读取终态",
"作为当前参会人接受、拒绝或暂定一个日程邀请;写响应必须非空且非失败,并在写后读取日程,绝不把空 ack 当成功。",
"dws calendar +rsvp --event <EVENT_ID> --status accept --format json",
[]shortcut.Flag{
{Name: "event", Type: shortcut.FlagString, Required: true, Desc: "日程 eventId"},
{Name: "status", Type: shortcut.FlagString, Required: true, Desc: "响应动作", Enum: []string{"accept", "decline", "tentative", "needs-action"}},
{Name: "calendar-id", Type: shortcut.FlagString, Desc: "日历 ID(默认 primary)"},
},
[]contract.ParamDecl{{Name: "event", Property: "eventId"}, {Name: "status", Property: "responseStatus"}, {Name: "calendar-id", Property: "calendarId"}},
func(rt *shortcut.RuntimeContext) error {
status := map[string]string{"accept": "accepted", "decline": "declined", "tentative": "tentative", "needs-action": "needsAction"}[rt.Str("status")]
target := calendarEventTarget(rt)
preflight, err := rt.CallMCPData("calendar", "get_calendar_detail", target)
if err != nil {
return err
}
if _, err := requireCalendarEvent(preflight, "calendar/get_calendar_detail", rt.Str("event")); err != nil {
return err
}
params := calendarEventTarget(rt)
params["responseStatus"] = status
if rt.DryRun() {
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "calendar/respond", "arguments": params})
}
written, err := rt.CallMCPWriteDataStrict("calendar", "respond", params)
if err != nil {
return err
}
written, err = requireCalendarWriteResponse(written, "calendar/respond")
if err != nil {
return err
}
readback, err := rt.CallMCPData("calendar", "get_calendar_detail", target)
if err != nil {
return err
}
event, err := requireCalendarEvent(readback, "calendar/get_calendar_detail", rt.Str("event"))
if err != nil {
return err
}
readStatus := firstCalendarString(event, "responseStatus", "response_status")
verified := readStatus == status
if readStatus != "" && !verified {
return calendarResponseError("calendar/respond", "readback_status_mismatch", "响应日程后读回的 responseStatus 与请求不一致")
}
if !verified {
writeStatus := nestedCalendarString(written, "responseStatus", "response_status", "status")
if writeStatus != "" && writeStatus != status {
return calendarResponseError("calendar/respond", "terminal_status_mismatch", "响应接口返回的状态与请求不一致")
}
if writeStatus == status {
verified = true
}
}
return rt.Output(map[string]any{"success": true, "eventId": rt.Str("event"), "responseStatus": status, "verified": verified, "verification": map[string]any{"eventReadback": true, "statusReadback": readStatus != ""}})
},
)
// EventSearch is an explicitly page-local client-side search. It returns the
// upstream cursor and complete=false whenever another page may exist.
var EventSearch = calendarReadShortcut(
"+search-event", "在一个日程页内严格搜索事件",
"DingTalk 没有专用日程全文搜索接口;该命令在 list_calendar_events 当前页按标题、描述和地点过滤,并原样保留 nextCursor/hasMore,绝不把单页零命中说成全局未找到。",
"events",
[]shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Required: true, Desc: "标题、描述或地点关键词"},
{Name: "start", Type: shortcut.FlagString, Desc: "开始时间,默认今天 00:00"},
{Name: "end", Type: shortcut.FlagString, Desc: "结束时间,默认今天 23:59:59"},
{Name: "calendar-id", Type: shortcut.FlagString, Desc: "日历 ID(默认 primary)"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "上一页 nextCursor"},
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "单页条数 1-100"},
},
[]contract.ParamDecl{{Name: "start", Property: "startTime"}, {Name: "end", Property: "endTime"}, {Name: "calendar-id", Property: "calendarId"}, {Name: "cursor", Property: "cursor"}, {Name: "limit", Property: "limit"}},
func(rt *shortcut.RuntimeContext) error {
params, err := calendarListParams(rt)
if err != nil {
return err
}
data, err := rt.CallMCPData("calendar", "list_calendar_events", params)
if err != nil {
return err
}
items, container, err := requireCalendarCollection(data, "calendar/list_calendar_events", "events")
if err != nil {
return err
}
query := strings.ToLower(rt.Str("query"))
matched := make([]any, 0)
for _, item := range items {
object := item.(map[string]any)
text := strings.ToLower(strings.Join([]string{
firstCalendarString(object, "summary", "title", "name"),
firstCalendarString(object, "description", "desc"),
firstCalendarString(object, "location", "locationName", "location_name"),
}, "\n"))
if strings.Contains(text, query) {
matched = append(matched, item)
}
}
events, err := projectCalendarRows(matched, "calendar/list_calendar_events", map[string][]string{
"eventId": {"eventId", "event_id", "id"}, "summary": {"summary", "title", "name"},
"start": {"start", "startTime", "startDateTime"}, "end": {"end", "endTime", "endDateTime"},
"status": {"status", "eventStatus"}, "location": {"location", "locationName"},
}, "eventId")
if err != nil {
return err
}
page, err := calendarPagination(container, "calendar/list_calendar_events")
if err != nil {
return err
}
if !page.Known {
return calendarResponseError("calendar/list_calendar_events", "missing_pagination", "日程搜索响应缺少 hasMore/nextCursor 分页证据,不能把当前页当作完整搜索结果")
}
out := map[string]any{"count": len(events), "events": events, "query": rt.Str("query")}
return outputCalendarPage(rt, out, page)
},
)
// Suggestion exposes the direct-user-id suggested-time route. Name resolution
// remains the distinct +suggest-time shortcut.
var Suggestion = calendarReadShortcut(
"+suggestion", "按 userId 严格推荐共同空闲时间",
"已经知道参与者 userId 时直接查询共同可用时间;显式 recommendEventTimes:[] 才表示没有建议,缺字段或坏元素会失败。按姓名解析请用 +suggest-time。",
"suggestions",
[]shortcut.Flag{
{Name: "users", Type: shortcut.FlagStringSlice, Required: true, Desc: "参与者 userId"},
{Name: "duration", Type: shortcut.FlagInt, Default: "30", Desc: "会议时长分钟数 1-1440"},
{Name: "start", Type: shortcut.FlagString, Desc: "推荐范围开始;与 --end 同时传"},
{Name: "end", Type: shortcut.FlagString, Desc: "推荐范围结束;与 --start 同时传"},
{Name: "timezone", Type: shortcut.FlagString, Desc: "IANA 时区"},
},
[]contract.ParamDecl{{Name: "users", Property: "attendeeUserIds"}, {Name: "duration", Property: "durationMinutes"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}, {Name: "timezone", Property: "timeZone"}},
func(rt *shortcut.RuntimeContext) error {
users := calendarStringList(rt.StrSlice("users"))
if len(users) == 0 {
return apperrors.NewValidation("--users 至少包含一个非空 userId")
}
if rt.Int("duration") < 1 || rt.Int("duration") > 1440 {
return apperrors.NewValidation("--duration 必须在 1-1440 分钟之间")
}
if rt.Changed("start") != rt.Changed("end") {
return apperrors.NewValidation("--start 与 --end 必须同时传入")
}
params := map[string]any{"attendeeUserIds": users, "durationMinutes": fmt.Sprint(rt.Int("duration"))}
if rt.Changed("start") {
if _, _, err := validateCalendarRange("start", rt.Str("start"), "end", rt.Str("end")); err != nil {
return err
}
params["start"] = rt.Str("start")
params["end"] = rt.Str("end")
}
if rt.Changed("timezone") {
params["timeZone"] = rt.Str("timezone")
}
data, err := rt.CallMCPData("calendar", "list_suggested_event_times", params)
if err != nil {
return err
}
items, _, err := requireCalendarCollection(data, "calendar/list_suggested_event_times", "recommendEventTimes", "suggestions")
if err != nil {
return err
}
suggestions, err := projectCalendarRows(items, "calendar/list_suggested_event_times", map[string][]string{
"start": {"startTime", "start", "startDateTime"}, "end": {"endTime", "end", "endDateTime"},
"conflicts": {"timeConflictAttendees", "conflicts"},
}, "start")
if err != nil {
return err
}
for index, suggestion := range suggestions {
if calendarEmptyValue(suggestion["end"]) {
return calendarResponseError("calendar/list_suggested_event_times", "malformed_collection_item", fmt.Sprintf("建议时段第 %d 项缺少结束时间", index))
}
}
return rt.Output(map[string]any{"count": len(suggestions), "suggestions": suggestions, "complete": true})
},
)
func calendarEventTarget(rt *shortcut.RuntimeContext) map[string]any {
params := map[string]any{"eventId": rt.Str("event")}
if calendarID := rt.Str("calendar-id"); calendarID != "" {
params["calendarId"] = calendarID
}
return params
}
func requireCalendarEvent(data map[string]any, operation, expectedID string) (map[string]any, error) {
event, err := requireCalendarObject(data, operation)
if err != nil {
return nil, err
}
eventID := calendarEventID(event)
if eventID == "" {
return nil, calendarResponseError(operation, "missing_event_id", "日程对象缺少 id/eventId")
}
if expectedID != "" && eventID != expectedID {
return nil, calendarResponseError(operation, "event_id_mismatch", "日程对象 id 与请求 eventId 不一致")
}
return event, nil
}
func normalizeCalendarEvent(event map[string]any) map[string]any {
out := make(map[string]any, len(event)+1)
for key, value := range event {
out[key] = value
}
out["eventId"] = calendarEventID(event)
return out
}
func calendarCreateParams(rt *shortcut.RuntimeContext) (map[string]any, map[string]any, error) {
if len([]rune(rt.Str("title"))) > 2048 {
return nil, nil, apperrors.NewValidation("--title 不能超过 2048 个字符")
}
if len([]rune(rt.Str("desc"))) > 5000 {
return nil, nil, apperrors.NewValidation("--desc 不能超过 5000 个字符")
}
if _, _, err := validateCalendarRange("start", rt.Str("start"), "end", rt.Str("end")); err != nil {
return nil, nil, err
}
attendees := calendarStringList(rt.StrSlice("attendees"))
if len(attendees) > 500 {
return nil, nil, apperrors.NewValidation("--attendees 最多 500 个 userId")
}
params := map[string]any{"summary": rt.Str("title"), "startDateTime": rt.Str("start"), "endDateTime": rt.Str("end")}
verify := map[string]any{"summary": rt.Str("title"), "startDateTime": rt.Str("start"), "endDateTime": rt.Str("end")}
for _, pair := range [][2]string{{"desc", "description"}, {"timezone", "timeZone"}, {"location", "location"}, {"free-busy", "freeBusy"}, {"calendar-id", "calendarId"}} {
if rt.Changed(pair[0]) {
params[pair[1]] = rt.Str(pair[0])
if pair[1] != "calendarId" {
verify[pair[1]] = rt.Str(pair[0])
}
}
}
if len(attendees) > 0 {
params["attendees"] = attendees
}
if rooms := calendarStringList(rt.StrSlice("rooms")); len(rooms) > 0 {
params["roomIds"] = rooms
}
return params, verify, nil
}
func calendarUpdateParams(rt *shortcut.RuntimeContext) (map[string]any, []string, []string, error) {
if rt.Changed("start") != rt.Changed("end") {
return nil, nil, nil, apperrors.NewValidation("--start 与 --end 必须同时传入")
}
if rt.Changed("start") {
if _, _, err := validateCalendarRange("start", rt.Str("start"), "end", rt.Str("end")); err != nil {
return nil, nil, nil, err
}
}
fields := map[string]any{}
for _, pair := range [][2]string{{"title", "summary"}, {"desc", "description"}, {"start", "startDateTime"}, {"end", "endDateTime"}, {"timezone", "timeZone"}, {"location", "location"}, {"free-busy", "freeBusy"}} {
if rt.Changed(pair[0]) {
fields[pair[1]] = rt.Str(pair[0])
}
}
add := calendarStringList(rt.StrSlice("add-attendees"))
remove := calendarStringList(rt.StrSlice("remove-attendees"))
if len(fields)+len(add)+len(remove) == 0 {
return nil, nil, nil, apperrors.NewValidation("至少提供一个要更新的字段或参会人变更")
}
removeSet := map[string]bool{}
for _, id := range remove {
removeSet[id] = true
}
for _, id := range add {
if removeSet[id] {
return nil, nil, nil, apperrors.NewValidation("同一 userId 不能同时出现在 --add-attendees 与 --remove-attendees")
}
}
return fields, add, remove, nil
}
func verifyCalendarAttendees(rows []map[string]any, added, removed []string) error {
present := map[string]bool{}
for _, row := range rows {
if id := strings.TrimSpace(fmt.Sprint(row["userId"])); id != "" {
present[id] = true
}
}
for _, id := range added {
if !present[id] {
return calendarResponseError("calendar/get_calendar_participants", "readback_attendee_missing", "写后读回未找到新增参会人")
}
}
for _, id := range removed {
if present[id] {
return calendarResponseError("calendar/get_calendar_participants", "readback_attendee_present", "写后读回仍包含已移除参会人")
}
}
return nil
}
func calendarStageError(stage string, completed []string, cause error) error {
message := fmt.Sprintf("日程更新在步骤 %s 失败", stage)
if len(completed) > 0 {
message += ";此前已完成且不会自动回滚的步骤: " + strings.Join(completed, ",")
}
if cause != nil {
message += ": " + cause.Error()
}
return calendarResponseError("calendar/update", "partial_or_unknown_effect", message)
}
func calendarListParams(rt *shortcut.RuntimeContext) (map[string]any, error) {
if rt.Int("limit") < 1 || rt.Int("limit") > 100 {
return nil, apperrors.NewValidation("--limit 必须在 1-100 之间")
}
now := time.Now()
start := rt.Str("start")
end := rt.Str("end")
if start == "" {
start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Format(time.RFC3339)
}
if end == "" {
end = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).Format(time.RFC3339)
}
startTime, endTime, err := validateCalendarRange("start", start, "end", end)
if err != nil {
return nil, err
}
params := map[string]any{"startTime": startTime.UnixMilli(), "endTime": endTime.UnixMilli(), "limit": rt.Int("limit")}
if value := rt.Str("calendar-id"); value != "" {
params["calendarId"] = value
}
if value := rt.Str("cursor"); value != "" {
params["cursor"] = value
}
return params, nil
}
@@ -22,7 +22,7 @@ import (
// list_meeting_room_groups nests the groups under result.groupList; the resolver
// must probe "groupList" or +room-groups silently returns empty despite the
// backend returning meeting-room groups.
func TestRoomGroupsProjectGroupListShape(t *testing.T) {
func TestCrossPlatformCoverageRoomGroupsProjectGroupListShape(t *testing.T) {
const raw = `{"result":{"groupList":[
{"groupId":"g1","groupName":"north rooms"},
{"groupId":"g2","groupName":"south rooms"}
@@ -31,7 +31,11 @@ func TestRoomGroupsProjectGroupListShape(t *testing.T) {
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
if got := roomGroupsProject(data); len(got) != 2 {
got, err := roomGroupsProject(data)
if err != nil {
t.Fatalf("projection: %v", err)
}
if len(got) != 2 {
t.Fatalf("lower/upper mismatch: result.groupList has 2 entries, projection returned %d", len(got))
}
}
+382 -33
View File
@@ -4,10 +4,14 @@
package doc
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"time"
"unicode"
@@ -18,6 +22,9 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/docresolver"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/renderer/html"
)
var (
@@ -28,6 +35,21 @@ var (
docMkdirTemp = os.MkdirTemp
docRemoveAll = os.RemoveAll
docDownload = localio.Download
docVerifyWait = waitForDocVerification
docVerifyDelays = []time.Duration{250 * time.Millisecond, 500 * time.Millisecond, time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second}
docMarkdown = goldmark.New(
goldmark.WithExtensions(extension.Table),
goldmark.WithRendererOptions(html.WithUnsafe()),
)
docMarkdownConvert = func(source []byte, writer io.Writer) error {
return docMarkdown.Convert(source, writer)
}
)
const (
docBlockReadPageSize = 50
docBlockReadMaxItems = 5000
docMarkdownVerifyMax = 2 * 1024 * 1024
)
var Create = shortcut.Shortcut{
@@ -133,7 +155,9 @@ var Create = shortcut.Shortcut{
verifyTool = "get_document_content"
verifyParams["format"] = format
}
verification, err := rt.CallMCPData(productDoc, verifyTool, verifyParams)
verification, err := readDocVerification(rt, verifyTool, verifyParams, func(data map[string]any) bool {
return content == "" || verifyUpdatedDocumentContent(data, content, "overwrite", format)
})
if err != nil {
return docVerificationError("doc.create", "verify", nodeID, err, append(steps, map[string]any{"name": "verify", "status": "failed"}))
}
@@ -422,7 +446,9 @@ var CheckpointUpdate = shortcut.Shortcut{
append(steps, map[string]any{"name": "update", "status": "failed"}, map[string]any{"name": "verify", "status": "not_started"}))
}
steps = append(steps, map[string]any{"name": "update", "status": "success"})
verification, err := rt.CallMCPData(productDoc, "get_document_content", map[string]any{"nodeId": rt.Str("node"), "format": "markdown"})
verification, err := readDocVerification(rt, "get_document_content", map[string]any{"nodeId": rt.Str("node"), "format": "markdown"}, func(data map[string]any) bool {
return verifyUpdatedDocumentContent(data, content, rt.Str("mode"), "markdown")
})
if err != nil {
return checkpointPartialWriteError(rt.Str("node"), checkpoint, "verify", "doc_checkpoint_verification_failed", err,
append(steps, map[string]any{"name": "verify", "status": "failed"}))
@@ -539,7 +565,7 @@ func executeUpdate(rt *shortcut.RuntimeContext) error {
}
referenceBlockID := rt.Str("after-block-id")
return executeVerifiedDocMutation(rt, "doc.update", "insert_document_block", params, node,
"list_document_blocks", map[string]any{"nodeId": node, "format": verificationFormat},
"list_document_blocks", map[string]any{"nodeId": node, "format": verificationFormat, "__allBlocks": true},
func(result, data map[string]any) bool {
return verifyInsertedBlock(result, data, referenceBlockID, content, rt.Str("doc-format"))
})
@@ -553,14 +579,14 @@ func executeUpdate(rt *shortcut.RuntimeContext) error {
params["element"] = map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": content}}
}
return executeVerifiedDocMutation(rt, "doc.update", "update_document_block", params, node,
"list_document_blocks", map[string]any{"nodeId": node, "blockId": blockID, "format": verificationFormat},
"list_document_blocks", map[string]any{"nodeId": node, "format": verificationFormat, "__allBlocks": true},
func(_, data map[string]any) bool {
return blockContentEquals(data, blockID, content, rt.Str("doc-format"))
})
case "block_delete":
blockID := rt.Str("block-id")
return executeVerifiedDocMutation(rt, "doc.update", "delete_document_block", map[string]any{"nodeId": node, "blockId": blockID}, node,
"list_document_blocks", map[string]any{"nodeId": node, "format": "element"},
"list_document_blocks", map[string]any{"nodeId": node, "format": "element", "__allBlocks": true},
func(_, data map[string]any) bool { return findBlock(data, blockID) == nil })
case "str_replace":
return executePlainTextReplace(rt, node)
@@ -609,7 +635,7 @@ func nestedRevision(value any) (int, bool) {
}
func executePlainTextReplace(rt *shortcut.RuntimeContext, nodeID string) error {
data, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element"})
data, err := readAllDocumentBlocks(rt, map[string]any{"nodeId": nodeID, "format": "element"})
if err != nil {
return err
}
@@ -643,12 +669,12 @@ func executePlainTextReplace(rt *shortcut.RuntimeContext, nodeID string) error {
blockID := matches[0].blockID
return executeVerifiedDocMutation(rt, "doc.update", "update_document_block",
map[string]any{"nodeId": nodeID, "blockId": blockID, "element": map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": updated}}}, nodeID,
"list_document_blocks", map[string]any{"nodeId": nodeID, "blockId": blockID, "format": "element"},
"list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element", "__allBlocks": true},
func(_, data map[string]any) bool { return blockContentEquals(data, blockID, updated, "markdown") })
}
func executeBlockCopy(rt *shortcut.RuntimeContext, nodeID string) error {
data, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": nodeID, "blockId": rt.Str("block-id"), "format": "element"})
data, err := readAllDocumentBlocks(rt, map[string]any{"nodeId": nodeID, "format": "element"})
if err != nil {
return err
}
@@ -664,7 +690,7 @@ func executeBlockCopy(rt *shortcut.RuntimeContext, nodeID string) error {
referenceBlockID := rt.Str("after-block-id")
return executeVerifiedDocMutation(rt, "doc.update", "insert_document_block",
map[string]any{"nodeId": nodeID, "referenceBlockId": referenceBlockID, "where": "after", "element": block}, nodeID,
"list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element"},
"list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element", "__allBlocks": true},
func(result, data map[string]any) bool {
return verifyInsertedCanonicalBlock(result, data, referenceBlockID, expectedContent, "markdown")
})
@@ -684,7 +710,9 @@ func executeVerifiedDocMutation(
return docUnknownWriteError(operation, tool, nodeID, err)
}
steps[0]["status"] = "success"
verification, err := rt.CallMCPData(productDoc, verifyTool, verifyParams)
verification, err := readDocVerification(rt, verifyTool, verifyParams, func(data map[string]any) bool {
return verify == nil || verify(result, data)
})
if err != nil {
return docVerificationError(operation, "verify", nodeID, err, append(steps, map[string]any{"name": "verify", "status": "failed"}))
}
@@ -732,7 +760,9 @@ func executeVerifiedDocContentMutation(rt *shortcut.RuntimeContext, firstParams
}
steps = append(steps, map[string]any{"name": stepName, "status": "success"})
}
verification, err := rt.CallMCPData(productDoc, "get_document_content", map[string]any{"nodeId": nodeID, "format": format})
verification, err := readDocVerification(rt, "get_document_content", map[string]any{"nodeId": nodeID, "format": format}, func(data map[string]any) bool {
return verifyUpdatedDocumentContent(data, content, mode, format)
})
if err != nil {
return docVerificationError("doc.update", "verify", nodeID, err, append(steps, map[string]any{"name": "verify", "status": "failed"}))
}
@@ -745,6 +775,180 @@ func executeVerifiedDocContentMutation(rt *shortcut.RuntimeContext, firstParams
}, steps...))
}
func readDocVerification(rt *shortcut.RuntimeContext, tool string, rawParams map[string]any, verify func(map[string]any) bool) (map[string]any, error) {
params := cloneMap(rawParams)
allBlocks, _ := params["__allBlocks"].(bool)
delete(params, "__allBlocks")
var last map[string]any
var lastErr error
for attempt := 0; attempt <= len(docVerifyDelays); attempt++ {
var data map[string]any
var err error
if allBlocks && tool == "list_document_blocks" {
data, err = readAllDocumentBlocks(rt, params)
} else {
data, err = rt.CallMCPData(productDoc, tool, params)
}
if err != nil {
lastErr = err
} else {
last = data
lastErr = nil
if verify == nil || verify(data) {
return data, nil
}
}
if attempt < len(docVerifyDelays) {
if err := docVerifyWait(rt.Command().Context(), docVerifyDelays[attempt]); err != nil {
return nil, err
}
}
}
if lastErr != nil {
return nil, lastErr
}
return last, nil
}
func waitForDocVerification(ctx context.Context, delay time.Duration) error {
if ctx == nil {
ctx = context.Background()
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func readAllDocumentBlocks(rt *shortcut.RuntimeContext, base map[string]any) (map[string]any, error) {
all := make([]any, 0, docBlockReadPageSize)
seenPageIdentities := map[string]bool{}
for start := 0; start < docBlockReadMaxItems; start += docBlockReadPageSize {
params := cloneMap(base)
params["startIndex"] = start
params["endIndex"] = start + docBlockReadPageSize - 1
page, err := rt.CallMCPData(productDoc, "list_document_blocks", params)
if err != nil {
return nil, err
}
blocks, ok := documentBlockEntries(page)
if !ok {
return nil, fmt.Errorf("list_document_blocks 回读缺少 blocks 数组")
}
pageIdentity := documentBlockPageIdentity(blocks)
if pageIdentity != "" && seenPageIdentities[pageIdentity] {
return nil, fmt.Errorf("list_document_blocks 分页停滞,无法证明回读完整")
}
if pageIdentity != "" {
seenPageIdentities[pageIdentity] = true
}
all = append(all, blocks...)
hasMore, known, _ := docPageState(page)
if known && !hasMore {
return map[string]any{"blocks": all, "hasMore": false, "totalCount": len(all)}, nil
}
if !known {
if total, ok := nestedNonNegativeInt(page, "totalCount", "total_count"); ok && len(all) >= total {
return map[string]any{"blocks": all, "hasMore": false, "totalCount": total}, nil
}
}
if !known && len(blocks) < docBlockReadPageSize {
return map[string]any{"blocks": all, "hasMore": false, "totalCount": len(all)}, nil
}
if len(blocks) == 0 {
return nil, fmt.Errorf("list_document_blocks 声明仍有下一页但当前页为空,无法证明回读完整")
}
}
return nil, fmt.Errorf("list_document_blocks 超过 %d 个块,无法在安全上限内完成回读", docBlockReadMaxItems)
}
func documentBlockPageIdentity(blocks []any) string {
if len(blocks) == 0 {
return ""
}
ids := make([]string, 0, len(blocks))
for _, value := range blocks {
id := ""
switch block := value.(type) {
case map[string]any:
id = blockIdentity(block, "")
if id == "" {
if element, ok := block["element"].(map[string]any); ok {
id = blockIdentity(element, "")
}
}
case []any:
id = jsonMLBlockIdentity(block)
}
if id == "" {
return ""
}
ids = append(ids, id)
}
encoded, _ := json.Marshal(ids)
return string(encoded)
}
func documentBlockEntries(value any) ([]any, bool) {
switch typed := value.(type) {
case map[string]any:
for _, key := range []string{"blocks", "items"} {
if blocks, ok := typed[key].([]any); ok {
return blocks, true
}
}
if encoded, ok := typed["jsonml"].(string); ok {
var decoded any
if json.Unmarshal([]byte(encoded), &decoded) == nil {
blocks := orderedJSONMLBlocks(decoded)
values := make([]any, len(blocks))
for index := range blocks {
values[index] = blocks[index]
}
return values, true
}
}
for _, key := range []string{"result", "data"} {
if nested, ok := typed[key]; ok {
if blocks, found := documentBlockEntries(nested); found {
return blocks, true
}
}
}
}
return nil, false
}
func nestedNonNegativeInt(value any, keys ...string) (int, bool) {
switch typed := value.(type) {
case map[string]any:
for _, key := range keys {
if raw, ok := typed[key]; ok {
switch number := raw.(type) {
case float64:
if number >= 0 && number == float64(int(number)) {
return int(number), true
}
case int:
if number >= 0 {
return number, true
}
}
}
}
for _, key := range []string{"result", "data"} {
if result, ok := nestedNonNegativeInt(typed[key], keys...); ok {
return result, true
}
}
}
return 0, false
}
func splitDocMarkdown(content string, maxRunes int) []string {
if maxRunes <= 0 {
return []string{content}
@@ -796,11 +1000,17 @@ func containsText(value any, needle string) bool {
}
func verifyUpdatedDocumentContent(value any, expected, mode, format string) bool {
expected = normalizeDocumentContentForVerification(expected, format)
expectedRaw := expected
expected = normalizeDocumentContentForVerification(expectedRaw, format)
for _, candidate := range documentContentCandidates(value, format) {
actual := normalizeDocumentContentForVerification(candidate, format)
actualRaw := candidate
actual := normalizeDocumentContentForVerification(actualRaw, format)
if mode == "overwrite" {
if actual == expected {
if actual == expected || (format == "markdown" && stripReadbackDocumentTitle(actual) == expected) {
return true
}
if format == "markdown" && (markdownSemanticallyEquivalent(actualRaw, expectedRaw) ||
markdownSemanticallyEquivalent(stripReadbackDocumentTitle(actualRaw), expectedRaw)) {
return true
}
continue
@@ -808,10 +1018,48 @@ func verifyUpdatedDocumentContent(value any, expected, mode, format string) bool
if actual == expected || strings.HasSuffix(actual, "\n"+expected) {
return true
}
if format == "markdown" && markdownSemanticallyEndsWith(actualRaw, expectedRaw) {
return true
}
}
return false
}
func markdownSemanticallyEquivalent(left, right string) bool {
leftFingerprint, leftOK := markdownSemanticFingerprint(left)
rightFingerprint, rightOK := markdownSemanticFingerprint(right)
return leftOK && rightOK && leftFingerprint == rightFingerprint
}
func markdownSemanticallyEndsWith(content, suffix string) bool {
contentFingerprint, contentOK := markdownSemanticFingerprint(content)
suffixFingerprint, suffixOK := markdownSemanticFingerprint(suffix)
return contentOK && suffixOK && strings.HasSuffix(contentFingerprint, suffixFingerprint)
}
func markdownSemanticFingerprint(source string) (string, bool) {
if len(source) > docMarkdownVerifyMax {
return "", false
}
var rendered bytes.Buffer
if err := docMarkdownConvert([]byte(source), &rendered); err != nil {
return "", false
}
return rendered.String(), true
}
func stripReadbackDocumentTitle(content string) string {
lines := strings.Split(content, "\n")
if len(lines) == 0 || !strings.HasPrefix(strings.TrimSpace(lines[0]), "# ") {
return content
}
lines = lines[1:]
for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" {
lines = lines[1:]
}
return strings.Join(lines, "\n")
}
func verifyInsertedBlock(result, data map[string]any, referenceBlockID, expected, format string) bool {
return verifyInsertedCanonicalBlock(result, data, referenceBlockID, normalizeDocumentContentForVerification(expected, format), format)
}
@@ -848,6 +1096,11 @@ func blockContentEquals(data map[string]any, blockID, expected, format string) b
}
func canonicalBlockContent(value any, format string) string {
if values, ok := value.(map[string]any); ok {
if element, ok := values["element"].(map[string]any); ok {
value = element
}
}
if format == "jsonml" {
if values, ok := value.(map[string]any); ok {
if encoded, ok := values["jsonml"].(string); ok {
@@ -871,7 +1124,7 @@ func canonicalBlockContent(value any, format string) string {
return
}
for key, child := range typed {
if key == "id" || key == "blockId" || key == "uuid" {
if key == "id" || key == "blockId" || key == "uuid" || key == "blockType" {
continue
}
walk(child)
@@ -1005,6 +1258,10 @@ func orderedDocumentBlocks(value any) []map[string]any {
walk = func(current any) {
switch typed := current.(type) {
case map[string]any:
if element, ok := typed["element"].(map[string]any); ok && blockIdentity(element, "") != "" {
blocks = append(blocks, element)
return
}
if blockIdentity(typed, "") != "" {
blocks = append(blocks, typed)
return
@@ -1065,9 +1322,23 @@ func normalizeDocumentContentForVerification(raw, format string) string {
func normalizeMarkdownForVerification(raw string) string {
raw = strings.ReplaceAll(strings.ReplaceAll(raw, "\r\n", "\n"), "\r", "\n")
lines := make([]string, 0, strings.Count(raw, "\n")+1)
inFence := false
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
inFence = !inFence
lines = append(lines, trimmed)
continue
}
if inFence {
lines = append(lines, strings.TrimRight(line, " \t"))
continue
}
line = trimmed
if line == "" {
if len(lines) > 0 && lines[len(lines)-1] != "" {
lines = append(lines, "")
}
continue
}
if strings.Contains(line, "|") {
@@ -1081,6 +1352,9 @@ func normalizeMarkdownForVerification(raw string) string {
}
lines = append(lines, line)
}
for len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
return strings.Join(lines, "\n")
}
@@ -1089,33 +1363,108 @@ func normalizeJSONMLForVerification(raw string) string {
if err := json.Unmarshal([]byte(raw), &value); err != nil {
return normalizeMarkdownForVerification(raw)
}
var tokens []string
var walk func(any)
walk = func(current any) {
var normalize func(any) any
normalize = func(current any) any {
switch typed := current.(type) {
case []any:
start := 0
if len(typed) > 0 {
if tag, ok := typed[0].(string); ok {
tokens = append(tokens, "<"+strings.ToLower(strings.TrimSpace(tag))+">")
start = 1
if len(typed) == 0 {
return []any{}
}
tag, isElement := typed[0].(string)
if !isElement {
out := make([]any, 0, len(typed))
for _, child := range typed {
out = append(out, normalize(child))
}
return out
}
start := 1
attrs := map[string]any{}
if len(typed) > 1 {
if declared, ok := typed[1].(map[string]any); ok {
attrs, _ = normalize(declared).(map[string]any)
attrs = removeGeneratedJSONMLDefaults(tag, attrs)
start = 2
}
}
children := make([]any, 0, len(typed)-start)
for _, child := range typed[start:] {
walk(child)
normalized := normalize(child)
if normalized != nil {
children = append(children, normalized)
}
}
if strings.EqualFold(tag, "span") && isGeneratedTextSpan(attrs) {
if len(children) == 1 {
return children[0]
}
return children
}
out := []any{strings.ToLower(tag), attrs}
out = append(out, children...)
return out
case map[string]any:
// JSONML maps contain element attributes. Server-generated UUIDs and
// default attributes do not change the authored document content.
return
case string:
if text := normalizeMarkdownForVerification(typed); text != "" {
tokens = append(tokens, text)
out := make(map[string]any, len(typed))
for key, child := range typed {
normalizedKey := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key))
if normalizedKey == "uuid" || normalizedKey == "blockid" || normalizedKey == "elementid" || normalizedKey == "index" {
continue
}
out[normalizedKey] = normalize(child)
}
return out
case string:
return strings.ReplaceAll(strings.ReplaceAll(typed, "\r\n", "\n"), "\r", "\n")
}
return current
}
walk(value)
return strings.Join(tokens, "\n")
// normalize only receives values decoded by encoding/json, so the resulting
// tree is always JSON-marshalable.
encoded, _ := json.Marshal(normalize(value))
return string(encoded)
}
var generatedJSONMLAttributeDefaults = map[string]map[string]any{
"hr": {
"sz": float64(1),
},
"tc": {
"colspan": float64(1), "rowspan": float64(1), "valign": "middle",
},
"code": {
"code": "", "syntax": "plaintext", "theme": "default",
"wrap": true, "showlinenumber": true, "fold": false,
},
}
// removeGeneratedJSONMLDefaults drops only defaults declared by the reviewed
// JSONML schema, plus empty server style objects. Other attributes remain part
// of the semantic fingerprint so links, formatting, and table layout stay
// strict.
func removeGeneratedJSONMLDefaults(tag string, attrs map[string]any) map[string]any {
defaults := generatedJSONMLAttributeDefaults[strings.ToLower(tag)]
out := make(map[string]any, len(attrs))
for key, value := range attrs {
if object, ok := value.(map[string]any); ok && len(object) == 0 {
continue
}
if defaultValue, ok := defaults[key]; ok && reflect.DeepEqual(value, defaultValue) {
continue
}
out[key] = value
}
return out
}
func isGeneratedTextSpan(attrs map[string]any) bool {
if len(attrs) == 0 {
return true
}
if len(attrs) != 1 {
return false
}
value, ok := attrs["datatype"].(string)
return ok && (value == "text" || value == "leaf")
}
func executeExport(rt *shortcut.RuntimeContext) error {
@@ -0,0 +1,401 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package doc
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageDocReadbackRetriesStaleContent(t *testing.T) {
testseam.Swap(t, &docVerifyWait, func(context.Context, time.Duration) error { return nil })
testseam.Swap(t, &docVerifyDelays, []time.Duration{time.Millisecond})
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"get_document_content": {{"markdown": "old"}, {"markdown": "old\nnew"}},
}}
if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "append", "--content", "new", "--yes"); err != nil {
t.Fatal(err)
}
reads := 0
for _, call := range caller.history {
if call.tool == "get_document_content" {
reads++
}
}
if reads != 2 {
t.Fatalf("readback calls = %d, want 2; history=%#v", reads, caller.history)
}
}
func TestCrossPlatformCoverageDocReadbackStopsOnCancellation(t *testing.T) {
if err := waitForDocVerification(nil, time.Nanosecond); err != nil {
t.Fatalf("completed verification wait = %v", err)
}
cancelled, cancel := context.WithCancel(context.Background())
cancel()
if err := waitForDocVerification(cancelled, time.Hour); !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled verification wait = %v, want context.Canceled", err)
}
cmd := &cobra.Command{Use: "verify"}
cmd.SetContext(cancelled)
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"get_document_content": {{"markdown": "stale"}},
}}
helpers.InitDeps(caller)
rt := shortcut.RuntimeContextForTest(cmd, Update)
_, err := readDocVerification(rt, "get_document_content", map[string]any{"nodeId": "n"}, func(map[string]any) bool { return false })
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled document verification = %v, want context.Canceled", err)
}
if len(caller.history) != 1 {
t.Fatalf("cancelled document verification calls = %d, want 1", len(caller.history))
}
}
func TestCrossPlatformCoverageDocDeleteReadbackConsumesEveryPage(t *testing.T) {
testseam.Swap(t, &docVerifyWait, func(context.Context, time.Duration) error { return nil })
testseam.Swap(t, &docVerifyDelays, []time.Duration{time.Millisecond})
firstPage := make([]any, 50)
for index := range firstPage {
firstPage[index] = map[string]any{"id": fmt.Sprintf("block-%d", index), "text": "body"}
}
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"list_document_blocks": {
{"blocks": firstPage, "hasMore": true, "totalCount": 51},
{"blocks": []any{map[string]any{"id": "target", "text": "stale"}}, "hasMore": false, "totalCount": 51},
{"blocks": firstPage, "hasMore": false, "totalCount": 50},
},
}}
if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes"); err != nil {
t.Fatal(err)
}
starts := []int{}
for _, call := range caller.history {
if call.tool == "list_document_blocks" {
starts = append(starts, call.params["startIndex"].(int))
}
}
if fmt.Sprint(starts) != "[0 50 0]" {
t.Fatalf("pagination starts = %v, want [0 50 0]", starts)
}
}
func TestCrossPlatformCoverageDocReplacePreflightIsGloballyUnique(t *testing.T) {
firstPage := make([]any, 50)
for index := range firstPage {
text := "body"
if index == 0 {
text = "unique needle"
}
firstPage[index] = map[string]any{"id": fmt.Sprintf("block-%d", index), "text": text}
}
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"list_document_blocks": {
{"blocks": firstPage, "hasMore": true, "totalCount": 51},
{"blocks": []any{map[string]any{"id": "block-50", "text": "another needle"}}, "hasMore": false, "totalCount": 51},
},
}}
err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "str_replace", "--old", "needle", "--new", "changed", "--yes")
if err == nil {
t.Fatal("str_replace accepted a second match on a later page")
}
for _, call := range caller.history {
if call.tool == "update_document_block" {
t.Fatalf("ambiguous replace executed a write: %#v", caller.history)
}
}
}
func TestCrossPlatformCoverageDocVerificationPreservesMeaning(t *testing.T) {
expected := "[\"p\",{},\"text\"]"
serverExpanded := "[\"p\",{\"uuid\":\"generated\",\"style\":{}},[\"span\",{\"data-type\":\"text\"},[\"span\",{\"data-type\":\"leaf\"},\"text\"]]]"
if normalizeJSONMLForVerification(expected) != normalizeJSONMLForVerification(serverExpanded) {
t.Fatal("generated JSONML text wrappers should not change document meaning")
}
defaultFree := `[["hr",{}],["code",{}],["table",{},["tr",{},["tc",{},["p",{},"cell"]]]]]`
serverDefaulted := `[["hr",{"sz":1}],["code",{"code":"","syntax":"plaintext","theme":"default","wrap":true,"showLineNumber":true,"fold":false}],["table",{},["tr",{},["tc",{"colSpan":1,"rowSpan":1,"vAlign":"middle"},["p",{},"cell"]]]]]`
if !verifyUpdatedDocumentContent(map[string]any{"jsonml": serverDefaulted}, defaultFree, "overwrite", "jsonml") {
t.Fatal("server-generated JSONML schema defaults should not fail readback verification")
}
linkA := "[\"a\",{\"href\":\"https://example.com/a\"},\"text\"]"
linkB := "[\"a\",{\"href\":\"https://example.com/b\"},\"text\"]"
if normalizeJSONMLForVerification(linkA) == normalizeJSONMLForVerification(linkB) {
t.Fatal("semantic JSONML attributes were ignored")
}
tableA := `[["table",{"jc":"center"},["tr",{},["tc",{},["p",{},"cell"]]]]]`
tableB := `[["table",{"jc":"right"},["tr",{},["tc",{},["p",{},"cell"]]]]]`
if normalizeJSONMLForVerification(tableA) == normalizeJSONMLForVerification(tableB) {
t.Fatal("semantic JSONML table alignment was ignored")
}
codeA := "~~~go\n return nil\n~~~"
codeB := "~~~go\nreturn nil\n~~~"
if normalizeMarkdownForVerification(codeA) == normalizeMarkdownForVerification(codeB) {
t.Fatal("fenced code indentation was ignored")
}
if !verifyUpdatedDocumentContent(map[string]any{"markdown": "# Server title\n\nbody"}, "body", "overwrite", "markdown") {
t.Fatal("server-generated document title prevented body verification")
}
}
func TestCrossPlatformCoverageMarkdownSemanticRoundTrip(t *testing.T) {
input := strings.Join([]string{
"sales_data.xlsx",
"### 1.",
"+10.22%",
"| name | value |",
"| -------- | -------- |",
"| sales_data.xlsx | +10.22% |",
}, "\n")
server := strings.Join([]string{
`sales\_data.xlsx`,
`### 1\.`,
`\+10.22%`,
"|name|value|",
"|---|---|",
`|sales\_data.xlsx|\+10.22%|`,
}, "\n")
if !markdownSemanticallyEquivalent(input, server) {
t.Fatal("server Markdown escaping and table delimiter normalization changed the semantic fingerprint")
}
if !verifyUpdatedDocumentContent(map[string]any{"markdown": server}, input, "overwrite", "markdown") {
t.Fatal("equivalent server Markdown failed overwrite verification")
}
if !verifyUpdatedDocumentContent(map[string]any{"markdown": "existing\n\n" + server}, input, "append", "markdown") {
t.Fatal("equivalent server Markdown failed append verification")
}
}
func TestCrossPlatformCoverageMarkdownSemanticDifferencesRemainStrict(t *testing.T) {
for _, test := range []struct {
name string
left string
right string
}{
{name: "emphasis", left: `*important*`, right: `\*important\*`},
{name: "inline code", left: "`sales_data`", right: "`sales\\_data`"},
{name: "fenced code", left: "```\nsales_data\n```", right: "```\nsales\\_data\n```"},
{name: "table alignment", left: "|a|\n|---|\n|x|", right: "|a|\n|:---|\n|x|"},
{name: "table columns", left: "|a|b|\n|---|---|\n|x|y|", right: "|a|\n|---|\n|x|"},
{name: "table content", left: "|a|\n|---|\n|x|", right: "|a|\n|---|\n|y|"},
} {
t.Run(test.name, func(t *testing.T) {
if markdownSemanticallyEquivalent(test.left, test.right) {
t.Fatal("meaningful Markdown difference was ignored")
}
})
}
oversized := strings.Repeat("x", docMarkdownVerifyMax+1)
if _, ok := markdownSemanticFingerprint(oversized); ok {
t.Fatal("oversized Markdown entered semantic verification")
}
testseam.Swap(t, &docMarkdownConvert, func([]byte, io.Writer) error { return errors.New("render") })
if _, ok := markdownSemanticFingerprint("body"); ok {
t.Fatal("failed Markdown render produced a semantic fingerprint")
}
}
func TestCrossPlatformCoverageDocElementReadbackUsesNestedElement(t *testing.T) {
wrapper := map[string]any{
"blockType": "paragraph",
"element": map[string]any{"id": "inserted", "blockType": "paragraph", "paragraph": map[string]any{"text": "body"}},
}
if got := canonicalBlockContent(wrapper, "markdown"); got != "body" {
t.Fatalf("nested element content = %q, want body", got)
}
blocks := orderedDocumentBlocks(map[string]any{"blocks": []any{wrapper}})
if len(blocks) != 1 || blockIdentity(blocks[0], "") != "inserted" {
t.Fatalf("nested element blocks = %#v", blocks)
}
}
func TestCrossPlatformCoverageVersionRevertRequiresTargetEvidence(t *testing.T) {
if revertResultMatchesVersion(map[string]any{"ok": true}, 3) || currentDocumentMatchesRestoredVersion(map[string]any{"version": 99}, 3) {
t.Fatal("readability or an unrelated current version must not prove a revert")
}
if revertResultMatchesVersion(map[string]any{"version": 3}, 3) {
t.Fatal("the request version parameter must not prove its own revert")
}
if revertResultMatchesVersion(map[string]any{"data": map[string]any{"request": map[string]any{"targetVersion": 3}}}, 3) {
t.Fatal("request echo containers must not provide version evidence")
}
for _, failed := range []map[string]any{
{"data": map[string]any{"success": "false", "revertedToVersion": 3}},
{"data": map[string]any{"ok": false, "revertedToVersion": 3}},
{"data": map[string]any{"status": "FAILED", "revertedToVersion": 3}},
{"data": map[string]any{"state": "failure", "revertedToVersion": 3}},
{"data": []any{map[string]any{"error_code": "REVERT_FAILED", "revertedToVersion": 3}}},
{"data": map[string]any{"errorCode": 500.0, "revertedToVersion": 3}},
{"data": map[string]any{"code": json.Number("500"), "revertedToVersion": 3}},
} {
if revertResultMatchesVersion(failed, 3) {
t.Fatalf("explicit failure %#v must override target-version evidence", failed)
}
}
for _, succeeded := range []map[string]any{
{"revertedToVersion": 3},
{"status": "SUCCESS", "revertedToVersion": 3},
{"state": "succeeded", "revertedToVersion": 3},
{"errorCode": "0", "revertedToVersion": 3},
{"code": 200, "revertedToVersion": 3},
{"code": "204", "revertedToVersion": 3},
} {
if !revertResultMatchesVersion(succeeded, 3) {
t.Fatalf("explicit success %#v suppressed target-version evidence", succeeded)
}
}
for _, absentOrSuccess := range []any{nil, "", "OK", "SUCCESS", json.Number("0"), json.Number("200"), 0.0, 201.0, 0, 202} {
if revertErrorCodeIsFailure(absentOrSuccess) {
t.Fatalf("success code %#v was treated as an explicit failure", absentOrSuccess)
}
}
for _, failedCode := range []any{json.Number("bad"), json.Number("500"), 1.0, 500.0, 1, 500} {
if !revertErrorCodeIsFailure(failedCode) {
t.Fatalf("failure code %#v was not treated as an explicit failure", failedCode)
}
}
if revertStatusIsFailure(500) || revertStatusIsFailure("PROCESSING") {
t.Fatal("non-failure status was treated as an explicit failure")
}
}
func TestCrossPlatformCoverageDocReadbackDefensiveEdges(t *testing.T) {
testseam.Swap(t, &docVerifyWait, func(context.Context, time.Duration) error { return nil })
for _, tc := range []struct {
name string
responses []map[string]any
failAt int
}{
{"call failure", nil, 1},
{"missing blocks", []map[string]any{{"ok": true}}, 0},
{"stalled page", []map[string]any{{"blocks": []any{map[string]any{"id": "a"}}, "hasMore": true}, {"blocks": []any{map[string]any{"id": "a"}}, "hasMore": true}}, 0},
{"empty continued page", []map[string]any{{"blocks": []any{}, "hasMore": true}}, 0},
} {
t.Run(tc.name, func(t *testing.T) {
testseam.Swap(t, &docVerifyDelays, []time.Duration{})
caller := &docCoverageCaller{failAt: tc.failAt, responses: map[string][]map[string]any{"list_document_blocks": tc.responses}}
err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes")
if err == nil {
t.Fatal("defensive readback unexpectedly succeeded")
}
})
}
t.Run("identical adjacent pages advance by requested indexes", func(t *testing.T) {
testseam.Swap(t, &docVerifyDelays, []time.Duration{})
blocks := make([]any, docBlockReadPageSize)
for index := range blocks {
blocks[index] = map[string]any{"blockType": "paragraph"}
}
caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {
{"blocks": blocks, "hasMore": true, "totalCount": 2 * docBlockReadPageSize},
{"blocks": blocks, "hasMore": false, "totalCount": 2 * docBlockReadPageSize},
}}}
if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes"); err != nil {
t.Fatal(err)
}
starts := []int{}
for _, call := range caller.history {
if call.tool == "list_document_blocks" {
starts = append(starts, call.params["startIndex"].(int))
}
}
if fmt.Sprint(starts) != "[0 50]" {
t.Fatalf("pagination starts = %v, want [0 50]", starts)
}
})
t.Run("total count terminates pagination", func(t *testing.T) {
testseam.Swap(t, &docVerifyDelays, []time.Duration{})
caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"blocks": []any{map[string]any{"id": "other"}}, "totalCount": 1}}}}
if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes"); err != nil {
t.Fatal(err)
}
})
t.Run("explicit has more overrides inconsistent total count", func(t *testing.T) {
testseam.Swap(t, &docVerifyDelays, []time.Duration{})
caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {
{"blocks": []any{map[string]any{"id": "first"}}, "hasMore": true, "totalCount": 1},
{"blocks": []any{map[string]any{"id": "second"}}, "hasMore": false, "totalCount": 2},
}}}
if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes"); err != nil {
t.Fatal(err)
}
reads := 0
for _, call := range caller.history {
if call.tool == "list_document_blocks" {
reads++
}
}
if reads != 2 {
t.Fatalf("read calls = %d, want both explicitly advertised pages", reads)
}
})
t.Run("block read safety limit", func(t *testing.T) {
testseam.Swap(t, &docVerifyDelays, []time.Duration{})
pages := make([]map[string]any, docBlockReadMaxItems/docBlockReadPageSize)
for index := range pages {
pages[index] = map[string]any{"blocks": []any{map[string]any{"id": fmt.Sprintf("block-%d", index)}}, "hasMore": true}
}
caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": pages}}
if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "block_delete", "--block-id", "target", "--yes"); err == nil {
t.Fatal("oversized block read returned nil")
}
})
if blocks, ok := documentBlockEntries(map[string]any{"jsonml": `["root",{},["p",{"uuid":"a"},"x"]]`}); !ok || len(blocks) == 0 {
t.Fatalf("jsonml blocks=%#v ok=%v", blocks, ok)
}
if _, ok := documentBlockEntries(map[string]any{"jsonml": `{`}); ok {
t.Fatal("invalid jsonml produced blocks")
}
if blocks, ok := documentBlockEntries(map[string]any{"data": map[string]any{"items": []any{"x"}}}); !ok || len(blocks) != 1 {
t.Fatalf("nested items=%#v ok=%v", blocks, ok)
}
if _, ok := documentBlockEntries(nil); ok {
t.Fatal("nil produced blocks")
}
for _, tc := range []struct {
value any
want bool
}{
{map[string]any{"totalCount": float64(2)}, true},
{map[string]any{"totalCount": float64(-1)}, false},
{map[string]any{"totalCount": 2.5}, false},
{map[string]any{"data": map[string]any{"total_count": 2}}, true},
{map[string]any{"totalCount": -1}, false},
{nil, false},
} {
_, ok := nestedNonNegativeInt(tc.value, "totalCount", "total_count")
if ok != tc.want {
t.Fatalf("nestedNonNegativeInt(%#v) ok=%v want=%v", tc.value, ok, tc.want)
}
}
for _, raw := range []string{
`[]`, `[1,["p",{},"x"]]`, `["span",{},"a","b"]`,
`["p",{"block_id":"x","custom":true},"x"]`, `true`,
} {
if normalizeJSONMLForVerification(raw) == "" {
t.Fatalf("empty normalized JSONML for %s", raw)
}
}
if !isGeneratedTextSpan(nil) || isGeneratedTextSpan(map[string]any{"a": 1, "b": 2}) || isGeneratedTextSpan(map[string]any{"data-type": 3}) || isGeneratedTextSpan(map[string]any{"data-type": "other"}) {
t.Fatal("generated span classification failed")
}
}
+55 -3
View File
@@ -15,6 +15,7 @@ import (
"strings"
"sync"
"testing"
"time"
"unicode/utf8"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
@@ -57,7 +58,7 @@ func (f *docCoverageCaller) CallTool(_ context.Context, _, tool string, params m
}
value := docCoveragePayload(tool)
if tool == "revert_doc_version" {
value = map[string]any{"version": params["version"]}
value = map[string]any{"revertedToVersion": params["version"]}
}
if queue := f.responses[tool]; len(queue) > 0 {
value = queue[0]
@@ -112,6 +113,7 @@ func runDocCoverageInput(t *testing.T, declaration shortcut.Shortcut, caller *do
func runDocCoveragePath(t *testing.T, declaration shortcut.Shortcut, caller *docCoverageCaller, input io.Reader, commandPath string, args ...string) error {
t.Helper()
testseam.Swap(t, &docVerifyWait, func(context.Context, time.Duration) error { return nil })
helpers.InitDeps(caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
@@ -491,6 +493,7 @@ func TestCrossPlatformCoverageDocUpdateAliasReachesNestedBranches(t *testing.T)
}
func TestCrossPlatformCoverageDocWritesStopOnUnknownCommitAndRequireVerification(t *testing.T) {
testseam.Swap(t, &docVerifyDelays, []time.Duration{})
unknown := &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}
err := runDocCoverage(t, Update, unknown, "--node", "n", "--command", "append", "--content", "x", "--yes")
var typed *apperrors.Error
@@ -512,6 +515,7 @@ func TestCrossPlatformCoverageDocWritesStopOnUnknownCommitAndRequireVerification
}
func TestCrossPlatformCoverageDocCreateRejectsSuccessfulMismatchedReadback(t *testing.T) {
testseam.Swap(t, &docVerifyDelays, []time.Duration{})
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"get_document_content": {{"markdown": "truncated"}},
}}
@@ -577,11 +581,59 @@ func TestCrossPlatformCoverageDocVersionRevertPaginationAndVerification(t *testi
"revert_doc_version": {{}},
"get_document_info": {{"nodeId": "n", "revision": 99.0}},
}}
if err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes"); err != nil {
t.Fatal(err)
err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes")
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "doc_history_revert_target_unproven" || typed.FailureStage != "verify" || typed.Details["status"] != "partial_success" {
t.Fatalf("unproven revert error = %#v", err)
}
data, _ := typed.Details["data"].(map[string]any)
steps, _ := typed.Details["steps"].([]map[string]any)
if data["verified"] != false || len(steps) != 3 || steps[2]["status"] != "failed" {
t.Fatalf("unproven revert details = %#v", typed.Details)
}
})
for _, test := range []struct {
name string
response map[string]any
current map[string]any
wantOK bool
}{
{name: "explicit server evidence", response: map[string]any{"data": map[string]any{"revertResult": map[string]any{"revertedToVersion": 3}}}, wantOK: true},
{name: "bare request parameter echo", response: map[string]any{"version": 3}},
{name: "nested request parameter echo", response: map[string]any{"data": map[string]any{"request": map[string]any{"nodeId": "n", "version": 3}}}},
{name: "accepted field inside request echo", response: map[string]any{"data": map[string]any{"request": map[string]any{"targetVersion": 3}}}},
{name: "nested business failure with request echo", response: map[string]any{"data": map[string]any{"success": false, "errorCode": "REVERT_FAILED", "request": map[string]any{"version": 3}}}},
{name: "failed status overrides target evidence", response: map[string]any{"data": map[string]any{"status": "FAILED", "revertResult": map[string]any{"revertedToVersion": 3}}}},
{name: "failed code overrides target evidence", response: map[string]any{"data": map[string]any{"code": "REVERT_FAILED", "revertResult": map[string]any{"revertedToVersion": 3}}}},
{
name: "nested business failure overrides all evidence",
response: map[string]any{"data": map[string]any{"state": "FAILURE", "revertResult": map[string]any{"revertedToVersion": 3}}},
current: map[string]any{"targetVersion": 3},
},
} {
t.Run(test.name, func(t *testing.T) {
responses := map[string][]map[string]any{
"revert_doc_version": {test.response},
}
if test.current != nil {
responses["get_document_info"] = []map[string]any{test.current}
}
caller := &docCoverageCaller{responses: responses}
err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes")
if test.wantOK {
if err != nil {
t.Fatal(err)
}
return
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "doc_history_revert_target_unproven" || typed.Details["status"] != "partial_success" {
t.Fatalf("request echo response %#v produced %#v", test.response, err)
}
})
}
for _, test := range []struct {
name string
responses []map[string]any
@@ -178,15 +178,51 @@ func executeHistoryRevert(rt *shortcut.RuntimeContext) error {
map[string]any{"available": false, "reason": "the requested revert completed; verify the current document before any further write"},
)
}
verified := !revertResponseHasExplicitFailure(reverted) &&
(revertResultMatchesVersion(reverted, target) || currentDocumentMatchesRestoredVersion(current, target))
if !verified {
return docPartialWriteError(
"doc.history_revert", "doc_history_revert_target_unproven", "verify",
fmt.Sprintf("版本 %d 的回滚请求已执行且文档可读,但响应没有提供目标版本证据;不要直接重试回滚", target),
fmt.Errorf("回读缺少目标版本 %d 的明确证据", target),
map[string]any{
"nodeId": nodeID, "version": target, "reverted": true, "verified": false,
"revertResult": reverted, "current": current,
},
[]map[string]any{
{"name": "preflight", "status": "success"},
{"name": "revert", "status": "success"},
{"name": "verify", "status": "failed"},
},
map[string]any{"available": false, "reason": "the revert may have completed; inspect version history before any further revert"},
)
}
return rt.Output(docEnvelope("doc.history_revert", map[string]any{
"version": target, "revertResult": reverted, "current": current, "verified": true,
"verification": "revert_acknowledged_and_document_readable",
"verification": "target_version_proven",
},
map[string]any{"name": "preflight", "status": "success"},
map[string]any{"name": "revert", "status": "success"},
map[string]any{"name": "verify", "status": "success"}))
}
func revertResultMatchesVersion(value map[string]any, target int) bool {
if revertResponseHasExplicitFailure(value) {
return false
}
return versionEvidenceMatches(value, target, map[string]bool{
"targetversion": true, "appliedversion": true,
"restoredversion": true, "revertedversion": true, "revertedtoversion": true, "sourceversion": true,
})
}
func currentDocumentMatchesRestoredVersion(value map[string]any, target int) bool {
return versionEvidenceMatches(value, target, map[string]bool{
"restoredfromversion": true, "revertedfromversion": true,
"sourceversion": true, "appliedversion": true, "targetversion": true,
})
}
func findHistoryVersion(rt *shortcut.RuntimeContext, nodeID string, target int) (bool, error) {
const maxPages = 20
cursor := ""
@@ -240,6 +276,9 @@ func versionEvidenceMatches(value any, target int, acceptedKeys map[string]bool)
case map[string]any:
for key, child := range typed {
normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key))
if versionEvidenceRequestEchoKeys[normalized] {
continue
}
if acceptedKeys[normalized] {
if versionNumberMatches(child, target) {
return true
@@ -259,6 +298,87 @@ func versionEvidenceMatches(value any, target int, acceptedKeys map[string]bool)
return false
}
var versionEvidenceRequestEchoKeys = map[string]bool{
"args": true, "arguments": true, "input": true, "inputs": true,
"params": true, "parameters": true, "request": true, "requestbody": true,
"requestparams": true, "toolargs": true, "toolarguments": true,
}
func revertResponseHasExplicitFailure(value any) bool {
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key))
if versionEvidenceRequestEchoKeys[normalized] {
continue
}
if normalized == "success" || normalized == "ok" {
if success, ok := child.(bool); ok && !success {
return true
}
if success, ok := child.(string); ok && strings.EqualFold(strings.TrimSpace(success), "false") {
return true
}
}
if (normalized == "status" || normalized == "state") && revertStatusIsFailure(child) {
return true
}
if normalized == "errorcode" || normalized == "code" {
if revertErrorCodeIsFailure(child) {
return true
}
}
if revertResponseHasExplicitFailure(child) {
return true
}
}
case []any:
for _, child := range typed {
if revertResponseHasExplicitFailure(child) {
return true
}
}
}
return false
}
func revertStatusIsFailure(value any) bool {
status, ok := value.(string)
if !ok {
return false
}
switch strings.ToLower(strings.TrimSpace(status)) {
case "fail", "failed", "failure", "error", "errored", "reject", "rejected", "deny", "denied", "cancel", "cancelled", "canceled", "abort", "aborted":
return true
default:
return false
}
}
func revertErrorCodeIsFailure(value any) bool {
switch typed := value.(type) {
case string:
normalized := strings.ToLower(strings.TrimSpace(typed))
switch normalized {
case "", "ok", "success", "succeed", "succeeded":
return false
}
if parsed, err := strconv.Atoi(normalized); err == nil {
return parsed != 0 && (parsed < 200 || parsed >= 300)
}
return true
case float64:
return typed != 0 && (typed < 200 || typed >= 300)
case json.Number:
parsed, err := typed.Int64()
return err != nil || (parsed != 0 && (parsed < 200 || parsed >= 300))
case int:
return typed != 0 && (typed < 200 || typed >= 300)
default:
return false
}
}
func versionNumberMatches(value any, target int) bool {
switch number := value.(type) {
case float64:
+3 -7
View File
@@ -618,15 +618,11 @@ func completeMinutesUpload(rt *shortcut.RuntimeContext, sessionID string, timeou
}
message := strings.ToLower(err.Error())
retryable := strings.Contains(message, "still uploading") || strings.Contains(message, "wait and retry") || strings.Contains(message, "正在上传")
if !retryable || time.Now().Add(interval).After(deadline) {
if !retryable || minutesPollDeadlineReached(deadline, interval) {
return nil, attempts, err
}
timer := time.NewTimer(interval)
select {
case <-rt.Command().Context().Done():
timer.Stop()
return nil, attempts, rt.Command().Context().Err()
case <-timer.C:
if err := waitMinutesInterval(rt, interval); err != nil {
return nil, attempts, err
}
}
}
+30 -6
View File
@@ -4,6 +4,7 @@
package minutes
import (
"context"
"encoding/json"
"fmt"
"os"
@@ -372,7 +373,7 @@ func runMinutesMindmap(rt *shortcut.RuntimeContext, id string, timeout, interval
payload := map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "taskStatus": status, "attempts": attempts, "result": result, "recovery": map[string]any{"taskUuid": id, "nextAction": "inspect source transcript; do not assume an empty mind map"}}
return payload, minutesCompositeError("minutes_mindmap_failed", "poll", payload)
}
if time.Now().Add(interval).After(deadline) {
if minutesPollDeadlineReached(deadline, interval) {
payload := map[string]any{"operation": "minutes.mindmap", "complete": false, "taskUuid": id, "taskStatus": status, "attempts": attempts, "recovery": map[string]any{"taskUuid": id, "nextAction": "dws minutes mind-graph status --id <taskUuid>"}}
return payload, minutesCompositeError("minutes_mindmap_timeout", "poll", payload)
}
@@ -422,7 +423,7 @@ func runMinutesSpeakerInsights(rt *shortcut.RuntimeContext, id string, timeout,
payload := map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "taskId": taskID, "attempts": attempts, "stage": "poll", "recovery": map[string]any{"taskUuid": id, "taskId": taskID, "nextAction": "dws minutes speaker summary get --ids <taskUuid>"}}
return payload, callErr
}
if time.Now().Add(interval).After(deadline) {
if minutesPollDeadlineReached(deadline, interval) {
payload := map[string]any{"operation": "minutes.speaker_insights", "complete": false, "taskUuid": id, "taskId": taskID, "attempts": attempts, "stage": "poll", "recovery": map[string]any{"taskUuid": id, "taskId": taskID, "nextAction": "dws minutes speaker summary get --ids <taskUuid>"}}
return payload, minutesCompositeError("minutes_speaker_insights_timeout", "poll", payload)
}
@@ -614,6 +615,17 @@ func executeMinutesShare(rt *shortcut.RuntimeContext) error {
}
func executeMinutesUnshare(rt *shortcut.RuntimeContext) error {
if !rt.DryRun() {
for _, id := range minutesIDs(rt) {
data, err := rt.CallMCPData("minutes", "get_minutes_basic_info", map[string]any{"taskUuid": id})
if err != nil {
return fmt.Errorf("minutes unshare preflight for %s: %w", id, err)
}
if _, err := minutesdata.Basic(id, data); err != nil {
return fmt.Errorf("minutes unshare preflight for %s: %w", id, err)
}
}
}
return executeMinutesPermissionLedger(rt, "unshare", "remove_member_permission", func(member string) map[string]any {
return map[string]any{"uuids": minutesIDs(rt), "memberUids": []string{member}}
})
@@ -631,7 +643,11 @@ func executeMinutesPermissionLedger(rt *shortcut.RuntimeContext, operation, tool
for index, member := range members {
data, err := rt.CallMCPWriteDataStrict("minutes", tool, params(member))
if err == nil {
err = minutesdata.RequireWriteAcknowledgement(operation, data)
if operation == "unshare" {
err = minutesdata.RequirePermissionMutationAcknowledgement(operation, minutesIDs(rt), []string{member}, data)
} else {
err = minutesdata.RequireWriteAcknowledgement(operation, data)
}
}
if err != nil {
failures = append(failures, map[string]any{"memberUid": member, "error": err.Error()})
@@ -708,7 +724,7 @@ func waitMinutesArtifacts(rt *shortcut.RuntimeContext, id string, artifacts []st
for {
attempts++
bundle, failures := collectMinutesArtifactsOnce(rt, id, artifacts, pageLimit)
if len(failures) == 0 || time.Now().Add(interval).After(deadline) {
if len(failures) == 0 || minutesPollDeadlineReached(deadline, interval) {
return bundle, failures, attempts
}
if err := waitMinutesInterval(rt, interval); err != nil {
@@ -720,14 +736,22 @@ func waitMinutesArtifacts(rt *shortcut.RuntimeContext, id string, artifacts []st
func waitMinutesInterval(rt *shortcut.RuntimeContext, interval time.Duration) error {
timer := time.NewTimer(interval)
defer timer.Stop()
commandContext := rt.Command().Context()
if commandContext == nil {
commandContext = context.Background()
}
select {
case <-rt.Command().Context().Done():
return rt.Command().Context().Err()
case <-commandContext.Done():
return commandContext.Err()
case <-timer.C:
return nil
}
}
func minutesPollDeadlineReached(deadline time.Time, interval time.Duration) bool {
return !time.Now().Add(interval).Before(deadline)
}
func outputWorkflowResult(rt *shortcut.RuntimeContext, payload map[string]any, failed bool, reason, stage string) error {
if err := rt.Output(payload); err != nil {
return err
@@ -323,7 +323,10 @@ func TestCrossPlatformCoverageMinutesPermissionLedgerBranchesE2E(t *testing.T) {
if err := runMinutesAlignmentCLIWithWriter(t, &minutesE2ECaller{}, minutesFailWriter{}, "minutes", "+unshare", "--id", "u1", "--member-uids", "m1", "--dry-run"); err == nil {
t.Fatal("unshare dry-run output failure accepted")
}
unshare := &minutesE2ECaller{responses: map[string][]string{"minutes/remove_member_permission": {`{"success":true,"result":{}}`}}}
unshare := &minutesE2ECaller{responses: map[string][]string{
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1"}}`},
"minutes/remove_member_permission": {`{"success":true,"result":{"resultMap":{"u1":["m1"]}}}`},
}}
payload, _, err := runMinutesAlignmentCLI(t, unshare, "minutes", "+unshare", "--id", "u1", "--member-uids", "m1", "--yes")
if err != nil || payload["complete"] != true || payload["succeeded"] != float64(1) {
t.Fatalf("unshare payload=%#v err=%v", payload, err)
@@ -337,11 +340,31 @@ func TestCrossPlatformCoverageMinutesPermissionLedgerBranchesE2E(t *testing.T) {
if args["coverPermission"] != "true" || len(args["roleSubResourceIds"].([]string)) != 2 {
t.Fatalf("share args=%#v", args)
}
continueFailure := &minutesE2ECaller{responses: map[string][]string{"minutes/remove_member_permission": {`{"result":{}}`, `{"success":true,"result":{}}`}}}
continueFailure := &minutesE2ECaller{responses: map[string][]string{
"minutes/get_minutes_basic_info": {`{"success":true,"result":{"taskUuid":"u1"}}`},
"minutes/remove_member_permission": {
`{"result":{}}`,
`{"success":true,"result":{"resultMap":{"u1":["m2"]}}}`,
},
}}
payload, output, err := runMinutesAlignmentCLI(t, continueFailure, "minutes", "+unshare", "--id", "u1", "--member-uids", "m1,m2", "--failure-policy", "continue", "--yes")
if err == nil || output == "" || payload["failed"] != float64(1) || payload["succeeded"] != float64(1) {
t.Fatalf("continue ledger payload=%#v err=%v", payload, err)
}
missing := &minutesE2ECaller{responses: map[string][]string{
"minutes/get_minutes_basic_info": {`{"success":true,"result":{}}`},
}}
payload, output, err = runMinutesAlignmentCLI(t, missing, "minutes", "+unshare", "--id", "missing", "--member-uids", "m1", "--yes")
if err == nil || payload != nil || output != "" || missing.counts["minutes/remove_member_permission"] != 0 {
t.Fatalf("missing minutes reached unshare: payload=%#v output=%q err=%v calls=%#v", payload, output, err, missing.counts)
}
preflightFailure := &minutesE2ECaller{failAt: map[string]int{"minutes/get_minutes_basic_info": 1}}
payload, output, err = runMinutesAlignmentCLI(t, preflightFailure, "minutes", "+unshare", "--id", "unavailable", "--member-uids", "m1", "--yes")
if err == nil || payload != nil || output != "" || preflightFailure.counts["minutes/remove_member_permission"] != 0 {
t.Fatalf("failed preflight reached unshare: payload=%#v output=%q err=%v calls=%#v", payload, output, err, preflightFailure.counts)
}
}
func TestCrossPlatformCoverageMinutesArtifactCollectorBranches(t *testing.T) {
@@ -419,10 +442,9 @@ func TestCrossPlatformCoverageMinutesArtifactWaitAndOutput(t *testing.T) {
t.Fatalf("cancel failures=%#v", failures)
}
cmd = &cobra.Command{Use: "wait"}
cmd.SetContext(context.Background())
rt = shortcut.RuntimeContextForTest(cmd, ExportPack)
if err := waitMinutesInterval(rt, 0); err != nil {
t.Fatalf("timer wait: %v", err)
t.Fatalf("timer wait with unset command context: %v", err)
}
cmd.SetOut(minutesFailWriter{})
@@ -564,7 +586,7 @@ func TestCrossPlatformCoverageMinutesExportPackBranchesE2E(t *testing.T) {
}
func TestCrossPlatformCoverageMinutesExportPathAndJSONFaults(t *testing.T) {
if _, _, _, err := prepareExportTarget("../escape"); err == nil {
if _, _, _, err := prepareExportTarget(filepath.Join("..", "escape")); err == nil {
t.Fatal("unsafe target accepted")
}
for _, test := range []struct {
@@ -590,7 +612,7 @@ func TestCrossPlatformCoverageMinutesExportPathAndJSONFaults(t *testing.T) {
}
}},
{name: "escape", run: func(t *testing.T) {
testseam.Swap(t, &minutesRel, func(string, string) (string, error) { return "../escape", nil })
testseam.Swap(t, &minutesRel, func(string, string) (string, error) { return filepath.Join("..", "escape"), nil })
if _, _, _, err := prepareExportTarget("pack"); err == nil {
t.Fatal("rel escape accepted")
}
@@ -602,7 +624,7 @@ func TestCrossPlatformCoverageMinutesExportPathAndJSONFaults(t *testing.T) {
}
}},
{name: "parent", run: func(t *testing.T) {
testseam.Swap(t, &minutesRel, func(string, string) (string, error) { return "parent/pack", nil })
testseam.Swap(t, &minutesRel, func(string, string) (string, error) { return filepath.Join("parent", "pack"), nil })
testseam.Swap(t, &minutesLstat, func(string) (os.FileInfo, error) { return nil, errors.New("parent") })
if _, _, _, err := prepareExportTarget("pack"); err == nil {
t.Fatal("unsafe parent accepted")
@@ -224,6 +224,33 @@ func TestCrossPlatformCoverageMinutesWorkflowCompletion(t *testing.T) {
if err := RequireWriteAcknowledgement("write", map[string]any{"result": map[string]any{"updated": true}}); err != nil {
t.Fatal(err)
}
if err := RequirePermissionMutationAcknowledgement("unshare", []string{"u1"}, []string{"202397"}, map[string]any{
"success": true,
"result": map[string]any{"resultMap": map[string]any{"u1": []any{float64(202397)}}},
}); err != nil {
t.Fatalf("valid permission acknowledgement rejected: %v", err)
}
for _, data := range []map[string]any{
{"success": false, "errorMsg": "denied"},
{"success": "yes"},
{"success": true, "result": "bad"},
{"success": true, "result": map[string]any{}},
{"success": true, "result": map[string]any{"resultMap": map[string]any{}}},
{"success": true, "result": map[string]any{"resultMap": map[string]any{"other": []any{"202397"}}}},
{"success": true, "result": map[string]any{"resultMap": map[string]any{"u1": "bad"}}},
{"success": true, "result": map[string]any{"resultMap": map[string]any{"u1": []any{nil}}}},
{"success": true, "result": map[string]any{"resultMap": map[string]any{"u1": []any{"other"}}}},
} {
if err := RequirePermissionMutationAcknowledgement("unshare", []string{"u1"}, []string{"202397"}, data); err == nil {
t.Fatalf("invalid permission acknowledgement accepted: %#v", data)
}
}
if err := RequirePermissionMutationAcknowledgement("unshare", []string{"u1"}, []string{"202397", "other"}, map[string]any{
"success": true,
"result": map[string]any{"resultMap": map[string]any{"u1": []any{"202397"}}},
}); err == nil {
t.Fatal("incomplete member coverage accepted")
}
for _, tc := range []struct {
cmd, id string
data map[string]any
+55
View File
@@ -30,6 +30,61 @@ func RequireWriteAcknowledgement(operation string, data map[string]any) error {
return fmt.Errorf("minutes %s response has no explicit successful acknowledgement", operation)
}
// RequirePermissionMutationAcknowledgement validates the target-level payload
// returned by add/remove_member_permission. The backend's top-level
// success=true is not sufficient: historically it also accompanied a
// resultMap that merely echoed nonexistent task UUIDs.
func RequirePermissionMutationAcknowledgement(operation string, taskUUIDs, memberUIDs []string, data map[string]any) error {
if err := validateEnvelope(data); err != nil {
return err
}
if success, ok := data["success"].(bool); !ok || !success {
return fmt.Errorf("minutes %s response has no explicit success=true", operation)
}
result, ok := data["result"].(map[string]any)
if !ok {
return fmt.Errorf("minutes %s response has no result object", operation)
}
resultMap, ok := result["resultMap"].(map[string]any)
if !ok {
return fmt.Errorf("minutes %s response has no result.resultMap object", operation)
}
if len(resultMap) != len(taskUUIDs) {
return fmt.Errorf("minutes %s resultMap covers %d task UUIDs, want %d", operation, len(resultMap), len(taskUUIDs))
}
wantMembers := make(map[string]bool, len(memberUIDs))
for _, member := range memberUIDs {
wantMembers[strings.TrimSpace(member)] = true
}
for _, taskUUID := range taskUUIDs {
rawMembers, exists := resultMap[taskUUID]
if !exists {
return fmt.Errorf("minutes %s resultMap is missing task UUID %s", operation, taskUUID)
}
members, ok := rawMembers.([]any)
if !ok {
return fmt.Errorf("minutes %s resultMap[%s] has type %T, want array", operation, taskUUID, rawMembers)
}
gotMembers := make(map[string]bool, len(members))
for index, member := range members {
value := stringField(map[string]any{"member": member}, "member")
if value == "" {
return fmt.Errorf("minutes %s resultMap[%s][%d] is not a member UID", operation, taskUUID, index)
}
gotMembers[value] = true
}
if len(gotMembers) != len(wantMembers) {
return fmt.Errorf("minutes %s resultMap[%s] member coverage mismatch", operation, taskUUID)
}
for member := range wantMembers {
if !gotMembers[member] {
return fmt.Errorf("minutes %s resultMap[%s] is missing member UID %s", operation, taskUUID, member)
}
}
}
return nil
}
// RecordResult validates the gateway's observed listening-note command result.
func RecordResult(expectedCmd, taskUUID string, data map[string]any) (map[string]any, error) {
if err := RequireWriteAcknowledgement("record "+expectedCmd, data); err != nil {
@@ -124,18 +124,25 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
"calendar\u0000+book-search": {},
"calendar\u0000+cancel-event": {},
"calendar\u0000+conflicts": {},
"calendar\u0000+create": {},
"calendar\u0000+free": {},
"calendar\u0000+free-slots": {},
"calendar\u0000+freebusy": {},
"calendar\u0000+get": {},
"calendar\u0000+invite": {},
"calendar\u0000+my-free": {},
"calendar\u0000+next-event": {},
"calendar\u0000+reschedule": {},
"calendar\u0000+room-find": {},
"calendar\u0000+room-groups": {},
"calendar\u0000+room-search": {},
"calendar\u0000+rsvp": {},
"calendar\u0000+search-event": {},
"calendar\u0000+suggest-time": {},
"calendar\u0000+suggestion": {},
"calendar\u0000+today": {},
"calendar\u0000+tomorrow": {},
"calendar\u0000+update": {},
"calendar\u0000+week": {},
"chat\u0000+at-me": {},
"chat\u0000+bot-find": {},
@@ -395,15 +402,25 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
"sheet\u0000+read": {},
"todo\u0000+assign": {},
"todo\u0000+assign-multi": {},
"todo\u0000+comment": {},
"todo\u0000+complete": {},
"todo\u0000+create": {},
"todo\u0000+created-todos": {},
"todo\u0000+due-today": {},
"todo\u0000+get": {},
"todo\u0000+get-my-tasks": {},
"todo\u0000+get-related-tasks": {},
"todo\u0000+list-attachment": {},
"todo\u0000+list-comment": {},
"todo\u0000+list-sub": {},
"todo\u0000+overdue": {},
"todo\u0000+related-tasks": {},
"todo\u0000+remind": {},
"todo\u0000+reminder": {},
"todo\u0000+reopen": {},
"todo\u0000+search": {},
"todo\u0000+todo-done": {},
"todo\u0000+update": {},
"wiki\u0000+delete-space": {},
"wiki\u0000+feed-list": {},
"wiki\u0000+member-add": {},
+8
View File
@@ -28,6 +28,12 @@ var driveSemanticCatalogJSON []byte
//go:embed semantic_catalog_wiki.json
var wikiSemanticCatalogJSON []byte
//go:embed semantic_catalog_calendar.json
var calendarSemanticCatalogJSON []byte
//go:embed semantic_catalog_todo.json
var todoSemanticCatalogJSON []byte
type semanticCatalogFile struct {
Version int `json:"version"`
Service string `json:"service"`
@@ -52,6 +58,8 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
minutesSemanticCatalogJSON,
driveSemanticCatalogJSON,
wikiSemanticCatalogJSON,
calendarSemanticCatalogJSON,
todoSemanticCatalogJSON,
)
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
@@ -0,0 +1,39 @@
{
"version": 1,
"service": "calendar",
"default_availability": "available",
"shortcuts": {
"+agenda": {"disposition":"semantic_adapter","semantic_delta":"严格验证 result.events 与分页证据,稳定投影 id 为 eventId;显式空数组才是空日程。","risk":"read","public":true,"reviewed":true},
"+get": {"disposition":"schema_leaf","semantic_delta":"严格读取日程对象并把稳定 id 规范化为 eventId,拒绝空响应和对象漂移。","risk":"read","public":true,"reviewed":true},
"+create": {"disposition":"primary_smart","semantic_delta":"创建响应必须给出 eventId/id,且标题和起止时间通过详情读回后才成功。","risk":"write","public":true,"reviewed":true},
"+update": {"disposition":"primary_smart","semantic_delta":"组合字段与参会人分阶段更新,明确非事务步骤并以详情和参会人列表读回验证。","risk":"write","public":true,"reviewed":true},
"+rsvp": {"disposition":"semantic_adapter","semantic_delta":"把直白 RSVP 动作映射为 responseStatus,拒绝空 ack,并在写后读取日程终态。","risk":"write","public":true,"reviewed":true},
"+search-event": {"disposition":"semantic_adapter","semantic_delta":"在服务端日程当前页按标题、描述和地点过滤,同时保留 hasMore/nextCursor,绝不把单页零命中当全局未找到。","risk":"read","public":true,"reviewed":true},
"+suggestion": {"disposition":"schema_leaf","semantic_delta":"直接按 userId 查询建议时段,严格区分显式空 recommendEventTimes 与缺失或畸形响应。","risk":"read","public":true,"reviewed":true},
"+attendee-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证参会人数组并投影显示名、响应状态和 self;真实后端不返回稳定参会人 userId,因此不伪造身份字段。","risk":"read","public":true,"reviewed":true},
"+room-search": {"disposition":"semantic_adapter","semantic_delta":"按名称查询会议室但不声称检查时间可用性;缺失会议室数组直接失败。","risk":"read","public":true,"reviewed":true},
"+room-find": {"disposition":"semantic_adapter","semantic_delta":"按单个未来时间段严格查询可用会议室,公开真实 page/pageSize/hasMore 证据和后端筛选边界。","risk":"read","public":true,"reviewed":true},
"+room-groups": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.groupList,避免会议室分组被响应 wrapper 漂移静默清空。","risk":"read","public":true,"reviewed":true},
"+freebusy": {"disposition":"semantic_adapter","semantic_delta":"统一按用户或会议室 ID 查询指定范围闲忙,并声明至少一类目标约束。","risk":"read","public":true,"reviewed":true},
"+book-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证日历本数组并稳定投影 calendarId,缺失数组不再误报为空。","risk":"read","public":true,"reviewed":true},
"+book-search": {"disposition":"semantic_adapter","semantic_delta":"按日历本名称搜索并严格区分零命中与协议错误。","risk":"read","public":true,"reviewed":true},
"+book": {"disposition":"primary_smart","semantic_delta":"按参会人姓名解析后创建日程,并在多步骤路径提供失败回滚与详情读回。","risk":"write","public":true,"reviewed":true},
"+cancel-event": {"disposition":"primary_smart","semantic_delta":"删除前读取并展示目标,经高风险确认后取消指定日程。","risk":"high-risk-write","public":true,"reviewed":true},
"+conflicts": {"disposition":"primary_smart","semantic_delta":"在指定范围分析重叠日程并输出冲突对,避免人工逐项比对。","risk":"read","public":true,"reviewed":true},
"+free": {"disposition":"primary_smart","semantic_delta":"按姓名解析参与者后查询闲忙,省去手工 userId 解析。","risk":"read","public":true,"reviewed":true},
"+free-slots": {"disposition":"primary_smart","semantic_delta":"从已有日程计算工作时段内空档,并显式输出满足时长的候选区间。","risk":"read","public":true,"reviewed":true},
"+invite": {"disposition":"primary_smart","semantic_delta":"按姓名唯一解析用户后邀请到已有日程。","risk":"write","public":true,"reviewed":true},
"+my-free": {"disposition":"primary_smart","semantic_delta":"以当前用户身份查询一个时间段是否空闲。","risk":"read","public":true,"reviewed":true},
"+next-event": {"disposition":"primary_smart","semantic_delta":"在未来时间窗内定位最近的下一场日程。","risk":"read","public":true,"reviewed":true},
"+reschedule": {"disposition":"primary_smart","semantic_delta":"修改前读取目标日程,再更新完整起止时间。","risk":"write","public":true,"reviewed":true},
"+suggest-time": {"disposition":"primary_smart","semantic_delta":"按姓名逐个唯一解析参与者,再查询大家共同可用的建议时间。","risk":"read","public":true,"reviewed":true},
"+today": {"disposition":"primary_smart","semantic_delta":"查询并整理今天的日程视图。","risk":"read","public":true,"reviewed":true},
"+tomorrow": {"disposition":"primary_smart","semantic_delta":"查询并整理明天的日程视图。","risk":"read","public":true,"reviewed":true},
"+week": {"disposition":"primary_smart","semantic_delta":"查询并按日期整理本周日程。","risk":"read","public":true,"reviewed":true},
"+find-room": {"disposition":"alias_internal","semantic_delta":"兼容旧的双时间参数会议室查询入口;新调用统一使用 +room-find。","risk":"read","availability":"deprecated","primary":"+room-find","public":false,"reviewed":true},
"+respond-event": {"disposition":"alias_internal","semantic_delta":"兼容旧的 accept/decline/tentative 响应入口;新调用统一使用 +rsvp。","risk":"write","availability":"deprecated","primary":"+rsvp","public":false,"reviewed":true}
}
}
@@ -0,0 +1,29 @@
{
"version": 1,
"service": "todo",
"default_availability": "available",
"shortcuts": {
"+get-my-tasks": {"disposition":"semantic_adapter","semantic_delta":"严格识别 todoCards/hasMore/page/size;显式空数组才是空结果,并支持有界全量分页。","risk":"read","public":true,"reviewed":true},
"+list-sub": {"disposition":"semantic_adapter","semantic_delta":"严格校验子待办容器、对象元素和稳定 taskId。","risk":"read","public":true,"reviewed":true},
"+get": {"disposition":"semantic_adapter","semantic_delta":"严格绑定 todoDetailModel.taskId,缺失或错绑均失败。","risk":"read","public":true,"reviewed":true},
"+list-attachment": {"disposition":"semantic_adapter","semantic_delta":"严格区分显式空附件与缺失容器,并要求每项稳定附件 ID。","risk":"read","public":true,"reviewed":true},
"+list-comment": {"disposition":"semantic_adapter","semantic_delta":"严格区分显式空评论与缺失容器,并要求每项稳定 commentId。","risk":"read","public":true,"reviewed":true},
"+create": {"disposition":"primary_smart","semantic_delta":"创建后要求稳定 taskId,并读取 todoDetailModel 验证标题。","risk":"write","public":true,"reviewed":true},
"+update": {"disposition":"primary_smart","semantic_delta":"更新指定字段后读取详情逐字段核验。","risk":"write","public":true,"reviewed":true},
"+complete": {"disposition":"primary_smart","semantic_delta":"先读当前状态避免重复写,必要时完成后再读回核验。","risk":"write","public":true,"reviewed":true},
"+reopen": {"disposition":"primary_smart","semantic_delta":"先读当前状态避免重复写,必要时重开后再读回核验。","risk":"write","public":true,"reviewed":true},
"+search": {"disposition":"primary_smart","semantic_delta":"遍历全部 hasMore 分页并按标题匹配;达到页上限仍未耗尽时失败。","risk":"read","public":true,"reviewed":true},
"+comment": {"disposition":"primary_smart","semantic_delta":"新增评论必须取得稳定 commentId,并分页读回验证内容。","risk":"write","public":true,"reviewed":true},
"+upload-attachment": {"disposition":"primary_smart","semantic_delta":"路由到现有原子上传事务;Shortcut 层不复制凭证和带头 OSS PUT 实现。","risk":"write","availability":"unavailable","public":false,"reviewed":true},
"+reminder": {"disposition":"semantic_adapter","semantic_delta":"严格验证提醒参数和 success=true 终端回执;上游不能读回规则,固定 verified=false。","risk":"write","public":true,"reviewed":true},
"+get-related-tasks": {"disposition":"primary_smart","semantic_delta":"遍历与我相关三种角色的全部分页并按 taskId 去重。","risk":"read","public":true,"reviewed":true},
"+related-tasks": {"disposition":"alias_internal","semantic_delta":"保留旧命令拼写,新的 Agent 路由统一使用 +get-related-tasks。","risk":"read","primary":"+get-related-tasks","public":true,"reviewed":true},
"+due-today": {"disposition":"primary_smart","semantic_delta":"按本地日历日构造服务端截止窗口并遍历全部分页。","risk":"read","public":true,"reviewed":true},
"+assign": {"disposition":"primary_smart","semantic_delta":"保留历史按姓名解析后创建并指派的新任务语义,避免同名破坏性变更。","risk":"write","public":true,"reviewed":true},
"+assign-multi": {"disposition":"primary_smart","semantic_delta":"全部姓名唯一解析后才创建一次,并以单一统一结果输出稳定 taskId。","risk":"write","public":true,"reviewed":true},
"+created-todos": {"disposition":"primary_smart","semantic_delta":"遍历创建者角色的全部分页后投影。","risk":"read","public":true,"reviewed":true},
"+overdue": {"disposition":"primary_smart","semantic_delta":"遍历执行者角色的全部分页并按当前时间筛选逾期项。","risk":"read","public":true,"reviewed":true},
"+remind": {"disposition":"primary_smart","semantic_delta":"解析当前用户后创建给自己的待办;该命令不是独立提醒规则。","risk":"write","public":true,"reviewed":true},
"+todo-done": {"disposition":"primary_smart","semantic_delta":"跨全部分页按标题唯一定位后完成任务。","risk":"write","public":true,"reviewed":true}
}
}
+24 -6
View File
@@ -14,9 +14,13 @@
package smart
import (
"encoding/json"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
todoshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/todo"
)
// Assign: create a todo AND assign it to a person by NAME, in one command.
@@ -27,10 +31,11 @@ import (
//
// dws todo +assign --to 张三 --task "整理周报"
var Assign = shortcut.Shortcut{
Service: "todo",
Command: "+assign",
Product: "todo",
Description: "按姓名给某人创建并指派一条待办(自动解析 userId)",
OutputRollout: output.RolloutUnifiedActive,
Service: "todo",
Command: "+assign",
Product: "todo",
Description: "按姓名给某人创建并指派一条待办(自动解析 userId)",
Intent: "当你想把一件事指派给某位同事、但只知道对方姓名不想先查 userId 时使用;" +
"内部先按姓名解析出唯一 userId,再创建待办并把 TA 设为执行人。会真实创建待办。",
Risk: shortcut.RiskWrite,
@@ -47,6 +52,7 @@ var Assign = shortcut.Shortcut{
PrimaryCLIPath: "todo +assign",
},
Description: "按姓名给某人创建并指派一条待办(自动解析 userId)",
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"已验证的单人指派待办","properties":{"taskId":{"type":"string","description":"新待办稳定 taskId"},"subject":{"type":"string","description":"待办标题"},"executorId":{"type":"string","description":"解析出的执行人 userId"},"verified":{"type":"boolean","description":"是否完成详情读回核验"}},"required":["taskId","subject","executorId","verified"],"additionalProperties":false}`)},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
@@ -88,9 +94,21 @@ var Assign = shortcut.Shortcut{
}
vo["dueTime"] = ms
}
return rt.CallMCP("create_personal_todo", map[string]any{
params := map[string]any{
"PersonalTodoCreateVO": vo,
})
}
if rt.DryRun() {
return rt.Output(map[string]any{"dryRun": true, "executed": false, "subject": rt.Str("task")})
}
data, err := rt.CallMCPWriteDataStrict("todo", "create_personal_todo", params)
if err != nil {
return err
}
taskID, _, err := todoshortcut.VerifyCreatedTodo(rt, data, "todo/create_personal_todo", rt.Str("task"))
if err != nil {
return err
}
return rt.Output(map[string]any{"taskId": taskID, "subject": rt.Str("task"), "executorId": user.userID, "verified": true})
},
}
+22 -6
View File
@@ -14,6 +14,7 @@
package smart
import (
"encoding/json"
"fmt"
"strings"
@@ -21,7 +22,9 @@ import (
"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/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
todoshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/todo"
)
// AssignMulti: create ONE personal todo and assign it to SEVERAL people at once,
@@ -41,10 +44,11 @@ import (
//
// dws todo +assign-multi --to "张三,李四,王五" --task "周五前提交排期"
var AssignMulti = shortcut.Shortcut{
Service: "todo",
Command: "+assign-multi",
Product: "todo",
Description: "把一条待办按姓名一次性指派给多个人(自动把每个姓名解析成 userId)",
OutputRollout: output.RolloutUnifiedActive,
Service: "todo",
Command: "+assign-multi",
Product: "todo",
Description: "把一条待办按姓名一次性指派给多个人(自动把每个姓名解析成 userId)",
Intent: "当你想把同一条待办同时指派给好几个同事、但手上只有他们的姓名而不是 userId 时使用;" +
"内部会把 --to 里的每个姓名逐个解析成唯一 userId,只要有任何一个姓名查不到或者重名有歧义," +
"就把这些问题一次性汇总报错、并且完全不创建待办(不会建出只指派了一半人的残缺待办)。" +
@@ -63,6 +67,7 @@ var AssignMulti = shortcut.Shortcut{
PrimaryCLIPath: "todo +assign-multi",
},
Description: "把一条待办按姓名一次性指派给多个人(自动把每个姓名解析成 userId)",
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"已验证的多人指派待办","properties":{"taskId":{"type":"string","description":"新待办稳定 taskId"},"subject":{"type":"string","description":"待办标题"},"executors":{"type":"array","description":"已解析执行人摘要","items":{"type":"string"}},"count":{"type":"integer","description":"执行人数"},"verified":{"type":"boolean","description":"是否完成详情读回核验"}},"required":["taskId","subject","executors","count","verified"],"additionalProperties":false}`)},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
@@ -126,19 +131,30 @@ var AssignMulti = shortcut.Shortcut{
// Create the todo once, assigning all resolved executors. Params mirror the
// todo helper's `task create` call site verbatim.
if err := rt.CallMCP("create_personal_todo", map[string]any{
params := map[string]any{
"PersonalTodoCreateVO": map[string]any{
"subject": task,
"executorIds": executorIDs,
},
}); err != nil {
}
if rt.DryRun() {
return rt.Output(map[string]any{"dryRun": true, "executed": false, "subject": task, "count": len(executorIDs)})
}
data, err := rt.CallMCPWriteDataStrict("todo", "create_personal_todo", params)
if err != nil {
return err
}
taskID, _, err := todoshortcut.VerifyCreatedTodo(rt, data, "todo/create_personal_todo", task)
if err != nil {
return err
}
return rt.Output(map[string]any{
"taskId": taskID,
"subject": task,
"executors": resolved,
"count": len(executorIDs),
"verified": true,
})
},
}
+88 -70
View File
@@ -85,112 +85,130 @@ var Book = shortcut.Shortcut{
`dws calendar +book --title "Q1 复盘会" --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" --with 张三,李四`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
start := rt.Str("start")
end := rt.Str("end")
if err := calendarSmartValidateRange(start, end); err != nil {
return err
}
// create_calendar_event params copied verbatim from the helper's create
// call site: summary + startDateTime/endDateTime (ISO strings, not millis).
createArgs := map[string]any{
"summary": rt.Str("title"),
"startDateTime": rt.Str("start"),
"endDateTime": rt.Str("end"),
}
// Fast path: no participants → create and print in one step.
if !rt.Changed("with") || strings.TrimSpace(rt.Str("with")) == "" {
return rt.CallMCP("create_calendar_event", createArgs)
"startDateTime": start,
"endDateTime": end,
}
// Step 1 — resolve every participant name to a unique userId BEFORE
// creating the event, so an unknown/ambiguous name fails cheaply without
// leaving a dangling event behind.
var userIDs []string
for _, name := range strings.Split(rt.Str("with"), ",") {
name = strings.TrimSpace(name)
if name == "" {
continue
var userNames []string
withPeople := rt.Changed("with") && strings.TrimSpace(rt.Str("with")) != ""
if withPeople {
for _, name := range strings.Split(rt.Str("with"), ",") {
name = strings.TrimSpace(name)
if name == "" {
continue
}
user, err := resolveUser(rt, name)
if err != nil {
return err
}
userIDs = append(userIDs, user.userID)
userNames = append(userNames, user.name)
}
user, err := resolveUser(rt, name)
if err != nil {
return err
if len(userIDs) == 0 {
return apperrors.NewValidation("--with 需要至少一个有效的参会人姓名")
}
userIDs = append(userIDs, user.userID)
}
if len(userIDs) == 0 {
return apperrors.NewValidation("--with 需要至少一个有效的参会人姓名")
}
// Under --dry-run we resolved names (reads) to validate them, but must not
// create the event or send invites. Preview what would happen and return.
if rt.DryRun() {
return rt.Output(map[string]any{
"dryRun": true,
"wouldCreate": createArgs,
"wouldInvite": userIDs,
"success": true,
"dryRun": true,
"executed": false,
"wouldCreate": createArgs,
"inviteeCount": len(userIDs),
})
}
// Step 2 — create the event and pull the eventId from the response.
created, err := rt.CallMCPWriteData("calendar", "create_calendar_event", createArgs)
// Step 2 — create the event and require an explicit terminal receipt plus
// a stable id. Empty/unknown write acknowledgements are never success.
created, err := rt.CallMCPWriteDataStrict("calendar", "create_calendar_event", createArgs)
if err != nil {
return err
}
eventID := shortcutExtractEventID(created)
if err := calendarSmartWriteReceipt(created, "calendar/create_calendar_event"); err != nil {
return err
}
eventID := calendarSmartEventID(created)
if eventID == "" {
return apperrors.NewValidation("日程创建成功但无法从返回结果中解析 eventId,已跳过邀请参会人")
return calendarSmartError("calendar/create_calendar_event", "missing_event_id", "创建回执缺少稳定日程 id,远端效果未知")
}
// Step 3 — add all participants in one batch. attendeesToAdd copied
// verbatim from the helper's `attendee add` call site.
if _, err := rt.CallMCPWriteData("calendar", "add_calendar_participant", map[string]any{
"eventId": eventID,
"attendeesToAdd": userIDs,
}); err != nil {
// Rollback: delete the just-created event so we never leave a
// half-built event without its intended attendees.
_, delErr := rt.CallMCPWriteData("calendar", "delete_calendar_event", map[string]any{
"eventId": eventID,
if len(userIDs) > 0 {
added, addErr := rt.CallMCPWriteDataStrict("calendar", "add_calendar_participant", map[string]any{
"eventId": eventID,
"attendeesToAdd": userIDs,
})
if delErr != nil {
return apperrors.NewValidation(fmt.Sprintf(
"添加参会人失败:%v;且回滚删除日程 %s 也失败:%v(请手动删除该日程)",
err, eventID, delErr))
if addErr == nil {
addErr = calendarSmartWriteReceipt(added, "calendar/add_calendar_participant")
}
if addErr != nil {
// Rollback itself is not enough: verify the newly-created event is
// absent before saying rollback succeeded.
if rollbackErr := calendarSmartDeleteAndVerify(rt, eventID); rollbackErr != nil {
return apperrors.NewValidation(fmt.Sprintf(
"添加参会人失败:%v;回滚删除或删除后验证失败:%v,请人工核查日程状态",
addErr, rollbackErr))
}
return apperrors.NewValidation(fmt.Sprintf("添加参会人失败:%v;新建日程已回滚并验证不存在", addErr))
}
return apperrors.NewValidation(fmt.Sprintf(
"添加参会人失败:%v;已回滚删除日程 %s", err, eventID))
}
// Step 4 — success: print the final event detail (eventId + attendees).
return rt.CallMCP("get_calendar_detail", map[string]any{"eventId": eventID})
// Step 4 — prove the final state before returning one composed result.
readback, err := rt.CallMCPData("calendar", "get_calendar_detail", map[string]any{"eventId": eventID})
if err != nil {
return err
}
event, err := calendarSmartRequireEvent(readback, "calendar/get_calendar_detail", eventID)
if err != nil {
return err
}
if err := calendarSmartVerifyCreatedEvent(event, eventID, rt.Str("title"), start, end); err != nil {
return err
}
if len(userIDs) > 0 {
participants, err := rt.CallMCPData("calendar", "get_calendar_participants", map[string]any{"eventId": eventID})
if err != nil {
return err
}
present, err := calendarSmartAttendees(participants)
if err != nil {
return err
}
currentUserID, err := calendarSmartCurrentUserID(rt, present)
if err != nil {
return err
}
if err := calendarSmartVerifyAttendees(present, userIDs, userNames, currentUserID); err != nil {
return err
}
}
return rt.Output(map[string]any{
"success": true,
"eventId": eventID,
"verified": true,
"event": event,
})
},
}
// shortcutExtractEventID digs the eventId out of a create_calendar_event
// response, tolerating the common shapes ({eventId}, {id}, {result:{...}},
// {event:{...}}) since the exact envelope is not guaranteed.
func shortcutExtractEventID(data map[string]any) string {
if data == nil {
return ""
}
if id := shortcutPickID(data); id != "" {
return id
}
for _, key := range []string{"result", "event", "data"} {
if nested, ok := data[key].(map[string]any); ok {
if id := shortcutPickID(nested); id != "" {
return id
}
}
}
return ""
}
func shortcutPickID(m map[string]any) string {
for _, k := range []string{"eventId", "id"} {
if v, ok := m[k].(string); ok && strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func init() {
finalizeCalendarSmart(&Book, "已创建并通过精确读回验证的日程")
shortcut.Register(Book)
}
+472 -8
View File
@@ -14,10 +14,19 @@
package smart
import (
"encoding/json"
"fmt"
"strings"
"time"
"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/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const calendarSmartMaxPages = 20
// calendarDayRange returns the [00:00, next-day 00:00) window for "today plus
// offsetDays" in the local timezone. Shared by the day-scoped calendar
// shortcuts (+today offset 0, +tomorrow offset 1, +conflicts --in-days).
@@ -28,16 +37,471 @@ func calendarDayRange(offsetDays int) (time.Time, time.Time) {
}
// calendarProjectEvents lists and projects the events in a list_calendar_events
// response into clean {title,start,end,location,eventId} items, skipping events
// that carry no id. Shared by +today / +tomorrow / +week so the projection lives
// in exactly one place (reuses shortcutNextEventProject).
func calendarProjectEvents(data map[string]any) []map[string]any {
// response into clean {title,start,end,location,eventId} items. The strict page
// parser has already required stable ids and valid time boundaries.
func calendarProjectEvents(raw []map[string]any) []map[string]any {
events := make([]map[string]any, 0)
for _, e := range shortcutNextEventList(data) {
if id, _ := e["id"].(string); strings.TrimSpace(id) == "" {
continue
}
for _, e := range raw {
events = append(events, shortcutNextEventProject(e))
}
return events
}
func calendarSmartResult(description string) *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: json.RawMessage(fmt.Sprintf(
`{"type":"object","description":%q,"additionalProperties":true}`,
description,
)),
}
}
func finalizeCalendarSmart(item *shortcut.Shortcut, description string) {
item.OutputRollout = output.RolloutUnifiedActive
item.Contract.Result = calendarSmartResult(description)
}
func calendarSmartError(operation, reason, message string) error {
return apperrors.NewAPI(message,
apperrors.WithOperation(operation),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("response_validation"),
apperrors.WithRetryable(false),
apperrors.WithReason(reason),
)
}
func calendarSmartRequireSuccess(data map[string]any, operation string) (map[string]any, error) {
if len(data) == 0 {
return nil, calendarSmartError(operation, "empty_tool_response", "服务返回空响应,无法证明成功或合法空结果")
}
raw, present := data["success"]
if !present {
return nil, calendarSmartError(operation, "missing_success", "响应缺少 success 终态字段")
}
success, ok := raw.(bool)
if !ok {
return nil, calendarSmartError(operation, "malformed_success", "响应 success 字段不是布尔值")
}
if !success {
message := calendarSmartFirstString(data, "errorMsg", "errorMessage", "message", "error")
if message == "" {
message = "服务明确返回 success=false"
}
return nil, calendarSmartError(operation, "remote_failure", message)
}
return data, nil
}
func calendarSmartRequireEvent(data map[string]any, operation, expectedID string) (map[string]any, error) {
data, err := calendarSmartRequireSuccess(data, operation)
if err != nil {
return nil, err
}
event, ok := data["result"].(map[string]any)
if !ok || len(event) == 0 {
return nil, calendarSmartError(operation, "missing_event", "响应 result 不是非空日程对象")
}
id := calendarSmartFirstString(event, "id", "eventId", "event_id")
if id == "" {
return nil, calendarSmartError(operation, "missing_event_id", "日程对象缺少稳定 id")
}
if expectedID != "" && id != expectedID {
return nil, calendarSmartError(operation, "event_id_mismatch", "读回日程 id 与请求 eventId 不一致")
}
return event, nil
}
func calendarSmartFirstString(data map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func calendarSmartEventID(data map[string]any) string {
if value := calendarSmartFirstString(data, "id", "eventId", "event_id"); value != "" {
return value
}
for _, key := range []string{"result", "event", "data"} {
if nested, ok := data[key].(map[string]any); ok {
if value := calendarSmartFirstString(nested, "id", "eventId", "event_id"); value != "" {
return value
}
}
}
return ""
}
func calendarSmartListAll(rt *shortcut.RuntimeContext, params map[string]any) ([]map[string]any, error) {
all := make([]map[string]any, 0)
seenIDs := map[string]bool{}
cursor := ""
for page := 1; page <= calendarSmartMaxPages; page++ {
request := make(map[string]any, len(params)+2)
for key, value := range params {
request[key] = value
}
request["limit"] = 100
if cursor != "" {
request["cursor"] = cursor
}
data, err := rt.CallMCPData("calendar", "list_calendar_events", request)
if err != nil {
return nil, err
}
items, hasMore, next, err := calendarSmartEventPage(data)
if err != nil {
return nil, err
}
for _, event := range items {
id := calendarSmartFirstString(event, "id", "eventId", "event_id")
if !seenIDs[id] {
seenIDs[id] = true
all = append(all, event)
}
}
if !hasMore {
return all, nil
}
if next == cursor {
return nil, calendarSmartError("calendar/list_calendar_events", "stalled_cursor", "服务端 nextCursor 未前进,不能证明列表完整")
}
cursor = next
}
return nil, calendarSmartError("calendar/list_calendar_events", "page_limit", fmt.Sprintf("自动翻页达到 %d 页上限,拒绝基于不完整事件集给出结论", calendarSmartMaxPages))
}
func calendarSmartEventPage(data map[string]any) ([]map[string]any, bool, string, error) {
data, err := calendarSmartRequireSuccess(data, "calendar/list_calendar_events")
if err != nil {
return nil, false, "", err
}
result, ok := data["result"].(map[string]any)
if !ok {
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "missing_result", "响应 result 不是对象")
}
rawEvents, present := result["events"]
if !present {
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "missing_events", "响应缺少显式 events 数组")
}
list, ok := rawEvents.([]any)
if !ok {
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "malformed_events", "响应 events 字段不是数组")
}
rawHasMore, present := result["hasMore"]
if !present {
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "missing_pagination", "响应缺少 hasMore 分页终态")
}
hasMore, ok := rawHasMore.(bool)
if !ok {
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "malformed_pagination", "响应 hasMore 字段不是布尔值")
}
next := ""
if raw, present := result["nextCursor"]; present && raw != nil {
var ok bool
next, ok = raw.(string)
if !ok {
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "malformed_pagination", "响应 nextCursor 字段不是字符串")
}
next = strings.TrimSpace(next)
}
if hasMore && next == "" {
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "missing_next_cursor", "服务端返回 hasMore=true 但 nextCursor 为空")
}
if !hasMore && next != "" {
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "inconsistent_pagination", "服务端返回 hasMore=false 但 nextCursor 非空")
}
events := make([]map[string]any, 0, len(list))
for index, item := range list {
event, ok := item.(map[string]any)
if !ok || len(event) == 0 {
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "malformed_event", fmt.Sprintf("events[%d] 不是非空对象", index))
}
if calendarSmartFirstString(event, "id", "eventId", "event_id") == "" {
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "missing_event_id", fmt.Sprintf("events[%d] 缺少稳定 id", index))
}
if _, ok := shortcutNextEventStart(event); !ok {
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "malformed_event_start", fmt.Sprintf("events[%d] 缺少合法开始时间", index))
}
if _, ok := conflictsEndTime(event); !ok {
return nil, false, "", calendarSmartError("calendar/list_calendar_events", "malformed_event_end", fmt.Sprintf("events[%d] 缺少合法结束时间", index))
}
events = append(events, event)
}
return events, hasMore, next, nil
}
func calendarSmartBusySlots(data map[string]any) ([]map[string]any, error) {
data, err := calendarSmartRequireSuccess(data, "calendar/query_busy_status")
if err != nil {
return nil, err
}
entries, ok := data["result"].([]any)
if !ok {
return nil, calendarSmartError("calendar/query_busy_status", "missing_result", "响应 result 不是显式忙闲数组")
}
out := make([]map[string]any, 0)
for entryIndex, entry := range entries {
object, ok := entry.(map[string]any)
if !ok || len(object) == 0 {
return nil, calendarSmartError("calendar/query_busy_status", "malformed_result_item", fmt.Sprintf("result[%d] 不是非空对象", entryIndex))
}
rawItems, present := object["scheduleItems"]
if !present {
return nil, calendarSmartError("calendar/query_busy_status", "missing_schedule_items", fmt.Sprintf("result[%d] 缺少 scheduleItems", entryIndex))
}
items, ok := rawItems.([]any)
if !ok {
return nil, calendarSmartError("calendar/query_busy_status", "malformed_schedule_items", fmt.Sprintf("result[%d].scheduleItems 不是数组", entryIndex))
}
for itemIndex, item := range items {
schedule, ok := item.(map[string]any)
if !ok || len(schedule) == 0 {
return nil, calendarSmartError("calendar/query_busy_status", "malformed_schedule_item", fmt.Sprintf("scheduleItems[%d] 不是非空对象", itemIndex))
}
start := freebusyDateTime(schedule["start"])
end := freebusyDateTime(schedule["end"])
if start == nil || end == nil || strings.TrimSpace(fmt.Sprint(start)) == "" || strings.TrimSpace(fmt.Sprint(end)) == "" {
return nil, calendarSmartError("calendar/query_busy_status", "malformed_schedule_time", "忙闲条目缺少开始或结束时间")
}
out = append(out, map[string]any{"start": start, "end": end})
}
}
return out, nil
}
func calendarSmartSuggestedSlots(data map[string]any) ([]map[string]any, error) {
data, err := calendarSmartRequireSuccess(data, "calendar/list_suggested_event_times")
if err != nil {
return nil, err
}
result, ok := data["result"].(map[string]any)
if !ok {
return nil, calendarSmartError("calendar/list_suggested_event_times", "missing_result", "响应 result 不是对象")
}
rawSlots, present := result["recommendEventTimes"]
if !present {
return nil, calendarSmartError("calendar/list_suggested_event_times", "missing_suggestions", "响应缺少显式 recommendEventTimes 数组")
}
slots, ok := rawSlots.([]any)
if !ok {
return nil, calendarSmartError("calendar/list_suggested_event_times", "malformed_suggestions", "recommendEventTimes 不是数组")
}
out := make([]map[string]any, 0, len(slots))
for index, item := range slots {
slot, ok := item.(map[string]any)
if !ok || len(slot) == 0 {
return nil, calendarSmartError("calendar/list_suggested_event_times", "malformed_suggestion", fmt.Sprintf("recommendEventTimes[%d] 不是非空对象", index))
}
start := slot["startTime"]
end := slot["endTime"]
if start == nil || end == nil {
return nil, calendarSmartError("calendar/list_suggested_event_times", "malformed_suggestion_time", fmt.Sprintf("recommendEventTimes[%d] 缺少 startTime/endTime", index))
}
row := map[string]any{"start": start, "end": end}
if raw, ok := slot["timeConflictAttendees"].([]any); ok {
conflicts := make([]any, 0, len(raw))
for _, value := range raw {
if value != nil {
conflicts = append(conflicts, value)
}
}
if len(conflicts) > 0 {
row["conflicts"] = conflicts
}
}
out = append(out, row)
}
return out, nil
}
func calendarSmartAttendees(data map[string]any) (map[string]bool, error) {
data, err := calendarSmartRequireSuccess(data, "calendar/get_calendar_participants")
if err != nil {
return nil, err
}
var raw any
result, present := data["result"]
if !present {
return nil, calendarSmartError("calendar/get_calendar_participants", "missing_result", "参会人响应缺少 result")
}
switch typed := result.(type) {
case []any:
raw = typed
case map[string]any:
found := false
for _, key := range []string{"attendees", "participants", "items", "list"} {
if raw, found = typed[key]; found {
break
}
}
if !found {
return nil, calendarSmartError("calendar/get_calendar_participants", "missing_attendees", "响应缺少显式参会人数组")
}
default:
return nil, calendarSmartError("calendar/get_calendar_participants", "malformed_result", "参会人响应 result 既不是数组也不是含参会人数组的对象")
}
items, ok := raw.([]any)
if !ok {
return nil, calendarSmartError("calendar/get_calendar_participants", "malformed_attendees", "参会人字段不是数组")
}
identities := map[string]bool{}
for index, item := range items {
attendee, ok := item.(map[string]any)
if !ok || len(attendee) == 0 {
return nil, calendarSmartError("calendar/get_calendar_participants", "malformed_attendee", fmt.Sprintf("参会人第 %d 项不是非空对象", index))
}
id := calendarSmartFirstString(attendee, "userId", "user_id", "id", "staffId")
name := calendarSmartFirstString(attendee, "displayName", "display_name", "name", "userName")
if id == "" && name == "" {
return nil, calendarSmartError("calendar/get_calendar_participants", "missing_attendee_identity", fmt.Sprintf("参会人第 %d 项既无 userId 也无 displayName", index))
}
if id != "" {
identities[id] = true
}
if name != "" {
identities[name] = true
}
if rawSelf, present := attendee["self"]; present {
isSelf, ok := rawSelf.(bool)
if !ok {
return nil, calendarSmartError("calendar/get_calendar_participants", "malformed_attendee_self", fmt.Sprintf("参会人第 %d 项 self 不是布尔值", index))
}
if isSelf {
identities["__self__"] = true
}
}
}
return identities, nil
}
func calendarSmartVerifyEventTimes(event map[string]any, start, end string) error {
startValue, startOK := calendarSmartEventTime(event["start"])
if !startOK {
startValue, startOK = calendarSmartEventTime(event["startDateTime"])
}
endValue, endOK := calendarSmartEventTime(event["end"])
if !endOK {
endValue, endOK = calendarSmartEventTime(event["endDateTime"])
}
wantStart, startErr := time.Parse(time.RFC3339, start)
wantEnd, endErr := time.Parse(time.RFC3339, end)
if startErr != nil || endErr != nil || !startOK || !endOK || !startValue.Equal(wantStart) || !endValue.Equal(wantEnd) {
return calendarSmartError("calendar/get_calendar_detail", "readback_time_mismatch", "写后读回的开始/结束时间与请求不一致")
}
return nil
}
func calendarSmartEventTime(value any) (time.Time, bool) {
if object, ok := value.(map[string]any); ok {
value = object["dateTime"]
}
text, ok := value.(string)
if !ok || strings.TrimSpace(text) == "" {
return time.Time{}, false
}
parsed, err := time.Parse(time.RFC3339, text)
return parsed, err == nil
}
func calendarSmartNotFound(err error) bool {
if err == nil {
return false
}
message := strings.ToLower(err.Error())
for _, marker := range []string{"not found", "not_found", "不存在", "未找到", "404"} {
if strings.Contains(message, marker) {
return true
}
}
return false
}
func calendarSmartValidateRange(start, end string) error {
startTime, startErr := time.Parse(time.RFC3339, strings.TrimSpace(start))
if startErr != nil {
return apperrors.NewValidation("--start 时间格式无效,请使用 RFC3339/ISO8601")
}
endTime, endErr := time.Parse(time.RFC3339, strings.TrimSpace(end))
if endErr != nil {
return apperrors.NewValidation("--end 时间格式无效,请使用 RFC3339/ISO8601")
}
if !endTime.After(startTime) {
return apperrors.NewValidation("--end 必须晚于 --start")
}
return nil
}
func calendarSmartWriteReceipt(data map[string]any, operation string) error {
_, err := calendarSmartRequireSuccess(data, operation)
return err
}
func calendarSmartVerifyCreatedEvent(event map[string]any, eventID, title, start, end string) error {
if id := calendarSmartFirstString(event, "id", "eventId", "event_id"); id != eventID {
return calendarSmartError("calendar/get_calendar_detail", "readback_event_id_mismatch", "创建后读回的日程 id 与创建回执不一致")
}
if got := calendarSmartFirstString(event, "summary", "title"); got != strings.TrimSpace(title) {
return calendarSmartError("calendar/get_calendar_detail", "readback_title_mismatch", "创建后读回的日程标题与请求不一致")
}
return calendarSmartVerifyEventTimes(event, start, end)
}
func calendarSmartVerifyAttendees(present map[string]bool, expectedIDs, expectedNames []string, currentUserID string) error {
for index, id := range expectedIDs {
name := ""
if index < len(expectedNames) {
name = expectedNames[index]
}
isCurrentUser := currentUserID != "" && id == currentUserID && present["__self__"]
if !present[id] && (name == "" || !present[name]) && !isCurrentUser {
return calendarSmartError("calendar/get_calendar_participants", "readback_attendee_missing", "写后读回未找到请求添加的参会人")
}
}
return nil
}
func calendarSmartCurrentUserID(rt *shortcut.RuntimeContext, attendees map[string]bool) (string, error) {
if !attendees["__self__"] {
return "", nil
}
profile, err := rt.CallMCPData("contact", "get_current_user_profile", nil)
if err != nil {
return "", err
}
userID := myAttendanceCurrentUserID(profile)
if userID == "" {
return "", calendarSmartError("contact/get_current_user_profile", "missing_current_user_id", "参会人读回只标记 self,但无法解析当前用户 userId")
}
return userID, nil
}
func calendarSmartDeleteAndVerify(rt *shortcut.RuntimeContext, eventID string) error {
written, err := rt.CallMCPWriteDataStrict("calendar", "delete_calendar_event", map[string]any{"eventId": eventID})
if err != nil {
return err
}
if err := calendarSmartWriteReceipt(written, "calendar/delete_calendar_event"); err != nil {
return err
}
data, err := rt.CallMCPData("calendar", "get_calendar_detail", map[string]any{"eventId": eventID})
if err != nil {
if calendarSmartNotFound(err) {
return nil
}
return calendarSmartError("calendar/get_calendar_detail", "delete_readback_unknown", "删除后读回失败,无法证明日程已不存在")
}
event, err := calendarSmartRequireEvent(data, "calendar/get_calendar_detail", eventID)
if err != nil {
return err
}
status := strings.ToLower(calendarSmartFirstString(event, "status", "eventStatus", "event_status"))
if status == "cancelled" || status == "canceled" || status == "deleted" {
return nil
}
return calendarSmartError("calendar/get_calendar_detail", "delete_readback_present", "删除后读回日程仍然存在")
}
@@ -0,0 +1,547 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package smart
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type calendarSmartTestStep struct {
text string
err error
}
type calendarSmartTestCall struct {
key string
args map[string]any
}
type calendarSmartTestCaller struct {
steps map[string][]calendarSmartTestStep
counts map[string]int
calls []calendarSmartTestCall
}
func (c *calendarSmartTestCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
if c.counts == nil {
c.counts = map[string]int{}
}
key := product + "/" + tool
c.counts[key]++
c.calls = append(c.calls, calendarSmartTestCall{key: key, args: args})
steps := c.steps[key]
index := c.counts[key] - 1
if index >= len(steps) {
return nil, errors.New("unexpected fixture call: " + key)
}
step := steps[index]
if step.err != nil {
return nil, step.err
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: step.text}}}, nil
}
func (c *calendarSmartTestCaller) CallReadTool(ctx context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
return c.CallTool(ctx, product, tool, args)
}
func (*calendarSmartTestCaller) Format() string { return "json" }
func (*calendarSmartTestCaller) DryRun() bool { return false }
func (*calendarSmartTestCaller) Fields() string { return "" }
func (*calendarSmartTestCaller) JQ() string { return "" }
func runCalendarSmartCLI(t *testing.T, caller edition.ToolCaller, args ...string) (map[string]any, string, error) {
t.Helper()
helpers.InitDepsForTest(t, caller)
root := newPlatformCoverageRoot()
var stdout bytes.Buffer
root.SetOut(&stdout)
root.SetErr(io.Discard)
root.SetArgs(args)
executed, err := root.ExecuteC()
if err == nil {
if _, _, emitErr := output.EmitStoredResult(executed); emitErr != nil {
err = emitErr
}
}
if stdout.Len() == 0 {
return nil, "", err
}
var payload map[string]any
if decodeErr := json.Unmarshal(stdout.Bytes(), &payload); decodeErr != nil {
t.Fatalf("calendar output is not exactly one JSON value: %q: %v", stdout.String(), decodeErr)
}
if data, ok := payload["data"].(map[string]any); ok {
return data, stdout.String(), err
}
return payload, stdout.String(), err
}
func calendarSmartTestEvent(id string) map[string]any {
return map[string]any{
"id": id,
"summary": "fixture event",
"start": map[string]any{"dateTime": "2026-08-17T09:00:00+08:00"},
"end": map[string]any{"dateTime": "2026-08-17T10:00:00+08:00"},
}
}
func TestCrossPlatformCoverageCalendarSmartResultContracts(t *testing.T) {
shortcuts := []struct {
name string
rollout output.RolloutState
result bool
}{
{"today", Today.OutputRollout, Today.Contract.Result != nil},
{"tomorrow", Tomorrow.OutputRollout, Tomorrow.Contract.Result != nil},
{"week", Week.OutputRollout, Week.Contract.Result != nil},
{"next-event", NextEvent.OutputRollout, NextEvent.Contract.Result != nil},
{"conflicts", Conflicts.OutputRollout, Conflicts.Contract.Result != nil},
{"free-slots", FreeSlots.OutputRollout, FreeSlots.Contract.Result != nil},
{"suggest-time", SuggestTime.OutputRollout, SuggestTime.Contract.Result != nil},
{"book", Book.OutputRollout, Book.Contract.Result != nil},
{"cancel-event", CancelEvent.OutputRollout, CancelEvent.Contract.Result != nil},
{"invite", Invite.OutputRollout, Invite.Contract.Result != nil},
{"reschedule", Reschedule.OutputRollout, Reschedule.Contract.Result != nil},
{"free", FreeBusy.OutputRollout, FreeBusy.Contract.Result != nil},
{"my-free", MyFree.OutputRollout, MyFree.Contract.Result != nil},
}
for _, item := range shortcuts {
if item.rollout != output.RolloutUnifiedActive || !item.result {
t.Errorf("%s rollout=%q result=%v", item.name, item.rollout, item.result)
}
}
}
func TestCrossPlatformCoverageCalendarSmartStrictCollections(t *testing.T) {
valid := calendarSmartTestEvent("event-placeholder")
items, hasMore, next, err := calendarSmartEventPage(map[string]any{
"success": true,
"result": map[string]any{
"events": []any{valid},
"hasMore": true,
"nextCursor": "cursor-placeholder",
},
})
if err != nil || len(items) != 1 || !hasMore || next != "cursor-placeholder" {
t.Fatalf("valid page items=%#v hasMore=%v next=%q err=%v", items, hasMore, next, err)
}
items, hasMore, next, err = calendarSmartEventPage(map[string]any{
"success": true,
"result": map[string]any{"events": []any{}, "hasMore": false},
})
if err != nil || items == nil || len(items) != 0 || hasMore || next != "" {
t.Fatalf("explicit empty page items=%#v hasMore=%v next=%q err=%v", items, hasMore, next, err)
}
invalidPages := []map[string]any{
{},
{"success": false, "result": map[string]any{"events": []any{}, "hasMore": false}},
{"success": true, "result": map[string]any{"hasMore": false}},
{"success": true, "result": map[string]any{"events": map[string]any{}, "hasMore": false}},
{"success": true, "result": map[string]any{"events": []any{}, "hasMore": true}},
{"success": true, "result": map[string]any{"events": []any{}, "hasMore": false, "nextCursor": "unexpected"}},
{"success": true, "result": map[string]any{"events": []any{map[string]any{"id": "event-placeholder"}}, "hasMore": false}},
{"success": true, "result": map[string]any{"events": []any{"bad-item"}, "hasMore": false}},
}
for index, page := range invalidPages {
if _, _, _, err := calendarSmartEventPage(page); err == nil {
t.Errorf("invalid page %d accepted: %#v", index, page)
}
}
allDay := map[string]any{
"id": "all-day-placeholder",
"start": map[string]any{"date": "2026-08-17"},
"end": map[string]any{"date": "2026-08-18"},
}
if _, _, _, err := calendarSmartEventPage(map[string]any{
"success": true,
"result": map[string]any{"events": []any{allDay}, "hasMore": false},
}); err != nil {
t.Fatalf("legitimate all-day event rejected: %v", err)
}
if slots, err := calendarSmartBusySlots(map[string]any{"success": true, "result": []any{}}); err != nil || slots == nil || len(slots) != 0 {
t.Fatalf("explicit empty busy slots=%#v err=%v", slots, err)
}
if _, err := calendarSmartBusySlots(map[string]any{"success": true, "result": []any{map[string]any{}}}); err == nil {
t.Fatal("busy result item without scheduleItems accepted")
}
if slots, err := calendarSmartSuggestedSlots(map[string]any{"success": true, "result": map[string]any{"recommendEventTimes": []any{}}}); err != nil || slots == nil || len(slots) != 0 {
t.Fatalf("explicit empty suggestions=%#v err=%v", slots, err)
}
if _, err := calendarSmartSuggestedSlots(map[string]any{"success": true, "result": map[string]any{}}); err == nil {
t.Fatal("missing recommendEventTimes accepted")
}
if _, err := calendarSmartSuggestedSlots(map[string]any{"success": true, "result": map[string]any{"recommendEventTimes": []any{map[string]any{"startTime": "x"}}}}); err == nil {
t.Fatal("suggestion without endTime accepted")
}
}
func TestCrossPlatformCoverageCalendarSmartPaginationIsCompleteOrFails(t *testing.T) {
eventJSON := `{"id":"event-placeholder","summary":"fixture","start":{"dateTime":"2026-08-17T09:00:00+08:00"},"end":{"dateTime":"2026-08-17T10:00:00+08:00"}}`
caller := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{
"calendar/list_calendar_events": {
{text: `{"success":true,"result":{"events":[` + eventJSON + `],"hasMore":true,"nextCursor":"cursor-placeholder"}}`},
{text: `{"success":true,"result":{"events":[],"hasMore":false}}`},
},
}}
payload, _, err := runCalendarSmartCLI(t, caller, "calendar", "+today")
if err != nil || payload["complete"] != true || payload["count"] != float64(1) {
t.Fatalf("today payload=%#v err=%v", payload, err)
}
if caller.counts["calendar/list_calendar_events"] != 2 {
t.Fatalf("list calls=%#v", caller.counts)
}
if got := caller.calls[1].args["cursor"]; got != "cursor-placeholder" {
t.Fatalf("second-page cursor=%#v calls=%#v", got, caller.calls)
}
for name, responses := range map[string][]calendarSmartTestStep{
"missing pagination": {{text: `{"success":true,"result":{"events":[]}}`}},
"stalled cursor": {
{text: `{"success":true,"result":{"events":[],"hasMore":true,"nextCursor":"cursor-placeholder"}}`},
{text: `{"success":true,"result":{"events":[],"hasMore":true,"nextCursor":"cursor-placeholder"}}`},
},
} {
t.Run(name, func(t *testing.T) {
bad := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"calendar/list_calendar_events": responses}}
payload, outputText, err := runCalendarSmartCLI(t, bad, "calendar", "+today")
if err == nil || payload != nil || outputText != "" {
t.Fatalf("incomplete list accepted payload=%#v output=%q err=%v", payload, outputText, err)
}
})
}
}
func TestCrossPlatformCoverageCalendarSmartWriteReceiptsAndReadback(t *testing.T) {
const start = "2026-08-17T09:00:00+08:00"
const end = "2026-08-17T10:00:00+08:00"
const eventID = "event-placeholder"
book := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{
"calendar/create_calendar_event": {{text: `{"success":true,"result":{"id":"` + eventID + `"}}`}},
"calendar/get_calendar_detail": {{text: `{"success":true,"result":{"id":"` + eventID + `","summary":"fixture title","start":{"dateTime":"` + start + `"},"end":{"dateTime":"` + end + `"}}}`}},
}}
payload, _, err := runCalendarSmartCLI(t, book, "calendar", "+book", "--title", "fixture title", "--start", start, "--end", end, "--yes")
if err != nil || payload["success"] != true || payload["verified"] != true || payload["eventId"] != eventID {
t.Fatalf("book payload=%#v err=%v", payload, err)
}
unknownWrite := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{
"calendar/create_calendar_event": {{text: `{"result":{"id":"` + eventID + `"}}`}},
}}
payload, outputText, err := runCalendarSmartCLI(t, unknownWrite, "calendar", "+book", "--title", "fixture title", "--start", start, "--end", end, "--yes")
if err == nil || payload != nil || outputText != "" || unknownWrite.counts["calendar/get_calendar_detail"] != 0 {
t.Fatalf("unknown create receipt accepted payload=%#v output=%q err=%v counts=%#v", payload, outputText, err, unknownWrite.counts)
}
reschedule := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{
"calendar/get_calendar_detail": {
{text: `{"success":true,"result":{"id":"` + eventID + `"}}`},
{text: `{"success":true,"result":{"id":"` + eventID + `","start":{"dateTime":"` + start + `"},"end":{"dateTime":"` + end + `"}}}`},
},
"calendar/update_calendar_event": {{text: `{"success":true}`}},
}}
payload, _, err = runCalendarSmartCLI(t, reschedule, "calendar", "+reschedule", "--event", eventID, "--start", start, "--end", end, "--yes")
if err != nil || payload["success"] != true || payload["verified"] != true {
t.Fatalf("reschedule payload=%#v err=%v", payload, err)
}
updates := reschedule.calls
var updateArgs map[string]any
for _, call := range updates {
if call.key == "calendar/update_calendar_event" {
updateArgs = call.args
}
}
if updateArgs["eventId"] != eventID || updateArgs["startDateTime"] != start || updateArgs["endDateTime"] != end {
t.Fatalf("reschedule request=%#v", updateArgs)
}
invite := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{
"contact/search_contact_by_key_word": {{text: `{"result":[{"userId":"user-placeholder","name":"fixture person"}]}`}},
"calendar/get_calendar_detail": {{text: `{"success":true,"result":{"id":"` + eventID + `"}}`}},
"calendar/add_calendar_participant": {{text: `{"success":true}`}},
"calendar/get_calendar_participants": {{text: `{"success":true,"result":[{"userId":"user-placeholder"}]}`}},
}}
payload, _, err = runCalendarSmartCLI(t, invite, "calendar", "+invite", "--event", eventID, "--with", "fixture person", "--yes")
if err != nil || payload["success"] != true || payload["verified"] != true || payload["invitedCount"] != float64(1) {
t.Fatalf("invite payload=%#v err=%v", payload, err)
}
}
func TestCrossPlatformCoverageCalendarSmartDeleteAndRollbackVerifyAbsence(t *testing.T) {
const eventID = "event-placeholder"
const sensitiveTitle = "fixture-sensitive-title"
cancel := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{
"calendar/get_calendar_detail": {
{text: `{"success":true,"result":{"id":"` + eventID + `","summary":"` + sensitiveTitle + `"}}`},
{err: errors.New("event not found")},
},
"calendar/delete_calendar_event": {{text: `{"success":true}`}},
}}
payload, outputText, err := runCalendarSmartCLI(t, cancel, "calendar", "+cancel-event", "--event", eventID, "--yes")
if err != nil || payload["success"] != true || payload["deleted"] != true || payload["verified"] != true {
t.Fatalf("cancel payload=%#v err=%v", payload, err)
}
if strings.Contains(outputText, sensitiveTitle) {
t.Fatalf("pre-delete PII leaked into output: %q", outputText)
}
if cancel.counts["calendar/get_calendar_detail"] != 2 {
t.Fatalf("cancel did not verify absence: %#v", cancel.counts)
}
tombstone := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{
"calendar/get_calendar_detail": {
{text: `{"success":true,"result":{"id":"` + eventID + `"}}`},
{text: `{"success":true,"result":{"id":"` + eventID + `","status":"cancelled"}}`},
},
"calendar/delete_calendar_event": {{text: `{"success":true}`}},
}}
payload, _, err = runCalendarSmartCLI(t, tombstone, "calendar", "+cancel-event", "--event", eventID, "--yes")
if err != nil || payload["success"] != true || payload["verified"] != true {
t.Fatalf("cancel tombstone payload=%#v err=%v", payload, err)
}
unknownReadback := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{
"calendar/get_calendar_detail": {
{text: `{"success":true,"result":{"id":"` + eventID + `"}}`},
{err: errors.New("temporary readback failure")},
},
"calendar/delete_calendar_event": {{text: `{"success":true}`}},
}}
payload, outputText, err = runCalendarSmartCLI(t, unknownReadback, "calendar", "+cancel-event", "--event", eventID, "--yes")
if err == nil || payload != nil || outputText != "" {
t.Fatalf("unknown delete readback accepted payload=%#v output=%q err=%v", payload, outputText, err)
}
rollback := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{
"contact/search_contact_by_key_word": {{text: `{"result":[{"userId":"user-placeholder","name":"fixture person"}]}`}},
"calendar/create_calendar_event": {{text: `{"success":true,"result":{"id":"` + eventID + `"}}`}},
"calendar/add_calendar_participant": {{text: `{"success":false,"message":"fixture rejection"}`}},
"calendar/delete_calendar_event": {{text: `{"success":true}`}},
"calendar/get_calendar_detail": {{err: errors.New("event not found")}},
}}
payload, outputText, err = runCalendarSmartCLI(t, rollback, "calendar", "+book", "--title", "fixture title", "--start", "2026-08-17T09:00:00+08:00", "--end", "2026-08-17T10:00:00+08:00", "--with", "fixture person", "--yes")
if err == nil || payload != nil || outputText != "" || rollback.counts["calendar/delete_calendar_event"] != 1 || rollback.counts["calendar/get_calendar_detail"] != 1 {
t.Fatalf("rollback was not verified payload=%#v output=%q err=%v counts=%#v", payload, outputText, err, rollback.counts)
}
}
func TestCrossPlatformCoverageCalendarSmartSharedFailureBranches(t *testing.T) {
for _, response := range []map[string]any{{}, {"success": "yes"}, {"success": false}} {
if _, err := calendarSmartRequireSuccess(response, "calendar/test"); err == nil {
t.Fatalf("bad success envelope accepted: %#v", response)
}
}
for _, response := range []map[string]any{
{"success": true},
{"success": true, "result": map[string]any{}},
{"success": true, "result": map[string]any{"summary": "missing id"}},
{"success": true, "result": map[string]any{"id": "wrong"}},
} {
if _, err := calendarSmartRequireEvent(response, "calendar/get", "event-1"); err == nil {
t.Fatalf("bad event accepted: %#v", response)
}
}
if event, err := calendarSmartRequireEvent(map[string]any{"success": true, "result": map[string]any{"id": "event-1"}}, "calendar/get", "event-1"); err != nil || event["id"] != "event-1" {
t.Fatalf("valid event=%#v %v", event, err)
}
if calendarSmartEventID(map[string]any{"eventId": "direct"}) != "direct" || calendarSmartEventID(map[string]any{"data": map[string]any{"id": "nested"}}) != "nested" || calendarSmartEventID(map[string]any{}) != "" {
t.Fatal("event id discovery drift")
}
invalidPages := []map[string]any{
{"success": true, "result": []any{}},
{"success": true, "result": map[string]any{"events": []any{}, "hasMore": "bad"}},
{"success": true, "result": map[string]any{"events": []any{}, "hasMore": true, "nextCursor": 1}},
{"success": true, "result": map[string]any{"events": []any{map[string]any{"summary": "no id", "start": map[string]any{"dateTime": "2026-08-17T09:00:00+08:00"}, "end": map[string]any{"dateTime": "2026-08-17T10:00:00+08:00"}}}, "hasMore": false}},
{"success": true, "result": map[string]any{"events": []any{map[string]any{"id": "x", "end": map[string]any{"dateTime": "2026-08-17T10:00:00+08:00"}}}, "hasMore": false}},
{"success": true, "result": map[string]any{"events": []any{map[string]any{"id": "x", "start": map[string]any{"dateTime": "2026-08-17T09:00:00+08:00"}}}, "hasMore": false}},
}
for index, response := range invalidPages {
if _, _, _, err := calendarSmartEventPage(response); err == nil {
t.Fatalf("bad page %d accepted", index)
}
}
for _, response := range []map[string]any{
{"success": true, "result": map[string]any{}},
{"success": true, "result": []any{"bad"}},
{"success": true, "result": []any{map[string]any{"other": true}}},
{"success": true, "result": []any{map[string]any{"scheduleItems": map[string]any{}}}},
{"success": true, "result": []any{map[string]any{"scheduleItems": []any{"bad"}}}},
{"success": true, "result": []any{map[string]any{"scheduleItems": []any{map[string]any{"start": ""}}}}},
} {
if _, err := calendarSmartBusySlots(response); err == nil {
t.Fatalf("bad busy slots accepted: %#v", response)
}
}
busy, err := calendarSmartBusySlots(map[string]any{"success": true, "result": []any{map[string]any{"scheduleItems": []any{map[string]any{"start": map[string]any{"dateTime": "s"}, "end": map[string]any{"date": "e"}}}}}})
if err != nil || len(busy) != 1 {
t.Fatalf("busy slots=%#v %v", busy, err)
}
for _, response := range []map[string]any{
{"success": true, "result": []any{}},
{"success": true, "result": map[string]any{"recommendEventTimes": map[string]any{}}},
{"success": true, "result": map[string]any{"recommendEventTimes": []any{"bad"}}},
} {
if _, err := calendarSmartSuggestedSlots(response); err == nil {
t.Fatalf("bad suggested slots accepted: %#v", response)
}
}
slots, err := calendarSmartSuggestedSlots(map[string]any{"success": true, "result": map[string]any{"recommendEventTimes": []any{map[string]any{"startTime": "s", "endTime": "e", "timeConflictAttendees": []any{nil, "u"}}}}})
if err != nil || len(slots) != 1 || len(slots[0]["conflicts"].([]any)) != 1 {
t.Fatalf("suggested slots=%#v %v", slots, err)
}
for _, response := range []map[string]any{
{"success": false},
{"success": true},
{"success": true, "result": map[string]any{}},
{"success": true, "result": "bad"},
{"success": true, "result": map[string]any{"attendees": map[string]any{}}},
{"success": true, "result": []any{"bad"}},
{"success": true, "result": []any{map[string]any{"role": "none"}}},
{"success": true, "result": []any{map[string]any{"userId": "u", "self": "yes"}}},
} {
if _, err := calendarSmartAttendees(response); err == nil {
t.Fatalf("bad attendees accepted: %#v", response)
}
}
attendees, err := calendarSmartAttendees(map[string]any{"success": true, "result": map[string]any{"participants": []any{map[string]any{"userId": "u", "displayName": "name", "self": true}}}})
if err != nil || !attendees["u"] || !attendees["name"] || !attendees["__self__"] {
t.Fatalf("attendees=%#v %v", attendees, err)
}
event := map[string]any{"startDateTime": "2026-08-17T09:00:00+08:00", "endDateTime": "2026-08-17T10:00:00+08:00"}
if err := calendarSmartVerifyEventTimes(event, "2026-08-17T09:00:00+08:00", "2026-08-17T10:00:00+08:00"); err != nil {
t.Fatal(err)
}
if err := calendarSmartVerifyEventTimes(event, "bad", "2026-08-17T10:00:00+08:00"); err == nil {
t.Fatal("bad expected event time accepted")
}
if _, ok := calendarSmartEventTime(1); ok {
t.Fatal("non-string event time accepted")
}
if _, ok := calendarSmartEventTime("bad"); ok {
t.Fatal("bad event time accepted")
}
if calendarSmartNotFound(nil) || !calendarSmartNotFound(errors.New("404 not found")) || calendarSmartNotFound(errors.New("temporary")) {
t.Fatal("not-found classification drift")
}
for _, pair := range [][2]string{{"bad", "2026-08-17T10:00:00+08:00"}, {"2026-08-17T09:00:00+08:00", "bad"}, {"2026-08-17T10:00:00+08:00", "2026-08-17T09:00:00+08:00"}} {
if err := calendarSmartValidateRange(pair[0], pair[1]); err == nil {
t.Fatalf("bad range accepted: %#v", pair)
}
}
if err := calendarSmartVerifyCreatedEvent(map[string]any{"id": "wrong", "summary": "title"}, "event-1", "title", "s", "e"); err == nil {
t.Fatal("wrong created event id accepted")
}
if err := calendarSmartVerifyCreatedEvent(map[string]any{"id": "event-1", "summary": "wrong"}, "event-1", "title", "s", "e"); err == nil {
t.Fatal("wrong created event title accepted")
}
if err := calendarSmartVerifyAttendees(map[string]bool{}, []string{"u"}, nil, ""); err == nil {
t.Fatal("missing attendee accepted")
}
if err := calendarSmartVerifyAttendees(map[string]bool{"name": true}, []string{"u"}, []string{"name"}, ""); err != nil {
t.Fatal(err)
}
if err := calendarSmartVerifyAttendees(map[string]bool{"__self__": true}, []string{"u"}, nil, "u"); err != nil {
t.Fatal(err)
}
}
func calendarSmartRuntimeForTest(t *testing.T, caller edition.ToolCaller) *shortcut.RuntimeContext {
t.Helper()
helpers.InitDepsForTest(t, caller)
cmd := &cobra.Command{Use: "calendar-test"}
ctx, _ := output.WithResultStore(context.Background())
cmd.SetContext(ctx)
return shortcut.RuntimeContextForTest(cmd, shortcut.Shortcut{Service: "calendar", Product: "calendar"})
}
func TestCrossPlatformCoverageCalendarSmartCurrentUserDeleteAndPageCap(t *testing.T) {
for name, steps := range map[string][]calendarSmartTestStep{
"call": {{err: errors.New("profile failure")}},
"missing": {{text: `{"success":true,"result":{}}`}},
"success": {{text: `{"success":true,"result":{"userId":"user-placeholder"}}`}},
} {
t.Run("profile-"+name, func(t *testing.T) {
caller := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"contact/get_current_user_profile": steps}}
id, err := calendarSmartCurrentUserID(calendarSmartRuntimeForTest(t, caller), map[string]bool{"__self__": true})
if name == "success" {
if err != nil || id == "" {
t.Fatalf("id=%q err=%v", id, err)
}
} else if err == nil {
t.Fatal("bad profile accepted")
}
})
}
if id, err := calendarSmartCurrentUserID(calendarSmartRuntimeForTest(t, &calendarSmartTestCaller{}), map[string]bool{}); err != nil || id != "" {
t.Fatalf("profile unnecessarily called: id=%q err=%v", id, err)
}
deleteCases := map[string]*calendarSmartTestCaller{
"write-call": {steps: map[string][]calendarSmartTestStep{"calendar/delete_calendar_event": {{err: errors.New("write")}}}},
"receipt": {steps: map[string][]calendarSmartTestStep{"calendar/delete_calendar_event": {{text: `{"result":{}}`}}}},
"read-unknown": {steps: map[string][]calendarSmartTestStep{"calendar/delete_calendar_event": {{text: `{"success":true}`}}, "calendar/get_calendar_detail": {{err: errors.New("temporary")}}}},
"read-malformed": {steps: map[string][]calendarSmartTestStep{"calendar/delete_calendar_event": {{text: `{"success":true}`}}, "calendar/get_calendar_detail": {{text: `{"success":true,"result":{}}`}}}},
"read-not-found-response": {steps: map[string][]calendarSmartTestStep{"calendar/delete_calendar_event": {{text: `{"success":true}`}}, "calendar/get_calendar_detail": {{text: `{"success":false,"message":"not found"}`}}}},
"present": {steps: map[string][]calendarSmartTestStep{"calendar/delete_calendar_event": {{text: `{"success":true}`}}, "calendar/get_calendar_detail": {{text: `{"success":true,"result":{"id":"event-placeholder","status":"active"}}`}}}},
"tombstone": {steps: map[string][]calendarSmartTestStep{"calendar/delete_calendar_event": {{text: `{"success":true}`}}, "calendar/get_calendar_detail": {{text: `{"success":true,"result":{"id":"event-placeholder","status":"deleted"}}`}}}},
}
for name, caller := range deleteCases {
t.Run("delete-"+name, func(t *testing.T) {
err := calendarSmartDeleteAndVerify(calendarSmartRuntimeForTest(t, caller), "event-placeholder")
if name == "tombstone" || name == "read-not-found-response" {
if err != nil {
t.Fatal(err)
}
} else if err == nil {
t.Fatal("bad delete path accepted")
}
})
}
steps := make([]calendarSmartTestStep, calendarSmartMaxPages)
for index := range steps {
steps[index] = calendarSmartTestStep{text: fmt.Sprintf(`{"success":true,"result":{"events":[],"hasMore":true,"nextCursor":"cursor-%d"}}`, index+1)}
}
capCaller := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"calendar/list_calendar_events": steps}}
if _, err := calendarSmartListAll(calendarSmartRuntimeForTest(t, capCaller), map[string]any{}); err == nil {
t.Fatal("calendar page cap accepted incomplete events")
}
callFailure := &calendarSmartTestCaller{steps: map[string][]calendarSmartTestStep{"calendar/list_calendar_events": {{err: errors.New("list")}}}}
if _, err := calendarSmartListAll(calendarSmartRuntimeForTest(t, callFailure), map[string]any{}); err == nil {
t.Fatal("calendar list transport failure accepted")
}
_ = calendarProjectEvents([]map[string]any{calendarSmartTestEvent("event-placeholder")})
item := shortcut.Shortcut{}
finalizeCalendarSmart(&item, "description")
if item.Contract.Result == nil {
t.Fatal("finalized smart shortcut missing result")
}
start, end := calendarDayRange(0)
if !end.After(start) || end.Sub(start) < 23*time.Hour {
t.Fatalf("day range=%v..%v", start, end)
}
}
+25 -50
View File
@@ -27,8 +27,8 @@ import (
// confirm-before-delete safety net so you never wipe the wrong eventId.
//
// Steps: first pull the event's detail via get_calendar_detail (so a bad or
// stale eventId fails clearly before any destructive write, and the title is
// surfaced back to the user). Then delete it via delete_calendar_event.
// stale eventId fails clearly before any destructive write). Then delete it via
// delete_calendar_event and verify the event is absent.
// Replaces `calendar event get --id` (verify it's the right one) →
// `calendar event delete --id` (destroy it).
//
@@ -43,7 +43,7 @@ var CancelEvent = shortcut.Shortcut{
Product: "calendar",
Description: "取消(删除)一个已有日程(删除前先确认它真实存在)",
Intent: "当你想取消/删除一个已经存在的日程时使用;" +
"内部先用 eventId 拉一次日程详情确认它真实存在并回显标题,再执行删除," +
"内部先用 eventId 拉一次日程详情确认它真实存在,再执行删除并验证它已不存在," +
"避免因 eventId 写错而误删别的日程。" +
"如果 eventId 查不到会直接报错,不会盲目删除。" +
"这是高危写操作,会真实删除该日程,框架会二次确认。",
@@ -68,7 +68,7 @@ var CancelEvent = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "取消(删除)一个已有日程(删除前先确认它真实存在)",
UseWhen: []string{"当你想取消/删除一个已经存在的日程时使用;内部先用 eventId 拉一次日程详情确认它真实存在并回显标题,再执行删除,避免因 eventId 写错而误删别的日程。如果 eventId 查不到会直接报错,不会盲目删除。这是高危写操作,会真实删除该日程,框架会二次确认。"},
UseWhen: []string{"当你想取消/删除一个已经存在的日程时使用;内部先用 eventId 拉一次日程详情确认它真实存在,再执行删除并验证它已不存在,避免因 eventId 写错而误删别的日程。如果 eventId 查不到会直接报错,不会盲目删除。这是高危写操作,会真实删除该日程,框架会二次确认。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws calendar +cancel-event --event EVENT_ID"},
},
@@ -92,60 +92,35 @@ var CancelEvent = shortcut.Shortcut{
if err != nil {
return err
}
if _, err := calendarSmartRequireEvent(detail, "calendar/get_calendar_detail", eventID); err != nil {
return err
}
// Defensively surface the event title so the user can see what is about to
// be cancelled. Structure is unknown, so probe several candidate fields at
// both the top level and under a nested detail/event/data wrapper; if none
// match we simply skip the projection and proceed to delete.
title := cancelEventExtractTitle(detail)
if title != "" {
_ = rt.Output(map[string]any{
"eventId": eventID,
"title": title,
"action": "about_to_cancel",
// Never print the pre-delete event detail: it may carry title, attendee,
// location, or meeting-room PII and would also create a second JSON value.
if rt.DryRun() {
return rt.Output(map[string]any{
"success": true,
"dryRun": true,
"executed": false,
"eventId": eventID,
})
}
// Step 2 — delete the event. eventId param copied verbatim from the
// helper's `event delete` call site (delete_calendar_event).
return rt.CallMCP("delete_calendar_event", map[string]any{
"eventId": eventID,
// Step 2 — require a terminal delete receipt and then prove absence.
if err := calendarSmartDeleteAndVerify(rt, eventID); err != nil {
return err
}
return rt.Output(map[string]any{
"success": true,
"eventId": eventID,
"deleted": true,
"verified": true,
})
},
}
// cancelEventExtractTitle defensively digs a human-readable event title out of
// a get_calendar_detail response. It checks common title-ish keys at the top
// level and inside a single nested wrapper (detail/event/data/result), and
// returns "" when nothing usable is found.
func cancelEventExtractTitle(data map[string]any) string {
if data == nil {
return ""
}
if t := cancelEventTitleFromMap(data); t != "" {
return t
}
for _, k := range []string{"detail", "event", "data", "result"} {
if nested, ok := data[k].(map[string]any); ok {
if t := cancelEventTitleFromMap(nested); t != "" {
return t
}
}
}
return ""
}
func cancelEventTitleFromMap(m map[string]any) string {
for _, k := range []string{"summary", "title", "subject", "name"} {
if v, ok := m[k].(string); ok {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
}
return ""
}
func init() {
finalizeCalendarSmart(&CancelEvent, "已删除并通过缺席读回验证的日程")
shortcut.Register(CancelEvent)
}
+6 -13
View File
@@ -57,7 +57,7 @@ var ChatMessages = shortcut.Shortcut{
Command: "+chat-messages",
Product: "chat",
Description: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出",
Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选过滤条件,可传姓名、userId 或 openDingTalkId:姓名优先唯一解析,稳定 ID 精确路由;通讯录无法分类时仍按原值 userId 筛选,但无稳定 senderId 命中不得作完整否定结论。不传时原样读取会话且不查询发送者身份。sender 仅是展示名,身份只比较稳定 senderId;" +
Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选的姓名、userId 或 openDingTalkId 混合入口:姓名优先唯一解析,稳定 ID 精确路由;通讯录无法分类时仍按原值 userId 筛选并保留 identity_unverified,可交付精确命中但不能把原值升级为已验证身份或作完整否定结论。--sender-query 只按姓名唯一解析,解析失败会抑制未过滤消息并返回错误。不传发送者条件时原样读取会话且不查询发送者身份。sender 展示名不参与身份比较;" +
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" +
"全量读取用 --page-all,并由 --page-limit/--max-items 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
@@ -83,7 +83,7 @@ var ChatMessages = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出",
UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选过滤条件,可传姓名、userId 或 openDingTalkId:姓名优先唯一解析,稳定 ID 精确路由;通讯录无法分类时仍按原值 userId 筛选,但无稳定 senderId 命中不得作完整否定结论。不传时原样读取会话且不查询发送者身份。sender 仅是展示名,身份只比较稳定 senderId;" +
UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选的姓名、userId 或 openDingTalkId 混合入口:姓名优先唯一解析,稳定 ID 精确路由;通讯录无法分类时仍按原值 userId 筛选并保留 identity_unverified,可交付精确命中但不能把原值升级为已验证身份或作完整否定结论。--sender-query 只按姓名唯一解析,解析失败会抑制未过滤消息并返回错误。不传发送者条件时原样读取会话且不查询发送者身份。sender 展示名不参与身份比较;" +
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" +
"全量读取用 --page-all,并由 --page-limit/--max-items 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
@@ -104,8 +104,8 @@ var ChatMessages = shortcut.Shortcut{
{Name: "user", Type: shortcut.FlagString, Desc: "单聊对方的 userId,与 --group 互斥"},
{Name: "user-query", Type: shortcut.FlagString, Desc: "按姓名解析唯一 openDingTalkId 的兼容入口", Hidden: true},
{Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊对方的 openDingTalkId,与 --group/--user 互斥"},
{Name: "sender", Type: shortcut.FlagStringSlice, Desc: "单个或多个发送者姓名、userId 或 openDingTalkId;姓名唯一解析,稳定 ID 精确路由,通讯录故障不阻断原值 userId 筛选"},
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "显式按姓名唯一解析发送者的兼容入口(可选,可重复或逗号分隔)"},
{Name: "sender", Type: shortcut.FlagStringSlice, Desc: "单个或多个发送者姓名、userId 或 openDingTalkId;姓名唯一解析,稳定 ID 精确路由,通讯录无法分类时按原值 userId 筛选并保留身份未验证状态"},
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "显式按姓名唯一解析发送者的兼容入口;解析失败时抑制未过滤消息并返回错误(可选,可重复或逗号分隔)"},
{Name: "time", Type: shortcut.FlagString, Desc: "时间边界,如 \"2025-03-01 00:00:00\";--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD;省略时从当前时间向前读取最近消息"},
{Name: "start", Type: shortcut.FlagString, Desc: "范围开始时间(可选、包含),支持 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD"},
{Name: "start-time", Type: shortcut.FlagString, Desc: "--start 的 lark-cli 对齐别名(可选、包含)"},
@@ -418,7 +418,7 @@ func applyOptionalChatMessagesSenderFilter(
payload["complete"] = false
payload["partial"] = len(filtered) > 0
}
unverifiedSenderInputs := chatMessagesUnverifiedSenderInputs(filtered, filter.resolutions)
unverifiedSenderInputs := chatMessagesUnverifiedSenderInputs(filter.resolutions)
if len(unverifiedSenderInputs) > 0 {
failures, _ := payload["failures"].([]map[string]any)
failures = append(failures, map[string]any{
@@ -470,18 +470,11 @@ func applyOptionalChatMessagesSenderFilter(
}
func chatMessagesUnverifiedSenderInputs(
messages []map[string]any,
resolutions []targetresolver.UserResolution,
) []string {
observed := map[string]bool{}
for _, message := range messages {
if senderID := strings.TrimSpace(fmt.Sprint(chatmsg.SenderID(message))); senderID != "" && senderID != "<nil>" {
observed[senderID] = true
}
}
values := make([]string, 0)
for _, resolution := range resolutions {
if targetresolver.IsUnverifiedUserIDResolution(resolution) && !observed[resolution.Selected.UserID] {
if targetresolver.IsUnverifiedUserIDResolution(resolution) {
values = append(values, resolution.Query)
}
}
@@ -631,7 +631,7 @@ func TestCrossPlatformCoverageChatMessagesSenderFilterFailureEdges(t *testing.T)
}
})
t.Run("unverified mixed sender becomes verified by an exact sender id match", func(t *testing.T) {
t.Run("unverified mixed sender remains unverified after an exact sender id match", func(t *testing.T) {
payload := map[string]any{"complete": true, "failures": []map[string]any{}}
resolution := targetresolver.UserResolution{
Status: targetresolver.StatusResolved,
@@ -655,13 +655,18 @@ func TestCrossPlatformCoverageChatMessagesSenderFilterFailureEdges(t *testing.T)
{"openMessageId": "other", "senderUserId": "other-user"},
}, &filter,
)
if len(filtered) != 1 || payload["complete"] != true || len(payload["failures"].([]map[string]any)) != 0 {
if len(filtered) != 1 || payload["complete"] != false || payload["partial"] != true ||
payload["failedCount"] != 1 || len(payload["failures"].([]map[string]any)) != 1 {
t.Fatalf("filtered=%#v payload=%#v", filtered, payload)
}
identity := payload["identityResult"].(map[string]any)
if identity["status"] != "evaluated" || identity["negativeConclusionAllowed"] != true {
if identity["status"] != "identity_unverified" || identity["negativeConclusionAllowed"] != false {
t.Fatalf("identityResult=%#v", identity)
}
filterResult := payload["senderFilter"].(map[string]any)
if filterResult["status"] != "identity_unverified" {
t.Fatalf("senderFilter=%#v", filterResult)
}
})
t.Run("unverified mixed sender without a match blocks complete negative conclusion", func(t *testing.T) {
@@ -24,6 +24,7 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -65,6 +66,10 @@ func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool strin
}
case "contact/get_current_user_profile":
text = `{"result":{"userId":"u1"}}`
case "todo/create_personal_todo":
text = `{"success":true,"result":{"taskId":"todo-created"}}`
case "todo/get_todo_detail":
text = `{"success":true,"result":{"todoDetailModel":{"taskId":"todo-created","subject":"交周报","isDone":false}}}`
case "im/search_groups":
text = `{"result":[{"openConversationId":"cid-1","title":"项目冲刺"}]}`
case "chat/list_conversation_message_v2":
@@ -112,6 +117,8 @@ func (f *platformCoverageCaller) JQ() string { return "" }
func newPlatformCoverageRoot() *cobra.Command {
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
ctx, _ := output.WithResultStore(context.Background())
root.SetContext(ctx)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.PersistentFlags().Bool("yes", false, "")
+22 -13
View File
@@ -83,7 +83,7 @@ var Conflicts = shortcut.Shortcut{
// Target day window in local time.
dayStart, dayEnd := calendarDayRange(rt.Int("in-days"))
data, err := rt.CallMCPData("calendar", "list_calendar_events", map[string]any{
events, err := calendarSmartListAll(rt, map[string]any{
"startTime": dayStart.UnixMilli(),
"endTime": dayEnd.UnixMilli(),
"calendarId": "primary",
@@ -100,7 +100,7 @@ var Conflicts = shortcut.Shortcut{
end time.Time
}
var evs []ev
for _, e := range shortcutNextEventList(data) {
for _, e := range events {
start, ok := shortcutNextEventStart(e)
if !ok {
continue
@@ -139,25 +139,33 @@ var Conflicts = shortcut.Shortcut{
"conflictCount": len(conflicts),
"hasConflict": len(conflicts) > 0,
"conflicts": conflicts,
"complete": true,
})
},
}
// conflictsEndTime parses an event's end time from end.dateTime (RFC3339).
// conflictsEndTime parses a timed event or a legitimate all-day event.
func conflictsEndTime(e map[string]any) (time.Time, bool) {
end, ok := e["end"].(map[string]any)
if !ok {
return time.Time{}, false
if ok {
if dt, ok := end["dateTime"].(string); ok && dt != "" {
t, err := time.Parse(time.RFC3339, dt)
return t, err == nil
}
if date, ok := end["date"].(string); ok && date != "" {
t, err := time.ParseInLocation("2006-01-02", date, time.Local)
return t, err == nil
}
}
dt, ok := end["dateTime"].(string)
if !ok || dt == "" {
return time.Time{}, false
for _, key := range []string{"endTime", "endDateTime"} {
if dt, ok := e[key].(string); ok && dt != "" {
t, err := time.Parse(time.RFC3339, dt)
if err == nil {
return t, true
}
}
}
t, err := time.Parse(time.RFC3339, dt)
if err != nil {
return time.Time{}, false
}
return t, true
return time.Time{}, false
}
func conflictsLabel(title string, start, end time.Time) string {
@@ -179,5 +187,6 @@ func conflictsMin(a, b time.Time) time.Time {
}
func init() {
finalizeCalendarSmart(&Conflicts, "完整事件集上的时间冲突分析")
shortcut.Register(Conflicts)
}
+9 -4
View File
@@ -14,8 +14,11 @@
package smart
import (
"encoding/json"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
@@ -39,10 +42,11 @@ import (
//
// dws todo +created-todos
var CreatedTodos = shortcut.Shortcut{
Service: "todo",
Command: "+created-todos",
Product: "todo",
Description: "列出我创建的待办(我作为创建人 creator 发起的待办,而非分配给我执行的)",
OutputRollout: output.RolloutUnifiedActive,
Service: "todo",
Command: "+created-todos",
Product: "todo",
Description: "列出我创建的待办(我作为创建人 creator 发起的待办,而非分配给我执行的)",
Intent: "当你想快速看清『哪些待办是我自己创建/发起的』,而不是别人指派给我执行的待办时使用;" +
"内部拉取你当前组织下角色为创建人(creator)的待办列表(roleTypes=[\"creator\"]," +
"creator 是待办列表支持的角色枚举之一),再在本地把每条待办投影成标题(subject)、" +
@@ -62,6 +66,7 @@ var CreatedTodos = shortcut.Shortcut{
PrimaryCLIPath: "todo +created-todos",
},
Description: "列出我创建的待办(我作为创建人 creator 发起的待办,而非分配给我执行的)",
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"我创建的全部待办","properties":{"created":{"type":"array","description":"创建人角色待办","items":{"type":"object","description":"待办条目","additionalProperties":true}}},"required":["created"],"additionalProperties":false}`)},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
+32 -13
View File
@@ -14,9 +14,12 @@
package smart
import (
"encoding/json"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
@@ -45,16 +48,32 @@ import (
//
// dws todo +due-today
var DueToday = shortcut.Shortcut{
Service: "todo",
Command: "+due-today",
Product: "todo",
Description: "列出我今天到期的待办",
OutputRollout: output.RolloutUnifiedActive,
Service: "todo",
Command: "+due-today",
Product: "todo",
Description: "列出我今天到期的待办",
Intent: "当你想快速看清自己今天(planFinishDate 落在今天 00:00 到次日 00:00 之间)到期的待办、方便安排一天的工作时使用;" +
"内部按今天的本地时间窗,把 planFinishDateStart=今天0点、planFinishDateEnd=次日0点(毫秒时间戳)传给 get_user_todos_in_current_org 做服务端过滤," +
"默认拉取你作为执行人(executor)的待办,可用 --role-types 覆盖角色范围,最后只打印这些今天到期待办的标题、状态、优先级、创建人、到期时间和任务 ID。" +
"这与 +overdue(已过期)不同:+overdue 看的是已经过了截止时间的待办,本命令看的是今天当天到期的待办。" +
"这是纯只读操作,只做列表与投影,不会修改或完成任何待办;若今天没有到期的待办则返回错误提示。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{ProductID: "todo", Name: "shortcut_due_today", CanonicalPath: "todo.shortcut_due_today", CLIPath: "todo +due-today", PrimaryCLIPath: "todo +due-today"},
Description: "列出我今天到期的待办",
Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "Reviewed Todo composite: local-day window construction, strict full pagination and projection are owned by the executable shortcut."},
Selection: contract.SelectionSpec{
AgentSummary: "列出我今天到期的待办",
UseWhen: []string{"需要查看本地日历日今天到期的待办时"},
AvoidWhen: []string{"查看已经逾期的任务时使用 todo +overdue"},
Examples: []string{"dws todo +due-today"},
},
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"今天到期的待办","properties":{"count":{"type":"integer","description":"任务数量"},"tasks":{"type":"array","description":"今天到期的任务","items":{"type":"object","description":"待办条目","additionalProperties":true}}},"required":["count","tasks"],"additionalProperties":false}`)},
},
Flags: []shortcut.Flag{
{
Name: "role-types",
@@ -108,24 +127,24 @@ var DueToday = shortcut.Shortcut{
// leaks outside [startMs, endMs).
results := make([]map[string]any, 0, len(cards))
for _, m := range cards {
if due, ok := shortcutOverdueDueTime(m); ok { // reused from overdue.go
if due < startMs || due >= endMs {
continue
}
due, ok := shortcutOverdueDueTime(m)
if !ok {
return shortcutTodoResponseError("malformed_due_time", "今天到期结果缺少可解析的 dueTime")
}
if due < startMs || due >= endMs {
continue
}
taskID := shortcutRelatedTaskID(m) // reused from related_tasks.go
results = append(results, shortcutRelatedProject(m, taskID)) // reused from related_tasks.go
}
if len(results) == 0 {
return apperrors.NewValidation("今天没有到期的待办")
}
// Step 4 — print the projected list.
return rt.Output(map[string]any{"count": len(results), "tasks": results})
},
}
func init() {
DueToday.Contract.Selection.AgentSummary = DueToday.Description
DueToday.Contract.Selection.UseWhen = []string{DueToday.Intent}
shortcut.Register(DueToday)
}
+4 -2
View File
@@ -102,7 +102,7 @@ var FreeSlots = shortcut.Shortcut{
// List the day's events (a little wider than the work window so events
// spanning the edges are captured).
data, err := rt.CallMCPData("calendar", "list_calendar_events", map[string]any{
events, err := calendarSmartListAll(rt, map[string]any{
"startTime": day.UnixMilli(),
"endTime": day.AddDate(0, 0, 1).UnixMilli(),
"calendarId": "primary",
@@ -114,7 +114,7 @@ var FreeSlots = shortcut.Shortcut{
// Collect and clip busy intervals to the work window.
type interval struct{ start, end time.Time }
var busy []interval
for _, e := range shortcutNextEventList(data) {
for _, e := range events {
s, ok := shortcutNextEventStart(e)
if !ok {
continue
@@ -156,6 +156,7 @@ var FreeSlots = shortcut.Shortcut{
"slotCount": len(free),
"freeSlots": free,
"allBusy": len(free) == 0,
"complete": true,
})
},
}
@@ -170,5 +171,6 @@ func freeSlotEntry(start, end time.Time) map[string]any {
}
func init() {
finalizeCalendarSmart(&FreeSlots, "完整事件集上计算的工作时段空档")
shortcut.Register(FreeSlots)
}
+5 -26
View File
@@ -102,7 +102,10 @@ var FreeBusy = shortcut.Shortcut{
if err != nil {
return err
}
busy := freebusySlots(data)
busy, err := calendarSmartBusySlots(data)
if err != nil {
return err
}
return rt.Output(map[string]any{
"who": user.name,
"userId": user.userID,
@@ -112,31 +115,6 @@ var FreeBusy = shortcut.Shortcut{
},
}
// freebusySlots flattens a query_busy_status response (result[] → scheduleItems[]
// → {start,end}.dateTime) into a flat list of {start,end} busy slots.
func freebusySlots(data map[string]any) []map[string]any {
out := []map[string]any{}
entries, _ := data["result"].([]any)
for _, e := range entries {
em, ok := e.(map[string]any)
if !ok {
continue
}
items, _ := em["scheduleItems"].([]any)
for _, it := range items {
im, ok := it.(map[string]any)
if !ok {
continue
}
out = append(out, map[string]any{
"start": freebusyDateTime(im["start"]),
"end": freebusyDateTime(im["end"]),
})
}
}
return out
}
// freebusyDateTime pulls the readable timestamp out of a {date,dateTime,timeZone}
// object, falling back to the raw value.
func freebusyDateTime(v any) any {
@@ -163,5 +141,6 @@ func freebusyParseMillis(flag, value string) (int64, error) {
}
func init() {
finalizeCalendarSmart(&FreeBusy, "严格校验的指定用户忙闲时段")
shortcut.Register(FreeBusy)
}
+49 -3
View File
@@ -84,6 +84,7 @@ var Invite = shortcut.Shortcut{
// Resolve every participant name to a unique userId first, so an
// unknown/ambiguous name fails before we touch the event.
var userIDs []string
var userNames []string
for _, name := range strings.Split(rt.Str("with"), ",") {
name = strings.TrimSpace(name)
if name == "" {
@@ -94,20 +95,65 @@ var Invite = shortcut.Shortcut{
return err
}
userIDs = append(userIDs, user.userID)
userNames = append(userNames, user.name)
}
if len(userIDs) == 0 {
return apperrors.NewValidation("--with 需要至少一个有效的参会人姓名")
}
// Batch-add all participants. eventId + attendeesToAdd copied verbatim
// from the helper's `attendee add` call site (add_calendar_participant).
return rt.CallMCP("add_calendar_participant", map[string]any{
preflight, err := rt.CallMCPData("calendar", "get_calendar_detail", map[string]any{"eventId": eventID})
if err != nil {
return err
}
if _, err := calendarSmartRequireEvent(preflight, "calendar/get_calendar_detail", eventID); err != nil {
return err
}
if rt.DryRun() {
return rt.Output(map[string]any{
"success": true,
"dryRun": true,
"executed": false,
"eventId": eventID,
"inviteeCount": len(userIDs),
})
}
// Batch-add all participants and require explicit success.
written, err := rt.CallMCPWriteDataStrict("calendar", "add_calendar_participant", map[string]any{
"eventId": eventID,
"attendeesToAdd": userIDs,
})
if err != nil {
return err
}
if err := calendarSmartWriteReceipt(written, "calendar/add_calendar_participant"); err != nil {
return err
}
participants, err := rt.CallMCPData("calendar", "get_calendar_participants", map[string]any{"eventId": eventID})
if err != nil {
return err
}
present, err := calendarSmartAttendees(participants)
if err != nil {
return err
}
currentUserID, err := calendarSmartCurrentUserID(rt, present)
if err != nil {
return err
}
if err := calendarSmartVerifyAttendees(present, userIDs, userNames, currentUserID); err != nil {
return err
}
return rt.Output(map[string]any{
"success": true,
"eventId": eventID,
"invitedCount": len(userIDs),
"verified": true,
})
},
}
func init() {
finalizeCalendarSmart(&Invite, "已添加并通过参会人读回验证的邀请")
shortcut.Register(Invite)
}
@@ -111,9 +111,13 @@ func runShortcut(t *testing.T, fake *stubMailboxCaller, argv ...string) string {
root.SetOut(&buf)
root.SetErr(io.Discard)
root.SetArgs(argv)
if err := root.Execute(); err != nil {
executed, err := root.ExecuteC()
if err != nil {
t.Fatalf("execute %v: %v", argv, err)
}
if _, _, err := output.EmitStoredResult(executed); err != nil {
t.Fatalf("emit %v: %v", argv, err)
}
return buf.String()
}
@@ -173,14 +177,15 @@ func TestCreatedTodosProjectsCards(t *testing.T) {
"get_user_todos_in_current_org": `{"result":{"todoCards":[
{"subject":"todoA","taskId":"111","dueTime":1784686500000},
{"subject":"todoB","taskId":"222"}
]}}`,
],"hasMore":false}}`,
}}
out := runShortcut(t, fake, "todo", "+created-todos", "--format", "json")
var d map[string]any
if err := json.Unmarshal([]byte(out), &d); err != nil {
t.Fatalf("output not json: %q", out)
}
created, _ := d["created"].([]any)
payload, _ := d["data"].(map[string]any)
created, _ := payload["created"].([]any)
if len(created) != 2 {
t.Fatalf("lower/upper mismatch: 2 todos in backend, projection created=%d (%s)", len(created), out)
}

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