Compare commits
85
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9850a2e49 | ||
|
|
8097943e3a | ||
|
|
d5a9a72fa6 | ||
|
|
b7918be6f3 | ||
|
|
b84b56d9f8 | ||
|
|
e66cd95c51 | ||
|
|
7e8b216e07 | ||
|
|
5501c9f1a5 | ||
|
|
eefe6f05e1 | ||
|
|
6598292b1b | ||
|
|
dea637228d | ||
|
|
a09467f1eb | ||
|
|
34feb348af | ||
|
|
6b1a1a6201 | ||
|
|
9f60cdeef1 | ||
|
|
258caa5906 | ||
|
|
a0d59a79aa | ||
|
|
c7148f3ebb | ||
|
|
ef29b48a55 | ||
|
|
5c33ea526e | ||
|
|
6d3b54b25f | ||
|
|
867bb44586 | ||
|
|
4bbd42cc25 | ||
|
|
fd0c3350f3 | ||
|
|
2cc24de505 | ||
|
|
6575301a3a | ||
|
|
43121f1d8f | ||
|
|
cd02fe71e6 | ||
|
|
93a2071837 | ||
|
|
33730337f3 | ||
|
|
6be12655dc | ||
|
|
cf3bcb380f | ||
|
|
89e8bd7015 | ||
|
|
64e1dcc150 | ||
|
|
f3ecac1ad1 | ||
|
|
46ae1c50fe | ||
|
|
5de36d783a | ||
|
|
2bc4ded969 | ||
|
|
468f9200f6 | ||
|
|
6f61183732 | ||
|
|
332b74e8ce | ||
|
|
10bb2ec205 | ||
|
|
ac0125e468 | ||
|
|
f06a24ec2e | ||
|
|
8f135ecde6 | ||
|
|
a2e51f2b86 | ||
|
|
3d6e62fc08 | ||
|
|
c6094e291e | ||
|
|
e026c754e0 | ||
|
|
18030f1018 | ||
|
|
6297b6b0c8 | ||
|
|
e8905a1984 | ||
|
|
a097d57510 | ||
|
|
b566afe3e2 | ||
|
|
7405825294 | ||
|
|
7eb39ad5d6 | ||
|
|
eb73b944a1 | ||
|
|
ecaa375be8 | ||
|
|
dbecf23bc4 | ||
|
|
d73e199d97 | ||
|
|
7c9687094f | ||
|
|
05d8c86177 | ||
|
|
115dad3b82 | ||
|
|
b8ac9810f4 | ||
|
|
c1a90c0194 | ||
|
|
0bb2b6ce98 | ||
|
|
2662f87ad0 | ||
|
|
5a5b567eb0 | ||
|
|
c2db909bd2 | ||
|
|
871542ef0c | ||
|
|
2c7d0f3ac4 | ||
|
|
5a345228eb | ||
|
|
ea7c66b190 | ||
|
|
b2b6153424 | ||
|
|
59efb4facb | ||
|
|
3605d4f450 | ||
|
|
f34b7741d4 | ||
|
|
2c573ec892 | ||
|
|
3027337a34 | ||
|
|
d0ad33034e | ||
|
|
5f13876e6e | ||
|
|
116117e987 | ||
|
|
a3773c4384 | ||
|
|
4110330575 | ||
|
|
2bd6b297b0 |
+28
-17
@@ -1370,36 +1370,47 @@ jobs:
|
||||
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
|
||||
base_ref="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
candidate_ref="$(git rev-parse 'HEAD^{commit}')"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$candidate_ref" != "$PR_HEAD_SHA" ]; then
|
||||
echo "Compatibility checkout $candidate_ref does not match PR head $PR_HEAD_SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
stable_ref="$(git tag --merged "$base_ref" --list 'v[0-9]*' --sort=-version:refname | awk 'index($0, "-") == 0 { print; exit }')"
|
||||
. ./scripts/release/release-lib.sh
|
||||
stable_ref=""
|
||||
for tag in $(git tag --merged "$base_ref" --list 'v*' --sort=-version:refname); do
|
||||
release_is_stable_version "$tag" || continue
|
||||
if git rev-parse --verify --quiet "refs/tags/withdrawn/$tag" >/dev/null; then
|
||||
continue
|
||||
fi
|
||||
stable_ref="$tag"
|
||||
break
|
||||
done
|
||||
if [ -z "$stable_ref" ]; then
|
||||
echo "No stable release tag is reachable from compatibility base $base_ref" >&2
|
||||
exit 1
|
||||
fi
|
||||
git rev-parse --verify "${stable_ref}^{commit}" >/dev/null
|
||||
echo "COMPATIBILITY_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV"
|
||||
printf '%s\n' \
|
||||
"COMPATIBILITY_BASE_REF=$base_ref" \
|
||||
"COMPATIBILITY_STABLE_REF=$stable_ref" \
|
||||
"COMPATIBILITY_CANDIDATE_REF=$candidate_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check historical commands and help compatibility
|
||||
- name: Check historical commands, help, and complete CLI compatibility
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
run: |
|
||||
make authoritative-interface-integrity \
|
||||
BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||
if [ "$(git rev-parse "${COMPATIBILITY_BASE_REF}^{commit}")" != "$(git rev-parse "${COMPATIBILITY_STABLE_REF}^{commit}")" ]; then
|
||||
make authoritative-interface-integrity \
|
||||
BASE_REF="$COMPATIBILITY_STABLE_REF"
|
||||
fi
|
||||
|
||||
- name: Check complete CLI command compatibility
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
run: |
|
||||
./scripts/policy/check-command-compatibility.sh \
|
||||
--base-ref "$COMPATIBILITY_BASE_REF" \
|
||||
--stable-ref "$COMPATIBILITY_STABLE_REF"
|
||||
BASE_REF="$COMPATIBILITY_BASE_REF" \
|
||||
STABLE_REF="$COMPATIBILITY_STABLE_REF" \
|
||||
CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"
|
||||
|
||||
- name: Check complete Schema compatibility
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||
run: |
|
||||
make schema-compatibility \
|
||||
BASE_REF="$COMPATIBILITY_BASE_REF" \
|
||||
STABLE_REF="$COMPATIBILITY_STABLE_REF" \
|
||||
CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"
|
||||
|
||||
- name: Check skill command references
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
|
||||
@@ -6,15 +6,93 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Streaming-card mentions** — `chat +messages-send-card` now accepts
|
||||
`--at-open-dingtalk-ids` and `--at-all` for group cards, passing mention
|
||||
targets to the initial card-creation request and prepending its returned
|
||||
`atTag` to the automatic streaming update.
|
||||
|
||||
## [1.0.58-beta.2] - 2026-08-10
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws sheet create-with-data`(新命令)** — 建表并写入初始数据与样式:`--values`(二维数组写默认表)/ `--sheets`(typed table 多工作表,二者必须给一个)/ `--styles`(`cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`,顶层键对齐飞书 snake_case、列表项内字段兼容 camelCase)。所有结构、字段类型与枚举在创建文档之前校验,非法配置不会留下白建的空文档:`--sheets` 按 `table_put` 的输入契约逐字段校验(`columns` 必填且列名非空不重复、`data` 为二维且行宽与 `columns` 一致、单元格仅限字符串/数字/布尔/null、`dtypes`/`formats` 的键须是列名、`mode`/`header`/`allowOverwrite`/`startCell` 类型与取值、单表 30000 单元格上限),并拒绝未知键、snake_case 变体、`{"sheets":"bad"}` 这类畸形包装与 `sheetId`(服务端会静默丢弃写错的键,导致"只写了表头却报成功"的静默丢数据);`--values` 校验单元格为标量并受 30000 单元格 / 2000000 字符上限约束;`--styles` 的顶层键与列表项内字段同样拒绝未知键,避免样式只应用一半。写入后回读校验按 `startCell` / `header` / `mode` 推算的首个预期非空单元格,而非固定 A1。该命令是多步编排(建文档 → 探活 → 定位默认工作表 → 写数据 → 回读 → 可选样式),因此如实声明为独立叶子 `sheet.create_with_data` + `interface_mode: composite`(附评审 reason,按契约不带 `interface_ref`);**`dws sheet create` 保持原样不变**——仍是一次 `create_workspace_sheet` 直连(`interface_mode: mcp`),不新增 flag,避免让 Schema 消费者把编排步骤的参数误当成该 RPC 的入参。
|
||||
- **`dws sheet export-csv`(新命令)** — 同步导出单个工作表为 RFC4180 CSV,支持 `--sheet-id` 选表、`--range` 限定范围、`--value-render-option` 选取值模式;`--output` 落盘(为目录时按 `sheet-export.csv` 命名,落盘走 `AtomicWrite` 原子替换,写入失败时已有文件保持原样;父目录不存在按错误处理,不会自动创建),不传则把纯 CSV 打到 stdout(警告只走 stderr)。数据超出单次读取上限时默认报错、既不输出也不写文件,需 `--allow-truncated` 显式接受不完整结果。响应缺 `csv` 字段或类型不对一律报错,不会用 0 字节覆盖已有文件。该分支读的是 `get_range_as_csv`、与 xlsx 的异步导出任务毫无关系,因此独立成叶子 `sheet.export_csv` 并如实声明 `interface_mode: mcp` + `interface_ref: get_range_as_csv`;**`dws sheet export` 保持原样不变**——仍只导 xlsx(`interface_ref: submit_export_job`),flag 面仍是 `--node` / `--output`,csv 专属 flag 不会出现在它上面(此前挂在同一条命令上时,漏写 `--export-format csv` 会让 `--range` 被静默丢弃而导出整篇工作簿)。
|
||||
- **`sheet update-dimension --size-type`** — `pixel` / `standard`(恢复默认行高列宽)/ `auto`(按内容自适应行高,仅 ROWS)。
|
||||
- **`sheet replace --match-formula`** — 在公式文本中查找替换。
|
||||
- **`sheet range set-style` 扩展样式维度** — 新增 `--font-style`(斜体)/ `--font-line`(下划线、删除线)/ `--font-family` / `--border-styles-json`(四边边框;每条边只接受 `style` / `color`,未知键与非字符串 `color` 直接报错,不再静默忽略而画出无颜色的边框,`set-style` / `batch-set-style` / `create-with-data --styles` 三条路径同源校验)。
|
||||
- **`sheet range batch-set-style --ranges`** — 一组样式刷多个带工作表前缀的区域,组装为一次原子 `batch_update`。
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat message post-send ID handoff** (#897) — CLI Help and bundled Skills
|
||||
now document the `send` → `query-send-status` → `edit`/`recall` workflow,
|
||||
so callers can reuse returned task, message, and conversation IDs instead
|
||||
of searching message history by content.
|
||||
- **Sheet mono/multi Skill alignment** — replaces the oversized mono Sheet
|
||||
reference with the progressive routing layout, aligns all 20 Sheet topic
|
||||
references across the mono and multi bundles, and adds a content-policy guard
|
||||
that prevents the paired topic trees from drifting again.
|
||||
- **`sheet range set-style` 后端切换为 `set_cell_range`** — 样式统一走 cellStyles 路径(仅设样式、保留原值),这是斜体/下划线删除线/字体族/边框唯一可用的通道。`interface_ref` 由 `update_range` 变为 `set_cell_range`,12 个样式 flag 改为 reviewed mapping exclusion。CLI 用法向后兼容、无 flag 删除;schema-compatibility 经 reviewed 豁免判定为兼容(0 changed fields)。
|
||||
- **`sheet range batch-set-style` 改为单次原子提交** — 由本地循环多次 `update_range` 改为一次 `batch_update`,任一项失败默认整批回滚;`--continue-on-error` 由本地控制改为透传服务端。新增批量上限:最多 100 个区域且累计不超过 200000 个单元格。
|
||||
- **`sheet range batch-clear` / `batch-set-style` 的 `--ranges` 拒绝空白工作表前缀**(用户可见行为变更)— 此前只按原始串里 `!` 的位置判断,`" !A1:B2"` 修剪后工作表名成了空串,操作却照样带着 `sheetId: ""` 提交:服务端要么让整批 `batch_update` 失败,要么更糟——落到默认工作表而不是用户指定的那张表,且命令报成功。现在工作表名与范围都必须在修剪之后仍非空,否则在发起任何请求之前报错。`batch-set-style --batch` 的纯空白 `sheetId` / `range` 同样拒绝(此前只挡空字符串);`--batch` 下发仍用原值不替用户修剪,因为 `sheetId` 可以是允许带首尾空格的工作表**名**。两条 `--ranges` 路径现在共用同一个拆分器。
|
||||
- **`sheet insert-dimension` / `delete-dimension` / `update-dimension` 的 `--length` 严格校验**(用户可见行为变更)— 解析由 `fmt.Sscanf("%d")` 改为 `strconv.Atoi`。此前只消费前缀数字,`--length 2x` / `3foo` 会被静默当成 `2` / `3` 并对错误的行列数执行操作(删除方向不可回滚);现在整个值必须是合法正整数,否则报错「`--length` 必须为正整数(>= 1)」且不发起任何请求。**升级影响**:原先依赖这种宽松解析、在传畸形 `--length` 的脚本会开始报错,请把参数修正为纯数字。合法数字值行为不变,上限仍为 5000。`add-dimension` 的 `--length` 是 `Int` 类型 flag,一直由 cobra 严格校验,不受影响。
|
||||
- **CLI 接口兼容门禁支持 reviewed flag 类型豁免**(无用户可见变更)— `authoritative-interface-integrity` 与 `check-command-compatibility.sh` 此前一律拒绝历史命令的 flag 类型变更,即使新类型只是把同一套校验从 RunE 前移到解析期,也没有任何评审通道。现在两道门禁各带一张精确豁免表:命令路径 + flag 名 + 旧类型 → 新类型四元组全等才命中、方向敏感(`string`→`int` 与 `int`→`string` 是两个不同的键,只有被评审的方向可用),且仅当该 flag 的其他契约(shorthand / required / hidden / no-opt / scope)纹丝不动时才放行,因此豁免夹带不了别的破坏。首条也是目前唯一一条登记的是 `dws minutes permission apply --policy` 的 `string` → `int`(配合 #912):旧实现在 RunE 里做 `strconv.ParseInt(v, 10, 64)` 再校验 `[2,4]`,新实现由 pflag 以 `strconv.ParseInt(s, 0, 64)` 解析后仍校验 `[2,4]`,**历史上能成功的调用集是新调用集的子集**(base 0 额外接受 `0x3` 这类写法,只放宽不收紧),非法值依然失败、只是报错文案与时机前移;flag 默认值由 `""` 变 `"0"` 是类型的必然结果,两道门禁都不比较默认值,且该 flag 必须显式给出、默认值不可达。两张表必须逐字一致并有守卫测试锚定漂移——重复是被迫的而非选择:`check-authoritative-interface-baselines.sh` 会把整个 `scripts/policy/interface-baseline` 目录复制进检出历史版本的 worktree 再编译,那份拷贝不能 import 本分支新增的包。
|
||||
- **Schema 兼容门禁支持 reviewed 参数类型豁免**(无用户可见变更)— 接上一条。`schema-compatibility` 是同一个 `Interface Integrity` job 里排在两道 CLI 接口门禁之后的第三道检查,此前也一律拒绝已发布参数的 `type` 变更。由于前两道先失败、`set -e` 让它从未在 CI 上暴露,上一条豁免只解决了三分之二。现在 `checkParameterCompatibility` 也带一张精确豁免表:`<product>/<tool id>` + 参数名 + 旧类型 + 新类型四元组全等才命中、方向敏感,且仅当该参数**除 `type` 外的全部已发布字段逐字段相等**时才放行。这里刻意用相等性比较而非「没有产生其他兼容性错误」:放宽 `required` / `cli_required`、清空 `required_when`、扩宽 `enum`、清空 `interface_type`、经 reviewed mapping exclusion 清空 `property`——这些变化单独看都是兼容的、根本不产生错误,若以错误列表代替相等性检查,它们就能搭着一次已评审的类型迁移一起蒙混过关。结构体整体比较还意味着将来给 `parameterSchema` 新增字段时会自动纳入守卫,而不是悄悄放宽每一条既有条目。唯一条目是 `minutes/minutes.apply_minutes_permission` 的 `policy` 由 `"string"` 迁移到 `"integer"`(配合 #912):该 `type` 由 Cobra flag 类型投影而来(provenance `cobra_flag_type`),描述的是 CLI 如何接受取值;消费方据此拼装的是命令行,而 `--policy 4` 在两种声明下是同一个 argv,加引号的 `--policy "4"` 到 pflag 仍是 4,RunE 也仍校验 `[2,4]`——而且该参数映射的 property `policyId` 一直以数字上报,新声明比旧声明更贴近真实请求。表里的类型值必须是 `schemaType` 实际产出的带引号形态(`"string"` 而非裸 `string`),守卫测试用 `schemaType` 复算并校验类型名属于 JSON Schema 的封闭取值集合——`reviewedInterfaceRefRedirect` 曾因键的书写形态错误两次静默失效,这里不重犯。
|
||||
|
||||
## [1.0.58-beta.1] - 2026-08-07
|
||||
|
||||
### Added
|
||||
|
||||
- **Robot image and file messages** (#867) — `dws chat message send-by-bot`
|
||||
now supports image URLs and local-file uploads through explicit message
|
||||
types, while retaining Markdown as the default and preserving its existing
|
||||
title and text requirements.
|
||||
- **Conversation shortcut-bar management** (#877) — adds `dws chat toolbar`
|
||||
commands to list, add, hide, sort, and manage custom conversation shortcuts,
|
||||
with validation and confirmation for destructive removal.
|
||||
- **Complete AI Table Shortcut surface** (#901) — makes all 92 supported
|
||||
AI Table Shortcuts discoverable through Runtime Schema and adds reliable
|
||||
Base, table, record, attachment, view, dashboard, and workflow operations
|
||||
with explicit confirmation and result-verification semantics for writes.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Doc import upload fallback** — `dws doc import` no longer fails on file
|
||||
formats outside the conversion whitelist (html, pdf, zip, extensionless,
|
||||
and any future format): it now hands the file to the document-space upload
|
||||
chain (the same primitive as `dws drive upload --workspace`), stores the
|
||||
original file at the requested `--folder`/`--workspace` target, and prints
|
||||
an explicit stderr notice with the supported-format list and the
|
||||
convert-to-md alternative. The fallback shares the import file checks
|
||||
(20MB cap, empty-file guard), keeps `--format json` / `--dry-run` output as
|
||||
a single JSON document, and marks the machine-readable result with
|
||||
`fallback: "upload"` and `converted: false` so agents never mistake the
|
||||
stored file for a converted online document. The fallback fails closed
|
||||
unless the commit response parses as JSON and carries a file identity
|
||||
(exposed as `dentry_id`); empty or unverifiable responses surface as
|
||||
errors instead of fabricated success. Importable formats and
|
||||
`dws sheet import` validation are unchanged.
|
||||
- **IM natural-target and history alignment** — Chat shortcuts can resolve natural user/group targets before execution, and message-history workflows expose bounded time ranges, ordering, explicit all-page controls, continuation ledgers, safe local export, and thread-reply pagination without treating empty or incomplete reads as successful results. Bundled mono/multi Skills and intent routing now describe the same executable surface.
|
||||
- **Sheet CSV formula writes** — `dws sheet csv-put` and batch `csv-put` now expose the service contract that CSV fields beginning with `=` are written as formulas. Prefix the field with an apostrophe to write literal text beginning with `=`; CSV content continues to pass through unchanged.
|
||||
- **Release-equivalent PR compatibility gate** (#889) — pull-request
|
||||
admission now runs command-surface compatibility checks against the current
|
||||
release baseline before code reaches `main`.
|
||||
- **Reviewer routing governance** (#903) — updates the Reviewer Router pool
|
||||
used for new ready PRs while retaining the existing current-head review and
|
||||
required-check gates.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Fail-closed IM pagination and audit evidence** — `+chat-messages`, `+search-msg`, `+thread-replies`, `+at-me`, `+my-groups`, conversation lists, and favorites preserve partial-read failures, reject missing or stalled continuation state, deduplicate page boundaries, and publish completion evidence. The live-audit regression suite now rejects empty projections and incomplete reads instead of promoting them to passing results.
|
||||
- **Sheet formula verification** (#873) — `dws sheet formula-verify` now calls
|
||||
the registered remote tool name `verify_formula`; the previous
|
||||
`formula_verify` name failed at gateway dispatch.
|
||||
- **CLI and parameter recovery boundaries** (#864) — command and parameter
|
||||
recovery now fail closed when an Agent-provided path or flag cannot be
|
||||
reconciled with the executable CLI surface, reducing unsafe hallucinated
|
||||
retries.
|
||||
|
||||
## [1.0.57-beta.4] - 2026-08-06
|
||||
|
||||
@@ -149,6 +227,7 @@ and compatibility and CI reliability fixes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Fail-closed IM pagination and audit evidence** — `+chat-messages`, `+search-msg`, `+thread-replies`, `+at-me`, `+my-groups`, conversation lists, and favorites preserve partial-read failures, reject missing or stalled continuation state, deduplicate page boundaries, and publish completion evidence. The live-audit regression suite now rejects empty projections and incomplete reads instead of promoting them to passing results.
|
||||
- **Unified command safety and Shortcut runtime (H0)** — Shortcut leaves now execute through `corecmd.New`, sharing the same typed Safety confirmation gate as Leaf commands. EOF / closed stdin returns `confirmation_required`, and interactive `no` returns the existing non-zero cancellation validation error instead of reporting success for an operation that did not run. Pass `--yes` or `--dry-run` to skip the prompt.
|
||||
- **Constraint "provided" for `at_least_one` / `exactly_one` (H0)** — a flag set to an empty string (`--flag ""`) no longer counts as provided; previously bare Cobra `Changed` satisfied the constraint. Pass a non-blank value for a member of the group.
|
||||
- **Chat media download JSON compatibility** — `dws chat message download-media --format json` once again returns a clean `{success, downloadUrl, output}` result after the file is saved, preserving the temporary URL and resolved local path without progress text corrupting JSON stdout.
|
||||
|
||||
+11
-3
@@ -68,9 +68,17 @@ coverage is additionally selected for platform-sensitive code.
|
||||
3. Include both the commands/results and user-visible or contract-level
|
||||
behavior evidence in the PR description.
|
||||
4. Run `./scripts/policy/check-command-surface.sh --strict` when command
|
||||
paths/flags change. CI also runs
|
||||
`./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>`
|
||||
against both the target branch and latest stable release.
|
||||
paths/flags change. CI resolves the exact merge-base, latest reachable
|
||||
non-withdrawn stable GA tag, and committed candidate SHA, then enters the single compatibility
|
||||
decision seam through
|
||||
`make authoritative-interface-integrity BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`.
|
||||
The Make target delegates to the authoritative wrapper; CI does not invoke a
|
||||
second comparator or the legacy fixture checker. See
|
||||
[CLI flag compatibility migration governance](docs/cli-interface-flag-migrations.md)
|
||||
for the reviewed two-stage `pending` → `consumed` lifecycle.
|
||||
Agent-visible flag migrations must also run
|
||||
`make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`;
|
||||
it consumes the same base-owned ledger rather than a second exception list.
|
||||
5. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may
|
||||
change.
|
||||
6. Run `./scripts/release/verify-package-managers.sh` when packaging or
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.57-beta.4"
|
||||
version "1.0.58-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.57-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "ff363e258d463732e4dc02aa71ac1b5b1f05c25107c32ddcc5ae11d14931a782"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "1b2b6953f7f1ae1ca6ecb0702424ac0e1a976a6a5ff91e8ffc3b5ae495d98c7c"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "3eafcc4c27931b8457f3611a12ce4ae727f7bd65356fbef80958699defa09acf"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "a1c1b3c58b48e04c0ae520062f9d6ab0dc961eddb635497bdb9b4345316e45f6"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "49eabb4d2c419d8d1fb0fcf71d9c318ef49cf3d198431df1bcbaa71589e11383"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "7f35e3c4734f17b125a8c32f3c95e05d1410f683cf6956be857ee9349f8e4d36"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "fb3903485fe494e1fadb67ec87b2fef19cf575c72b7df25f0b93f80f8b36273f"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "37beb9e39790563cf0584ac23376f713bf2eb2c50cff4222965e831ac9adbb0e"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-skills.zip"
|
||||
sha256 "6bd11363dbd2ce79627f49bc80b8a92f9c79fca083a6bd3ff9bc5c4833594fb9"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-skills.zip"
|
||||
sha256 "7e10fead4192059c98d596c5b1886f77fd550526de5cd18c425cdad6fd64cd3a"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -24,13 +24,13 @@ help:
|
||||
@printf " make format-check - Check all repository Go source files with gofmt\n"
|
||||
@printf " make fmt - Format all repository Go source files\n"
|
||||
@printf " make policy - Check the built dws plus open-source and Schema policies\n"
|
||||
@printf " make interface-integrity - Check historical commands and help contracts still work\n"
|
||||
@printf " make authoritative-interface-integrity BASE_REF=<ref> - Check the Git-owned PR merge-base\n"
|
||||
@printf " make interface-integrity [BASE_REF=<ref>] [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check authoritative CLI history\n"
|
||||
@printf " make authoritative-interface-integrity BASE_REF=<ref> [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check Git-owned CLI history\n"
|
||||
@printf " make coverage-gate BASE_REF=<ref> - Enforce overall non-regression and 100%% changed-code coverage\n"
|
||||
@printf " make coverage-gate-platform BASE_REF=<ref> PROFILE=<file> - Enforce 100%% native changed-code coverage\n"
|
||||
@printf " make update-interface-baseline - Add new CLI contracts without removing history\n"
|
||||
@printf " make reset-interface-baseline - DANGEROUS: replace all CLI compatibility history\n"
|
||||
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
|
||||
@printf " make update-interface-baseline - Update the non-authoritative CLI smoke fixture\n"
|
||||
@printf " make reset-interface-baseline - DANGEROUS: replace the non-authoritative CLI smoke fixture\n"
|
||||
@printf " make schema-compatibility BASE_REF=<ref> [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check the authoritative Schema history\n"
|
||||
@printf " make skill-command-integrity - Check dws commands referenced by skills exist\n"
|
||||
@printf " make skill-context-budget - Check generated Skill drift and common-path context budgets\n"
|
||||
@printf " make multi-im-skill-chain-integrity - Check reviewed IM intents keep one default Skill route\n"
|
||||
@@ -89,6 +89,7 @@ policy: test-auth-legacy-compat
|
||||
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-multi-im-skill-chain.sh
|
||||
@python3 scripts/run_chat_shortcut_live_audit_test.py
|
||||
@$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict
|
||||
@$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-param-concepts.sh
|
||||
@@ -102,10 +103,22 @@ edition-test:
|
||||
$(GO) test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
interface-integrity:
|
||||
@./scripts/policy/check-interface-baseline.sh
|
||||
@base_ref="$(BASE_REF)"; \
|
||||
candidate_ref="$(CANDIDATE_REF)"; \
|
||||
if [ -z "$$base_ref" ]; then base_ref="origin/main"; fi; \
|
||||
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
|
||||
./scripts/policy/check-authoritative-interface-baselines.sh \
|
||||
--base-ref "$$base_ref" \
|
||||
--stable-ref "$(STABLE_REF)" \
|
||||
--candidate-ref "$$candidate_ref"
|
||||
|
||||
authoritative-interface-integrity:
|
||||
@./scripts/policy/check-authoritative-interface-baselines.sh --base-ref "$(BASE_REF)"
|
||||
@candidate_ref="$(CANDIDATE_REF)"; \
|
||||
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
|
||||
./scripts/policy/check-authoritative-interface-baselines.sh \
|
||||
--base-ref "$(BASE_REF)" \
|
||||
--stable-ref "$(STABLE_REF)" \
|
||||
--candidate-ref "$$candidate_ref"
|
||||
|
||||
coverage-gate:
|
||||
@./scripts/policy/check-coverage-gate.sh --base-ref "$(BASE_REF)" --scope-buildable
|
||||
@@ -120,7 +133,12 @@ reset-interface-baseline:
|
||||
@./scripts/policy/check-interface-baseline.sh --reset
|
||||
|
||||
schema-compatibility:
|
||||
@./scripts/policy/check-authoritative-schema-compatibility.sh --base-ref "$(BASE_REF)"
|
||||
@candidate_ref="$(CANDIDATE_REF)"; \
|
||||
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
|
||||
./scripts/policy/check-authoritative-schema-compatibility.sh \
|
||||
--base-ref "$(BASE_REF)" \
|
||||
--stable-ref "$(STABLE_REF)" \
|
||||
--candidate-ref "$$candidate_ref"
|
||||
|
||||
skill-command-integrity:
|
||||
@./scripts/policy/check-skill-commands.sh
|
||||
|
||||
@@ -17,18 +17,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var newRootCommand = func() *cobra.Command { return app.NewRootCommand() }
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
@@ -112,7 +117,11 @@ func runGenerate(args []string, stdout, stderr io.Writer) error {
|
||||
defer i18n.SetLang(previousLang)
|
||||
i18n.SetLang("en")
|
||||
|
||||
snapshot := interfacesnapshot.Capture(app.NewRootCommand())
|
||||
root := newRootCommand()
|
||||
snapshot := interfacesnapshot.Capture(root)
|
||||
if err := validateHelpRendering(root, snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
if *output == "-" {
|
||||
return interfacesnapshot.Write(stdout, snapshot)
|
||||
}
|
||||
@@ -138,6 +147,16 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
currentPath := flags.String("current", "", "candidate snapshot path")
|
||||
basePath := flags.String("base", "", "target main/development baseline snapshot path")
|
||||
stablePath := flags.String("stable", "", "latest stable GA snapshot path")
|
||||
approvedMigrationsPath := flags.String(
|
||||
"approved-flag-migrations",
|
||||
"",
|
||||
"merge-base-owned approved flag migration manifest",
|
||||
)
|
||||
candidateMigrationsPath := flags.String(
|
||||
"candidate-flag-migrations",
|
||||
"",
|
||||
"candidate flag migration manifest",
|
||||
)
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -150,6 +169,14 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
if *basePath == "" && *stablePath == "" {
|
||||
return false, fmt.Errorf("compare requires --base, --stable, or both")
|
||||
}
|
||||
if (*approvedMigrationsPath == "") != (*candidateMigrationsPath == "") {
|
||||
return false, fmt.Errorf(
|
||||
"--approved-flag-migrations and --candidate-flag-migrations must be provided together",
|
||||
)
|
||||
}
|
||||
if *approvedMigrationsPath != "" && (*basePath == "" || *stablePath == "") {
|
||||
return false, fmt.Errorf("flag migration compare requires both --base and --stable")
|
||||
}
|
||||
|
||||
current, err := readSnapshot(*currentPath)
|
||||
if err != nil {
|
||||
@@ -170,6 +197,25 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
}
|
||||
|
||||
report := interfacesnapshot.CompareAll(current, references)
|
||||
if *approvedMigrationsPath != "" {
|
||||
approved, readErr := readFlagMigrationManifest(*approvedMigrationsPath)
|
||||
if readErr != nil {
|
||||
return false, fmt.Errorf("read approved flag migrations: %w", readErr)
|
||||
}
|
||||
candidate, readErr := readFlagMigrationManifest(*candidateMigrationsPath)
|
||||
if readErr != nil {
|
||||
return false, fmt.Errorf("read candidate flag migrations: %w", readErr)
|
||||
}
|
||||
report, err = interfacesnapshot.CompareAllWithFlagMigrations(
|
||||
current,
|
||||
references,
|
||||
approved,
|
||||
candidate,
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("validate flag migration lifecycle: %w", err)
|
||||
}
|
||||
}
|
||||
encoder := json.NewEncoder(stdout)
|
||||
encoder.SetEscapeHTML(false)
|
||||
encoder.SetIndent("", " ")
|
||||
@@ -179,6 +225,49 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
return report.Compatible, nil
|
||||
}
|
||||
|
||||
func readFlagMigrationManifest(path string) (interfacesnapshot.FlagMigrationManifest, error) {
|
||||
file, err := os.Open(filepath.Clean(path))
|
||||
if err != nil {
|
||||
return interfacesnapshot.FlagMigrationManifest{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
return interfacesnapshot.ReadFlagMigrationManifest(file)
|
||||
}
|
||||
|
||||
func validateHelpRendering(root *cobra.Command, snapshot interfacesnapshot.Snapshot) error {
|
||||
for _, command := range snapshot.Commands {
|
||||
path := strings.TrimPrefix(command.Path, "dws")
|
||||
resolved, remaining, err := root.Find(strings.Fields(path))
|
||||
if err != nil || len(remaining) != 0 || resolved == nil {
|
||||
return fmt.Errorf("resolve %q before help rendering: remaining=%v error=%v", command.Path, remaining, err)
|
||||
}
|
||||
if err := renderCommandHelp(resolved); err != nil {
|
||||
return fmt.Errorf("render %q help: %w", command.Path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderCommandHelp(command *cobra.Command) (err error) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
command.InitDefaultHelpFlag()
|
||||
command.SetOut(&stdout)
|
||||
command.SetErr(&stderr)
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
err = fmt.Errorf("help renderer panicked: %v", recovered)
|
||||
}
|
||||
}()
|
||||
command.HelpFunc()(command, []string{})
|
||||
if stderr.Len() > 0 {
|
||||
return fmt.Errorf("help renderer wrote an error: %s", strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
if stdout.Len() == 0 {
|
||||
return fmt.Errorf("help renderer produced empty output")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
|
||||
file, err := os.Open(filepath.Clean(path))
|
||||
if err != nil {
|
||||
@@ -191,5 +280,5 @@ func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
|
||||
func printUsage(w io.Writer) {
|
||||
fmt.Fprintln(w, "usage:")
|
||||
fmt.Fprintln(w, " interface-snapshot generate [--output FILE]")
|
||||
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE]")
|
||||
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE]")
|
||||
}
|
||||
|
||||
@@ -15,11 +15,16 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRunGenerateCapturesActualRootOffline(t *testing.T) {
|
||||
@@ -56,6 +61,24 @@ func TestCrossPlatformCoverageRunGenerateCapturesActualRootOffline(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunGenerateRejectsHelpRenderingFailure(t *testing.T) {
|
||||
testseam.Swap(t, &newRootCommand, func() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetHelpFunc(func(command *cobra.Command, _ []string) {
|
||||
_, _ = io.WriteString(command.ErrOrStderr(), "injected help failure")
|
||||
})
|
||||
return root
|
||||
})
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exitCode := run([]string{"generate"}, &stdout, &stderr); exitCode != 2 {
|
||||
t.Fatalf("run(generate) exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "injected help failure") {
|
||||
t.Fatalf("run(generate) stderr=%q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareUsesBothSnapshotInputsAndExitCode(t *testing.T) {
|
||||
current := commandSnapshot("dws")
|
||||
mergeBase := commandSnapshot("dws")
|
||||
@@ -83,6 +106,368 @@ func TestCrossPlatformCoverageRunCompareUsesBothSnapshotInputsAndExitCode(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareEnforcesBaseOwnedFlagMigrationLifecycle(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
before := flagMigrationSnapshot(false)
|
||||
after := flagMigrationSnapshot(true)
|
||||
currentPath := writeSnapshot(t, dir, "current.json", after)
|
||||
basePath := writeSnapshot(t, dir, "base.json", before)
|
||||
stablePath := writeSnapshot(t, dir, "stable.json", before)
|
||||
approvedPath := writeManifest(t, dir, "approved.json", flagMigrationManifestJSON("pending"))
|
||||
candidatePath := writeManifest(t, dir, "candidate.json", flagMigrationManifestJSON("consumed"))
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{
|
||||
"compare",
|
||||
"--current", currentPath,
|
||||
"--base", basePath,
|
||||
"--stable", stablePath,
|
||||
"--approved-flag-migrations", approvedPath,
|
||||
"--candidate-flag-migrations", candidatePath,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exact base-owned migration exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"compatible": true`)) {
|
||||
t.Fatalf("exact migration report is not compatible:\n%s", stdout.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
emptyApproved := writeManifest(t, dir, "empty-approved.json", `{"version":1,"migrations":[]}`)
|
||||
exitCode = run([]string{
|
||||
"compare",
|
||||
"--current", currentPath,
|
||||
"--base", basePath,
|
||||
"--stable", stablePath,
|
||||
"--approved-flag-migrations", emptyApproved,
|
||||
"--candidate-flag-migrations", candidatePath,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "must start pending") {
|
||||
t.Fatalf("candidate self-approval exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareRequiresBothFlagMigrationInputs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
|
||||
basePath := writeSnapshot(t, dir, "base.json", commandSnapshot("dws"))
|
||||
approvedPath := writeManifest(t, dir, "approved.json", `{"version":1,"migrations":[]}`)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{
|
||||
"compare",
|
||||
"--current", currentPath,
|
||||
"--base", basePath,
|
||||
"--approved-flag-migrations", approvedPath,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "must be provided together") {
|
||||
t.Fatalf("one-sided migration input exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareRequiresBothReferencesForFlagMigrations(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
|
||||
basePath := writeSnapshot(t, dir, "base.json", commandSnapshot("dws"))
|
||||
stablePath := writeSnapshot(t, dir, "stable.json", commandSnapshot("dws"))
|
||||
approvedPath := writeManifest(t, dir, "approved.json", `{"version":1,"migrations":[]}`)
|
||||
candidatePath := writeManifest(t, dir, "candidate.json", `{"version":1,"migrations":[]}`)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "missing stable",
|
||||
args: []string{"--base", basePath},
|
||||
},
|
||||
{
|
||||
name: "missing base",
|
||||
args: []string{"--stable", stablePath},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
args := []string{"compare", "--current", currentPath}
|
||||
args = append(args, test.args...)
|
||||
args = append(args,
|
||||
"--approved-flag-migrations", approvedPath,
|
||||
"--candidate-flag-migrations", candidatePath,
|
||||
)
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run(args, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "requires both --base and --stable") {
|
||||
t.Fatalf("one-reference migration compare exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPrintsUsageForMissingAndUnknownCommands(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantStderr []string
|
||||
}{
|
||||
{
|
||||
name: "missing command",
|
||||
args: nil,
|
||||
wantStderr: []string{"usage:", "interface-snapshot generate", "--approved-flag-migrations"},
|
||||
},
|
||||
{
|
||||
name: "unknown command",
|
||||
args: []string{"unknown"},
|
||||
wantStderr: []string{`unknown command "unknown"`, "usage:", "interface-snapshot compare"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
|
||||
t.Fatalf("run(%v) exit=%d, want 2", test.args, exitCode)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("run(%v) unexpectedly wrote stdout: %s", test.args, stdout.String())
|
||||
}
|
||||
for _, want := range test.wantStderr {
|
||||
if !strings.Contains(stderr.String(), want) {
|
||||
t.Errorf("run(%v) stderr missing %q:\n%s", test.args, want, stderr.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunRejectsInvalidSubcommandArguments(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "generate unknown flag", args: []string{"generate", "--unknown"}, want: "flag provided but not defined"},
|
||||
{name: "generate positional", args: []string{"generate", "unexpected"}, want: "generate accepts no positional arguments"},
|
||||
{name: "compare unknown flag", args: []string{"compare", "--unknown"}, want: "flag provided but not defined"},
|
||||
{name: "compare positional", args: []string{"compare", "unexpected"}, want: "compare accepts no positional arguments"},
|
||||
{name: "compare missing current", args: []string{"compare", "--base", "base.json"}, want: "compare requires --current"},
|
||||
{name: "compare missing reference", args: []string{"compare", "--current", "current.json"}, want: "compare requires --base, --stable, or both"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
|
||||
t.Fatalf("run(%v) exit=%d, want 2", test.args, exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), test.want) {
|
||||
t.Fatalf("run(%v) stderr missing %q:\n%s", test.args, test.want, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunGenerateRejectsUnsafeOutputPath(t *testing.T) {
|
||||
outputDirectory := t.TempDir()
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{"generate", "--output", outputDirectory}, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "create snapshot") {
|
||||
t.Fatalf("directory output exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunGenerateReportsTemporaryDirectoryFailure(t *testing.T) {
|
||||
missingTempRoot := filepath.Join(t.TempDir(), "missing")
|
||||
for _, name := range []string{"TMPDIR", "TMP", "TEMP"} {
|
||||
t.Setenv(name, missingTempRoot)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{"generate"}, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "create isolated home") {
|
||||
t.Fatalf("invalid temporary root exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareReportsSnapshotReadFailures(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
validPath := writeSnapshot(t, dir, "valid.json", commandSnapshot("dws"))
|
||||
missingPath := filepath.Join(dir, "missing.json")
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "current",
|
||||
args: []string{"compare", "--current", missingPath, "--base", validPath},
|
||||
want: "read current snapshot",
|
||||
},
|
||||
{
|
||||
name: "main",
|
||||
args: []string{"compare", "--current", validPath, "--base", missingPath},
|
||||
want: "read main/development baseline snapshot",
|
||||
},
|
||||
{
|
||||
name: "stable",
|
||||
args: []string{"compare", "--current", validPath, "--stable", missingPath},
|
||||
want: "read stable snapshot",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
|
||||
t.Fatalf("run(compare) exit=%d, want 2", exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), test.want) {
|
||||
t.Fatalf("stderr missing %q:\n%s", test.want, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareReportsEachManifestReadFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
|
||||
validManifest := writeManifest(t, dir, "valid-manifest.json", `{"version":1,"migrations":[]}`)
|
||||
invalidManifest := writeManifest(t, dir, "invalid-manifest.json", `{`)
|
||||
tests := []struct {
|
||||
name string
|
||||
approved string
|
||||
candidate string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "approved manifest",
|
||||
approved: invalidManifest,
|
||||
candidate: validManifest,
|
||||
want: "read approved flag migrations",
|
||||
},
|
||||
{
|
||||
name: "candidate manifest",
|
||||
approved: validManifest,
|
||||
candidate: invalidManifest,
|
||||
want: "read candidate flag migrations",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{
|
||||
"compare",
|
||||
"--current", snapshotPath,
|
||||
"--base", snapshotPath,
|
||||
"--stable", snapshotPath,
|
||||
"--approved-flag-migrations", test.approved,
|
||||
"--candidate-flag-migrations", test.candidate,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), test.want) {
|
||||
t.Fatalf("%s exit=%d stdout=%s stderr=%s", test.name, exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareReportsOutputFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
|
||||
var stderr bytes.Buffer
|
||||
exitCode := run([]string{
|
||||
"compare",
|
||||
"--current", snapshotPath,
|
||||
"--base", snapshotPath,
|
||||
}, failingWriter{}, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "write comparison report") {
|
||||
t.Fatalf("comparison output failure exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadHelpersRejectMissingAndInvalidInputs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
missingPath := filepath.Join(dir, "missing.json")
|
||||
invalidPath := filepath.Join(dir, "invalid.json")
|
||||
if err := os.WriteFile(invalidPath, []byte(`{`), 0o600); err != nil {
|
||||
t.Fatalf("write invalid fixture: %v", err)
|
||||
}
|
||||
|
||||
if _, err := readSnapshot(missingPath); err == nil {
|
||||
t.Fatal("readSnapshot(missing) unexpectedly succeeded")
|
||||
}
|
||||
if _, err := readSnapshot(invalidPath); err == nil {
|
||||
t.Fatal("readSnapshot(invalid) unexpectedly succeeded")
|
||||
}
|
||||
if _, err := readFlagMigrationManifest(missingPath); err == nil {
|
||||
t.Fatal("readFlagMigrationManifest(missing) unexpectedly succeeded")
|
||||
}
|
||||
if _, err := readFlagMigrationManifest(invalidPath); err == nil {
|
||||
t.Fatal("readFlagMigrationManifest(invalid) unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingReportsResolveError(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
err := validateHelpRendering(root, commandSnapshot("dws missing"))
|
||||
if err == nil || !strings.Contains(err.Error(), `resolve "dws missing" before help rendering`) {
|
||||
t.Fatalf("validateHelpRendering resolve error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingReportsTemplateError(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetHelpTemplate(`{{index .Commands 99}}`)
|
||||
err := validateHelpRendering(root, commandSnapshot("dws"))
|
||||
if err == nil || !strings.Contains(err.Error(), `render "dws" help`) {
|
||||
t.Fatalf("validateHelpRendering template error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingRecoversTemplatePanic(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetHelpTemplate("{{")
|
||||
err := validateHelpRendering(root, commandSnapshot("dws"))
|
||||
if err == nil || !strings.Contains(err.Error(), `render "dws" help`) {
|
||||
t.Fatalf("validateHelpRendering template panic = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingRejectsCustomHelpStderr(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetHelpFunc(func(command *cobra.Command, _ []string) {
|
||||
_, _ = io.WriteString(command.ErrOrStderr(), "injected help failure")
|
||||
})
|
||||
err := validateHelpRendering(root, commandSnapshot("dws"))
|
||||
if err == nil || !strings.Contains(err.Error(), "injected help failure") {
|
||||
t.Fatalf("validateHelpRendering custom stderr = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingRejectsEmptyOutput(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetHelpFunc(func(*cobra.Command, []string) {})
|
||||
err := validateHelpRendering(root, commandSnapshot("dws"))
|
||||
if err == nil || !strings.Contains(err.Error(), "empty") {
|
||||
t.Fatalf("validateHelpRendering empty output = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingAcceptsNormalOutput(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws", Short: "root command"}
|
||||
if err := validateHelpRendering(root, commandSnapshot("dws")); err != nil {
|
||||
t.Fatalf("validateHelpRendering normal output: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type failingWriter struct{}
|
||||
|
||||
func (failingWriter) Write([]byte) (int, error) {
|
||||
return 0, errors.New("injected write failure")
|
||||
}
|
||||
|
||||
var _ io.Writer = failingWriter{}
|
||||
|
||||
func commandSnapshot(paths ...string) interfacesnapshot.Snapshot {
|
||||
commands := make([]interfacesnapshot.Command, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
@@ -120,6 +505,68 @@ func writeSnapshot(t *testing.T, dir, name string, snapshot interfacesnapshot.Sn
|
||||
return path
|
||||
}
|
||||
|
||||
func writeManifest(t *testing.T, dir, name, contents string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func flagMigrationSnapshot(after bool) interfacesnapshot.Snapshot {
|
||||
legacy := interfacesnapshot.Flag{
|
||||
Name: "legacy-id",
|
||||
Shorthand: "l",
|
||||
Type: "string",
|
||||
Default: "",
|
||||
NoOpt: "auto",
|
||||
Required: true,
|
||||
}
|
||||
flags := []interfacesnapshot.Flag{legacy}
|
||||
if after {
|
||||
legacy.Required = false
|
||||
legacy.Hidden = true
|
||||
legacy.AliasOf = "message-id"
|
||||
flags = []interfacesnapshot.Flag{
|
||||
legacy,
|
||||
{Name: "message-id", Type: "string", Default: "", Required: true},
|
||||
}
|
||||
}
|
||||
return interfacesnapshot.Snapshot{
|
||||
SchemaVersion: interfacesnapshot.SchemaVersion,
|
||||
Rules: interfacesnapshot.Rules{
|
||||
ExcludedCommandSubtrees: []string{},
|
||||
ExcludedFlags: []string{},
|
||||
},
|
||||
Commands: []interfacesnapshot.Command{
|
||||
{Path: "dws", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
|
||||
{Path: "dws chat send", Runnable: true, Aliases: []string{}, LocalFlags: flags, InheritedFlags: []interfacesnapshot.Flag{}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func flagMigrationManifestJSON(state string) string {
|
||||
return strings.Replace(`{
|
||||
"version": 1,
|
||||
"migrations": [{
|
||||
"command": "dws chat send",
|
||||
"legacy": {
|
||||
"name": "legacy-id",
|
||||
"before": {"present": true, "type": "string", "required": true, "shorthand": "l", "no_opt": "auto", "scope": "local"},
|
||||
"after": {"present": true, "type": "string", "hidden": true, "shorthand": "l", "no_opt": "auto", "scope": "local", "alias_of": "message-id"}
|
||||
},
|
||||
"canonical": {
|
||||
"name": "message-id",
|
||||
"before": {"present": false},
|
||||
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
|
||||
},
|
||||
"state": "STATE",
|
||||
"reason": "reviewed exact migration"
|
||||
}]
|
||||
}`, "STATE", state, 1)
|
||||
}
|
||||
|
||||
func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == name && flag.Type == flagType {
|
||||
|
||||
+39
-8
@@ -157,15 +157,33 @@ expected to repeat every CI job locally:
|
||||
```sh
|
||||
make build
|
||||
make policy
|
||||
make interface-integrity
|
||||
make authoritative-interface-integrity BASE_REF=<merge-base>
|
||||
make schema-compatibility BASE_REF=<merge-base>
|
||||
make interface-integrity BASE_REF=<merge-base> STABLE_REF=<stable-tag> CANDIDATE_REF=<candidate-sha>
|
||||
make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<stable-tag> CANDIDATE_REF=<candidate-sha>
|
||||
make skill-command-integrity
|
||||
make cli-smoke
|
||||
make mock-mcp-smoke
|
||||
go test -v -count=1 ./pkg/editiontest/...
|
||||
```
|
||||
|
||||
CI 先解析并核对精确的 merge-base、最近可达且未撤回的 stable GA tag 和已提交的 candidate
|
||||
SHA,再调用 `make authoritative-interface-integrity`。本地 `make interface-integrity`
|
||||
与该 CI target 都只委托给同一个 modern authoritative wrapper,不存在第二个比较
|
||||
入口。省略 `BASE_REF` 时本地 target 默认比较 `origin/main`,省略 `STABLE_REF` 时自动
|
||||
选择该 base 可达且未撤回的最近 stable GA tag,省略 `CANDIDATE_REF` 时比较已提交的 `HEAD`。
|
||||
需要逐字复现某次 CI 时,应显式传入该次运行记录的 merge-base、stable tag 和
|
||||
candidate SHA。
|
||||
|
||||
`make update-interface-baseline` / `make reset-interface-baseline` 只维护
|
||||
`test/fixtures/cli-interface-baseline.txt` 这一份非权威 CLI Smoke fixture。底层旧
|
||||
`check-interface-baseline.sh` 不再作为本地或 CI 的兼容性审批入口,也不能用于批准
|
||||
flag 迁移。
|
||||
|
||||
Schema compatibility 使用同一组 base、stable、candidate refs 和同一份 base-owned flag
|
||||
migration ledger。merge-base-owned checker 分别规范化 merge-base 与 stable 的完整
|
||||
Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过 Interface
|
||||
lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会
|
||||
放宽其他 Schema 历史字段。
|
||||
|
||||
For an exact CHANGELOG-only branch:
|
||||
|
||||
```sh
|
||||
@@ -186,11 +204,24 @@ are evaluated by the same block-deduplicating checker; supporting policy and
|
||||
shortcut profiles contribute to changed-code coverage only. The checked-in
|
||||
badge is presentation only and is never read as a gate input.
|
||||
|
||||
Compatibility checks derive authoritative Interface snapshots from the PR
|
||||
merge-base and the latest reachable stable release. The candidate cannot bless
|
||||
a breaking change by editing a fixture. Schema additions are allowed;
|
||||
historical products, tools, parameters, mappings, positional execution fields,
|
||||
constraints, and safety semantics remain protected.
|
||||
CLI 兼容检查只使用 modern Interface Snapshot 这一处权威比较 seam,并从 PR
|
||||
merge-base 和最近的可达 stable release 生成权威快照。本治理机制合入后,
|
||||
merge-base 拥有生成器、比较器和已审批迁移清单,因此 candidate 不能通过修改
|
||||
helper、fixture 或在同一 PR 新增 self-approval 记录来放行 breaking change。首次
|
||||
bootstrap 仍由 merge-base 已有的 modern helper 做无豁免比较,并只接受 candidate
|
||||
提交中的规范空清单;完整边界见下方治理文档。
|
||||
|
||||
精确的两阶段 flag 迁移生命周期见
|
||||
[CLI flag 兼容迁移治理](cli-interface-flag-migrations.md)。治理 PR 只能在
|
||||
surface 未变化时新增 `pending`;后续产品 PR 达到审批的精确 surface 后,才能
|
||||
消费 base-owned 记录并改为 `consumed`。在 main 与 stable 都达到 after 状态前
|
||||
必须保留该回执,之后再由单独 PR 清理。机制只放行记录中的 legacy
|
||||
visible-to-hidden,以及 canonical required 新增或提升;删除、type、scope、
|
||||
shorthand、no-opt 和任何无关漂移仍然阻塞。Schema 可以新增;历史 product、
|
||||
tool、parameter、mapping、positional execution、constraint 与 safety 语义继续
|
||||
受保护。`alias_of` 只是一项由 `FlagSpec.Aliases` 产生的框架关系证据,不是 payload
|
||||
等价证明;产品 PR 仍须证明 canonical 与 legacy 的最终运行 payload 等价并在 transport
|
||||
前拒绝冲突输入。当前迁移清单为空,不授权 PR #904。
|
||||
|
||||
## Required GitHub repository settings
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
# CLI flag 兼容迁移治理
|
||||
|
||||
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 提升为必填。它只解决这一种精确变更,不是通用 breaking-change 豁免。
|
||||
|
||||
同名 flag 的精确类型迁移属于另一类评审机制,只能进入
|
||||
`internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename
|
||||
只能进入本文的 JSON lifecycle ledger。一项迁移不得跨两种机制组合授权。
|
||||
|
||||
## 唯一比较入口与信任边界
|
||||
|
||||
PR 与本地兼容性审批的唯一权威比较入口是 modern Interface Snapshot:
|
||||
|
||||
- `cmd/interface-snapshot` 生成和比较快照;
|
||||
- `internal/interfacesnapshot` 实现兼容规则和迁移生命周期;
|
||||
- `scripts/policy/check-command-compatibility.sh` 组装 candidate、PR merge-base 和最近可达且未撤回的 stable GA 三份快照;
|
||||
- `scripts/policy/check-authoritative-interface-baselines.sh` 只保留为 Makefile 的兼容包装,不再维护第二套判断逻辑。
|
||||
|
||||
紧随其后的 Schema compatibility 不是第二份审批清单。它从同一 merge-base-owned
|
||||
ledger 和同一组三方 Interface Snapshot 取得已经完成 lifecycle 校验的
|
||||
authorization。merge-base-owned checker 会分别规范化 merge-base 与 stable 的完整
|
||||
Schema,并让 candidate 对两份历史 contract 独立执行检查;授权的 flag rename 只会
|
||||
精确投影到当前被检查的历史副本。candidate 不能为 CLI 与 Schema 分别提供两套例外。
|
||||
|
||||
`make interface-integrity` 也调用上述 authoritative wrapper;默认 base 为
|
||||
`origin/main`,stable 可由包装脚本自动解析,candidate 默认为已提交的 `HEAD`。旧
|
||||
`scripts/policy/check-interface-baseline.sh` 只供
|
||||
`make update-interface-baseline` / `make reset-interface-baseline` 维护非权威 CLI
|
||||
Smoke fixture,不参与迁移审批。
|
||||
|
||||
直接调用 `interface-snapshot compare` 时,只要提供 migration manifest 参数,就必须
|
||||
同时提供 `--base` 与 `--stable`;核心 lifecycle 也拒绝缺失 stable 的非空清单,避免
|
||||
调用方因漏传历史参考而提前清理 consumed receipt。
|
||||
|
||||
PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入本机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空清单,不会让 candidate 新增的 comparator 决定本 PR 是否兼容。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此本治理 PR 的新 parser、lifecycle、launcher 与 hostile tests 仍是必须由真人评审的受保护策略变更;它们合入后才成为后续 PR 的 base-owned authority。
|
||||
|
||||
这条边界保护比较规则和审批数据,不是任意代码沙箱。GitHub workflow / launcher 的变更仍由仓库保护规则和真人评审负责;candidate Cobra 构建也会执行 candidate 代码,因此对同一 runner 上的主动恶意代码,需要独立进程或文件系统隔离,不能把本门禁描述成已经解决。
|
||||
|
||||
已审批清单固定为:
|
||||
|
||||
```text
|
||||
scripts/policy/interface-migrations/approved-flag-migrations-v1.json
|
||||
```
|
||||
|
||||
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。当前清单为空,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。
|
||||
|
||||
## 两阶段迁移与回执清理
|
||||
|
||||
每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期:
|
||||
|
||||
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|
||||
|---|---|---|
|
||||
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
|
||||
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 达到记录的必填状态 |
|
||||
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
|
||||
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
|
||||
|
||||
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。
|
||||
|
||||
下面只是清单结构示例,不代表已审批命令;实际字段必须从 Interface Snapshot 核对:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"migrations": [
|
||||
{
|
||||
"command": "dws chat message recall",
|
||||
"legacy": {
|
||||
"name": "msg-id",
|
||||
"before": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"scope": "local"
|
||||
},
|
||||
"after": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"hidden": true,
|
||||
"scope": "local",
|
||||
"alias_of": "message-id"
|
||||
}
|
||||
},
|
||||
"canonical": {
|
||||
"name": "message-id",
|
||||
"before": { "present": false },
|
||||
"after": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"scope": "local"
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将规范 flag 设为唯一可见入口"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
产品迁移 PR 必须保持同一条记录的命令、flag、before/after 和 reason 不变,只把 `pending` 改成 `consumed`。
|
||||
|
||||
## `alias_of` 是框架来源的受评审关系证据
|
||||
|
||||
`alias_of` 不是 Schema 同义词、参数概念词典或任意文字声明。它只能由 `FlagSpec.Aliases` 写入,并与内部 origin `corecmd.flag_spec_aliases.v1` 成对出现;每次 Interface Integrity 都会在已提交的 detached candidate 上执行源码门禁,禁止其他生产文件写入或复刻这些 evidence token。Interface Snapshot 会验证:
|
||||
|
||||
- legacy 与 canonical 位于同一个可执行命令;
|
||||
- canonical flag 确实存在;
|
||||
- legacy 与 canonical 类型一致;
|
||||
- legacy 不是指向自身,也不存在 alias chain;
|
||||
- legacy 的 after 状态精确指向该记录中的 canonical flag。
|
||||
|
||||
通过命令框架声明 `FlagSpec.Aliases` 时,框架会自动注册隐藏的兼容 flag,并写入两项 relation annotation;仅手写 `alias_of`、伪造 origin、重复值或不精确值都会让快照生成失败。不要用 Schema overlay、迁移清单或手写 Cobra annotation 伪造关系。
|
||||
|
||||
这项关系证据只证明 legacy/canonical 经过受控框架路径建立关系,不证明最终 transport payload 等价,也不会替产品代码实现命令特有的值同步。当前框架还禁止把
|
||||
`MarkRequired` 与 `FlagSpec.Aliases` 直接组合,因为 Cobra 的 hard-required
|
||||
校验只识别 canonical spelling。若产品迁移同时需要 canonical 的 Cobra required
|
||||
标记和 legacy spelling,产品 PR 必须提供明确的运行时方案,并通过 canonical / legacy
|
||||
最终 payload 等价、同值输入一致、冲突输入在 transport 前失败、legacy 仍可调用但 Help 隐藏等测试;迁移清单和 relation evidence 都不能替代这些证明。
|
||||
|
||||
## 豁免边界
|
||||
|
||||
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
|
||||
|
||||
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
|
||||
2. canonical flag 的 `required_flag_added`(新增时即必填)或 `flag_became_required`(已有 flag 从可选变必填)。
|
||||
|
||||
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
|
||||
|
||||
- 删除 legacy、canonical、命令或其他 flag;
|
||||
- flag 类型或迁移记录中的 scope、shorthand、`no_opt` 漂移;
|
||||
- `alias_of` 缺失、指向变化或 alias chain;
|
||||
- 命令路径及任何无关的阻塞性接口变化;
|
||||
- 不精确、部分完成、超出记录范围的 surface 变化。
|
||||
|
||||
## Schema 投影边界
|
||||
|
||||
Agent-visible command 会把 visible Cobra flag 投影为 Schema parameter,因此合法的
|
||||
legacy hidden 迁移会同时表现为历史 parameter 消失,constraint member 也可能从
|
||||
legacy 名改为 canonical 名。Schema adapter 只接受已经由三方 Interface Snapshot
|
||||
判定为 authorized 的迁移,并按 tool 的精确 `primary_cli_path` 绑定:
|
||||
|
||||
- reference 仍处于 `before` 且该 flag 有 Schema surface 时,baseline legacy parameter
|
||||
必须存在,candidate legacy parameter 必须消失,candidate canonical parameter 必须存在;
|
||||
如果 baseline 只有 canonical、没有 legacy,则 adapter 不得借 CLI ledger 提升
|
||||
`required` / `cli_required` 或重写 constraint;
|
||||
- rename 前后的 `type`、`property`、`interface_type`、default、format、enum 与
|
||||
`required_when` 必须完全一致;
|
||||
- `required` / `cli_required` 只能保持不变或按审批从 `false` 提升为 `true`,禁止降低;
|
||||
- constraint 只允许在同一 tool 内按已枚举的 legacy → canonical map 做 member 替换、
|
||||
排序与去重;group kind、非迁移 member 或 group 增删仍然阻塞;
|
||||
- 多个 legacy 指向同一 canonical 时,所有历史 parameter signature 必须一致,否则
|
||||
fail closed。
|
||||
|
||||
adapter 先构造经过上述验证的历史 contract 副本,再调用原 Schema checker;它不会按
|
||||
错误字符串删除 finding。这样既能处理纯 rename,也能阻止“旧 required 参数改名后意外
|
||||
变为 optional”或 property 漂移等伪兼容。`consumed` 回执在 merge-base Schema 已经处于
|
||||
canonical-only `after` 状态时不需要再次投影;adapter 保持 baseline 不变,由原 checker
|
||||
验证 candidate 是否仍与该 canonical contract 兼容。
|
||||
|
||||
## 本地验证
|
||||
|
||||
先确保 merge-base 和 stable tag 已在本地,然后运行与 CI 相同的权威门禁:
|
||||
|
||||
```sh
|
||||
make interface-integrity \
|
||||
BASE_REF=<merge-base> \
|
||||
STABLE_REF=<stable-tag> \
|
||||
CANDIDATE_REF=<candidate-sha>
|
||||
|
||||
make schema-compatibility \
|
||||
BASE_REF=<merge-base> \
|
||||
STABLE_REF=<stable-tag> \
|
||||
CANDIDATE_REF=<candidate-sha>
|
||||
```
|
||||
|
||||
`STABLE_REF` 必须解析到从该 merge-base 可达的最高未撤回 stable GA tag;primary checker 会按 release contract 独立核对,不能用任意 after commit 或已撤回版本提前清理回执。包装脚本可以在省略时自动解析。`CANDIDATE_REF` 省略时固定为命令启动时的已提交 `HEAD`;评审和复现 CI 时应显式传入 candidate SHA,避免 surface 与清单来自不同 revision。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -70,16 +70,17 @@
|
||||
|
||||
> 已落地:minutes `+detail`(✅ smart `+detail`)、minutes `+word-replace`(✅ smart `+replace-batch`,批量+去重)、base `+record-share-link-create`(✅ smart `+record-share-links`,>20 去重+分片+合并)、im `+threads-messages-list`(✅ smart `chat +thread-replies`,list_topic_replies + 投影)、im `+chat-list`(✅ smart `chat +chat-list`)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
|
||||
|
||||
### im → chat(5)
|
||||
### im → chat(7)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+chat-list` ✅ | read | **已建 smart `chat +chat-list`**:`list_all_conversations` + 默认仅群聊 + `--types group/p2p` 当前页过滤 + `--exclude-muted` + page-size/page-token 别名 + openConversationId/name/conversationType 投影。剩余未做:sort/sort-type、bot 身份 p2p 剥离(DWS 无对应身份模型) |
|
||||
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊 list_conversation_message_v2 / list_individual_chat_message 互斥 + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
|
||||
| `+chat-search` | read | dws 无群名模糊搜索v2对应 tool(search_common_groups/find 语义不同),缺 query规范化、mode映射、mute过滤、meta投影 |
|
||||
| `+chat-list` ✅ | read | **已建 smart `chat +chat-list`**:`list_all_conversations` + 默认仅群聊 + `--types group/p2p` + `--exclude-muted` + page-size/page-token 别名 + `--page-all/--page-limit` 数字 cursor 自动翻页、跨页去重、合并后类型过滤和完整性 ledger。剩余未做:sort/sort-type、bot 身份 p2p 剥离(DWS 无对应身份模型) |
|
||||
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊互斥解析、时间范围、asc/desc、时间边界全量翻页、reaction、资源下载与完整性 ledger |
|
||||
| `+chat-search` ✅ | read | **已建 smart `chat +chat-search`**:真实 `search_groups` 关键词搜索 + page-size/page-token 别名 + `--page-all/--page-limit` 不透明 cursor 自动翻页、跨页去重和完整性 ledger。Lark v2 的 member/type/mode/manager/sort 过滤没有可验证的钉钉对应参数,未伪造 |
|
||||
| `+flag-list` ✅ | read | **已建 smart `chat +flag-list`**:真实 `list_message_favorites` 的 `items + hasMore + 数字 nextCursor`,支持 page-size/page-token、`--page-all/--page-limit`、跨页去重和完整性 ledger;仅对齐 message favorite,不模拟 Lark Feed thread flag |
|
||||
| `+messages-resources-download` | write | dws download-media 走 get_resource_download_url 拿URL,缺分片Range下载/重试/扩展名推断/安全落盘路径校验 |
|
||||
| `+messages-search` | read | dws 有 search_messages_by_keyword/by_time_range/by_sender/at_me 多个原子 tool,但各自单点,缺统一多维filter编排+mget+chat上下文富化+跨字段Validate |
|
||||
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:list_topic_replies + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
|
||||
| `+messages-search` ✅ | read | **已建 smart `chat +search-msg`**:统一多维过滤、精确时间范围、asc/desc、cursor 全量翻页、mget 富化、reaction、资源下载与完整性 ledger。剩余差异是 Lark chat 上下文和部分 sender/attachment 类型过滤 |
|
||||
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:支持主消息 ID 自动只读解析 conversation/thread,也支持显式 group + thread/topic ID;list_topic_replies + sender/text/time/reaction/resource 投影 + 下层毫秒级 nextCursor 有界自动翻页、跨页去重、完整性 ledger,以及全量结果 asc/desc。与 Lark 的剩余差异是钉钉底层没有服务端 asc 单页,因此 DWS 的 asc 明确要求 `--page-all`,避免伪全局排序 |
|
||||
|
||||
### task → todo(3)
|
||||
|
||||
|
||||
@@ -2681,7 +2681,7 @@
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "一体化创建会话、上传、确认转换并轮询导入结果。",
|
||||
"semantic_delta": "一体化创建会话、上传、确认转换并轮询导入结果;白名单外格式自动改走文件上传原样入库(fallback=upload、converted=false)。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -101,5 +101,5 @@ skills/mono/
|
||||
## 7. 变更流程
|
||||
|
||||
1. 改 / 增内容 → 更新 `skills/content-qa/mono-multi-coverage.yaml`(coverage 或 omit)
|
||||
2. 跑 `make skill-mono-multi-content`(或 `make policy`)
|
||||
2. 跑 `make skill-mono-multi-content`(该独立门禁不包含在默认 `make policy` 中)
|
||||
3. 失败则修内容或更新 reviewed omit(disposition + 原因),**禁止**用安装默认值绕过
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。
|
||||
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。
|
||||
> 执行:`make skill-mono-multi-content`(已挂入 `make policy`)。
|
||||
> 执行:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)。
|
||||
|
||||
## 1. 质检矩阵
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
| **G1 形状** | 结构 | `skills/multi/*` | 仅 `dingtalk-*`(含必选 `dingtalk-shared`);每目录有 `SKILL.md` |
|
||||
| **G2 结构** | 结构 | 各 `SKILL.md` frontmatter | `name`==目录名;非空 `description`;`category`∈{product,shared};`requires.bins` 含 `dws` |
|
||||
| **G3 覆盖** | 覆盖 | mono `references/products/*` 顶层 stem | 每 stem ∈ `coverage` 或 `omit_coverage`;coverage 目标 skill/refs 存在 |
|
||||
| **G4 漂移** | 漂移 | scripts、成对文件、全局协议 | orphan 脚本 ∈ allowlist;paired 一致;全局协议存在或 ∈ `omit_global` |
|
||||
| **G4 漂移** | 漂移 | scripts、成对文件、全局协议 | orphan 脚本 ∈ allowlist;paired 内容一致(允许合同声明的布局链接替换);全局协议存在或 ∈ `omit_global` |
|
||||
| **G5 链接** | 可达性 | 合同覆盖的 paired Markdown | 内联相对链接目标文件或目录存在且不逃出仓库;外链、纯锚点和锚点内容不在检查范围 |
|
||||
|
||||
已有门禁(继续复用,不替代本矩阵):`check-skill-commands`、`check-skill-context-budget`、`check-multi-im-skill-chain`、`skill_docs_policy`、whiteboard 成对测试。
|
||||
|
||||
@@ -37,6 +38,16 @@ orphan_scripts_allowlist:
|
||||
- path: dingtalk-misc/scripts/report_received_today.py
|
||||
disposition: defer
|
||||
reason: "pending report.md reference"
|
||||
|
||||
paired_files:
|
||||
- mono: references/products/sheet.md
|
||||
multi: dingtalk-misc/references/sheet.md
|
||||
mode: link-normalized
|
||||
link_substitutions:
|
||||
- mono: "../url-patterns.md"
|
||||
multi: "../../dingtalk-shared/references/url-patterns.md"
|
||||
- mono: "../intent-guide.md"
|
||||
multi: "sheet-intent-guide.md"
|
||||
```
|
||||
|
||||
**处置原则**:质检失败 → 修**内容**或更新 reviewed omit;**不**改安装/升级默认。
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
> 合同短文:[skill-content-framework.md](skill-content-framework.md)
|
||||
> 质检规格:[skill-mono-multi-qa.md](skill-mono-multi-qa.md)
|
||||
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`
|
||||
> 门禁:`make skill-mono-multi-content`(已入 `make policy`)
|
||||
> 门禁:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)
|
||||
>
|
||||
> 撰写 / 收窄 / 质检增补 / 执行:2026-08-05
|
||||
> 工作树:`/Users/john/GolandProjects/open-source/dws-multi-skill-align`
|
||||
|
||||
@@ -39,7 +39,7 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
|
||||
copyArgs[key] = value
|
||||
}
|
||||
c.calls = append(c.calls, paramAliasToolCall{server: server, tool: tool, args: copyArgs})
|
||||
text := paramAliasResponseForTool(tool)
|
||||
text := c.paramAliasResponseForTool(tool)
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
|
||||
// print the transport result and need an empty object; smart shortcuts that
|
||||
// inspect a read response receive the smallest shape that lets their full RunE
|
||||
// complete without falling back to a validation error.
|
||||
func paramAliasResponseForTool(tool string) string {
|
||||
func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string {
|
||||
switch tool {
|
||||
case "list_calendar_events":
|
||||
return `{"result":{"events":[]}}`
|
||||
@@ -62,9 +62,22 @@ func paramAliasResponseForTool(tool string) string {
|
||||
return `{"result":[{"name":"Fixture User","userId":"fixture-user","openDingTalkId":"D-fixture-user"}]}`
|
||||
case "list_doc_versions":
|
||||
return `{"result":{"items":[{"version":3}]}}`
|
||||
case "revert_doc_version":
|
||||
return `{"version":3}`
|
||||
case "search_doc_templates":
|
||||
return `{"result":[{"templateId":"fixture-template-id"}]}`
|
||||
case "create_document":
|
||||
return `{"nodeId":"fixture-node"}`
|
||||
case "get_document_content":
|
||||
for index := len(c.calls) - 2; index >= 0; index-- {
|
||||
call := c.calls[index]
|
||||
for _, key := range []string{"jsonml", "markdown"} {
|
||||
if content, ok := call.args[key].(string); ok {
|
||||
encoded, _ := json.Marshal(map[string]any{"revision": 1, key: content})
|
||||
return string(encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
return `{"revision":1}`
|
||||
default:
|
||||
return `{}`
|
||||
@@ -173,6 +186,24 @@ func executeParamAliasDryRunE2E(t *testing.T, args ...string) (*pipeline.Context
|
||||
return ctx, preview, append([]executor.Invocation(nil), rejectRunner.attempts...), executeErr
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagListDryRunStopsBeforeReadDispatch(t *testing.T) {
|
||||
_, preview, attempts, err := executeParamAliasDryRunE2E(t,
|
||||
"chat", "+flag-list", "--page-size", "20", "--cursor", "0", "--dry-run",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("flag-list dry-run error = %v", err)
|
||||
}
|
||||
if len(attempts) != 0 {
|
||||
t.Fatalf("flag-list dry-run crossed dispatch boundary: %#v", attempts)
|
||||
}
|
||||
if !preview.DryRun || preview.Executed || preview.Tool != "list_message_favorites" {
|
||||
t.Fatalf("flag-list dry-run preview = %#v", preview)
|
||||
}
|
||||
if preview.Arguments["cursor"] != float64(0) || preview.Arguments["size"] != "20" {
|
||||
t.Fatalf("flag-list dry-run arguments = %#v", preview.Arguments)
|
||||
}
|
||||
}
|
||||
|
||||
func executeParamAliasE2E(t *testing.T, caller *paramAliasCaptureCaller, args ...string) (*pipeline.Context, error) {
|
||||
t.Helper()
|
||||
originalArgs := os.Args
|
||||
|
||||
@@ -48,7 +48,7 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"chat +feed-group-query-item": {"chat", "+feed-group-query-item", "--category-id", "7", "--conversation-ids", "fixture-conversation"},
|
||||
"chat +flag-cancel": {"chat", "+flag-cancel", "--conversation-id", "fixture-conversation", "--message-id", "message-1", "--yes"},
|
||||
"chat +flag-create": {"chat", "+flag-create", "--conversation-id", "fixture-conversation", "--message-id", "message-1", "--yes"},
|
||||
"chat +flag-list": {"chat", "+flag-list", "--cursor", "0", "--size", "7"},
|
||||
"chat +flag-list": {"chat", "+flag-list", "--cursor", "0", "--page-size", "7"},
|
||||
"chat +messages-combine-forward": {"chat", "+messages-combine-forward", "--src-conversation-id", "fixture-source", "--msg-ids", "message-1,message-2", "--dest-conversation-id", "fixture-destination", "--yes"},
|
||||
"chat +messages-forward": {"chat", "+messages-forward", "--src-conversation-id", "fixture-source", "--msg-id", "message-1", "--dest-conversation-id", "fixture-destination", "--yes"},
|
||||
"chat +messages-forward-topic": {"chat", "+messages-forward-topic", "--src-msg-id", "message-1", "--src-conversation-id", "fixture-source", "--src-thread-id", "convThread-fixture", "--dest-conversation-id", "fixture-destination", "--yes"},
|
||||
@@ -123,7 +123,7 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"doc +version-list": {"doc", "+version-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"doc +version-revert": {"doc", "+version-revert", "--node", "node-1", "--version", "3", "--yes"},
|
||||
"doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"},
|
||||
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "append", "--content", "fixture body", "--expected-revision", "1", "--yes"},
|
||||
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"},
|
||||
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"},
|
||||
@@ -212,7 +212,7 @@ var paramAliasNewIMCases = []struct {
|
||||
{command: "chat +chat-update", emitted: "open-conversation-id", canonical: "group"},
|
||||
{command: "chat +chat-update", emitted: "title", canonical: "name"},
|
||||
{command: "chat +chat-update", emitted: "new-title", canonical: "name"},
|
||||
{command: "chat +flag-list", emitted: "limit", canonical: "size"},
|
||||
{command: "chat +flag-list", emitted: "limit", canonical: "page-size"},
|
||||
{command: "chat +chat-members-list", emitted: "chat-id", canonical: "conversation-id"},
|
||||
{command: "chat +chat-members-list", emitted: "id", canonical: "conversation-id"},
|
||||
{command: "chat +conversation-set-top", emitted: "open-conversation-id", canonical: "conversation-id"},
|
||||
|
||||
@@ -296,7 +296,14 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles {
|
||||
t.Fatalf("write dry-run fixture %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
return agentExampleFiles{root: root, markdown: markdown, json: jsonFile, batch: batch, binary: binary, image: image}
|
||||
return agentExampleFiles{
|
||||
root: root,
|
||||
markdown: "./" + filepath.Base(markdown),
|
||||
json: "./" + filepath.Base(jsonFile),
|
||||
batch: "./" + filepath.Base(batch),
|
||||
binary: "./" + filepath.Base(binary),
|
||||
image: "./" + filepath.Base(image),
|
||||
}
|
||||
}
|
||||
|
||||
func materializeAgentExampleArgv(argv []string, files agentExampleFiles) []string {
|
||||
|
||||
@@ -34,7 +34,12 @@ func TestReviewedRoutedInterfacesReachFinalSchema(t *testing.T) {
|
||||
{
|
||||
canonical: "sheet.range_batch_set_style",
|
||||
mode: "composite",
|
||||
reason: "The CLI reads a local batch file and performs multiple sheet/update_range calls with local continue-on-error control; the workflow has no single direct MCP interface.",
|
||||
reason: "The CLI assembles style cell matrices locally from --ranges or a local batch file and submits them as one sheet/batch_update operations array; no single direct MCP interface represents the wrapper input shape.",
|
||||
},
|
||||
{
|
||||
canonical: "sheet.create_with_data",
|
||||
mode: "composite",
|
||||
reason: "Reviewed composite workflow: the command calls sheet/create_workspace_sheet, waits for the new document to become writable, resolves the default worksheet, writes the initial data through sheet/set_range_from_csv or sheet/table_put, reads it back with sheet/get_range_as_csv, and optionally applies sheet/set_cell_range, sheet/update_dimension and sheet/merge_cells; no single pinned RPC represents the workflow.",
|
||||
},
|
||||
{
|
||||
canonical: "sheet.range_read",
|
||||
|
||||
@@ -129,6 +129,75 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryDocUpdateShortcutPublishesCompleteConditionalContract(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", "doc +update")
|
||||
if got, want := schemaContractString(leaf["confirmation"]), "user_required"; got != want {
|
||||
t.Fatalf("confirmation = %q, want %q", got, want)
|
||||
}
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
if got, want := len(parameters), 11; got != want {
|
||||
t.Fatalf("parameter count = %d, want %d: %#v", got, want, parameters)
|
||||
}
|
||||
if required, _ := parameters["node"]["required"].(bool); !required {
|
||||
t.Errorf("--node required = %#v, want true", parameters["node"]["required"])
|
||||
}
|
||||
if required, _ := parameters["command"]["required"].(bool); required {
|
||||
t.Errorf("--command required = true, want runtime custom validation")
|
||||
}
|
||||
wantProperties := map[string]string{
|
||||
"node": "node", "doc": "node", "command": "command", "content": "content", "text": "content", "doc-format": "docFormat",
|
||||
"block-id": "blockId", "after-block-id": "afterBlockId", "old": "old", "new": "new",
|
||||
"expected-revision": "expectedRevision",
|
||||
}
|
||||
for name, want := range wantProperties {
|
||||
if got := schemaContractString(parameters[name]["property"]); got != want {
|
||||
t.Errorf("--%s property = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"content", "block-id", "after-block-id", "old", "new"} {
|
||||
parameter := parameters[name]
|
||||
if required, _ := parameter["required"].(bool); required {
|
||||
t.Errorf("--%s required = true, want runtime custom validation", name)
|
||||
}
|
||||
if got := schemaContractString(parameter["required_when"]); got != "" {
|
||||
t.Errorf("--%s required_when = %q, want compatibility-safe custom validation", name, got)
|
||||
}
|
||||
}
|
||||
if constraints, exists := leaf["constraints"]; exists && constraints != nil {
|
||||
t.Fatalf("enum-discriminated requirements must not be mispublished as relationship constraints: %#v", constraints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryDocCommentExportImportContractsAreCanonical(t *testing.T) {
|
||||
comment := executeShortcutSchemaQuery(t, "--cli-path", "doc +comment-create")
|
||||
commentParameters := schemaContractMap(comment["parameters"])
|
||||
for _, name := range []string{"node", "content", "selection", "block-id", "start", "end", "selected-text", "mention"} {
|
||||
if _, ok := commentParameters[name]; !ok {
|
||||
t.Errorf("comment-create missing --%s: %#v", name, commentParameters)
|
||||
}
|
||||
}
|
||||
for name, want := range map[string]string{"node": "node", "mention": "mention"} {
|
||||
if got := schemaContractString(commentParameters[name]["property"]); got != want {
|
||||
t.Errorf("comment-create --%s property = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
export := executeShortcutSchemaQuery(t, "--cli-path", "doc +export")
|
||||
exportFormat := schemaContractMap(export["parameters"])["export-format"]
|
||||
if required, _ := exportFormat["required"].(bool); required {
|
||||
t.Fatalf("export --export-format required = true, want compatibility default")
|
||||
}
|
||||
if defaultValue := schemaContractString(exportFormat["default"]); defaultValue != "docx" {
|
||||
t.Fatalf("export --export-format default = %q, want docx", defaultValue)
|
||||
}
|
||||
|
||||
importLeaf := executeShortcutSchemaQuery(t, "--cli-path", "doc +import")
|
||||
constraints, _ := importLeaf["constraints"].(map[string]any)
|
||||
if requireOneOf, ok := constraints["require_one_of"]; ok && !schemaContractJSONEqual(requireOneOf, [][]string{}) {
|
||||
t.Fatalf("import unexpectedly requires a target: %#v", constraints)
|
||||
}
|
||||
}
|
||||
|
||||
func executeShortcutSchemaQuery(t testing.TB, args ...string) map[string]any {
|
||||
t.Helper()
|
||||
root := NewRootCommand()
|
||||
|
||||
@@ -568,9 +568,9 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat +flag-list",
|
||||
Aliases: map[string]string{
|
||||
"limit": "size",
|
||||
"limit": "page-size",
|
||||
},
|
||||
Blocked: []string{"count", "max", "max-results", "max-size", "page", "page-size", "per-page"},
|
||||
Blocked: []string{"count", "max", "max-results", "max-size", "page", "per-page"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat +group-members",
|
||||
@@ -2003,7 +2003,6 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"next-page-token": "cursor",
|
||||
"next-token": "cursor",
|
||||
"node-id": "node",
|
||||
"page-size": "limit",
|
||||
"page-token": "cursor",
|
||||
"per-page": "limit",
|
||||
"size": "limit",
|
||||
@@ -2459,25 +2458,15 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "doc +version-list",
|
||||
Aliases: map[string]string{
|
||||
"doc": "node",
|
||||
"doc-id": "node",
|
||||
"document-id": "node",
|
||||
"file-id": "node",
|
||||
"max-result": "limit",
|
||||
"max-results": "limit",
|
||||
"next-cursor": "cursor",
|
||||
"next-page-token": "cursor",
|
||||
"next-token": "cursor",
|
||||
"node-id": "node",
|
||||
"page-size": "limit",
|
||||
"page-token": "cursor",
|
||||
"per-page": "limit",
|
||||
"size": "limit",
|
||||
"take": "limit",
|
||||
"top": "limit",
|
||||
"url": "node",
|
||||
"doc": "node",
|
||||
"doc-id": "node",
|
||||
"document-id": "node",
|
||||
"file-id": "node",
|
||||
"node-id": "node",
|
||||
"url": "node",
|
||||
},
|
||||
Blocked: []string{"block-id", "comment-id", "comment-key", "count", "folder", "folder-id", "id", "job-id", "offset", "page", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"},
|
||||
Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"},
|
||||
Ambiguous: []string{"max-result", "max-results", "next-cursor", "next-page-token", "next-token", "per-page", "size", "take", "top"},
|
||||
},
|
||||
{
|
||||
CLIPath: "doc +version-revert",
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
},
|
||||
|
||||
"command_overrides": {
|
||||
"doc +version-list": {"ambiguous": ["size", "max-results", "max-result", "take", "top", "per-page", "next-cursor", "next-token", "next-page-token"], "note": "--limit/--cursor and the shipped visible compatibility flags --page-size/--page-token remain native. Other pagination spellings cannot choose between two visible real flags and must stop before execution."},
|
||||
"chat group rename": {"bind": {"id": "open_conversation_id"}, "note": "This command's real --id carries one openConversationId; aliases reduce to --id without changing the value."},
|
||||
"chat group members": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."},
|
||||
@@ -148,7 +149,7 @@
|
||||
"chat +chat-update": {"scoped_aliases": {"conversation-id": "group", "open-conversation-id": "group", "chat-id": "group", "title": "name", "new-title": "name"}, "block": ["id", "group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "scope_strict": true, "note": "--group accepts a name or CID, so only explicit CID spellings are mapped; generic --id is blocked."},
|
||||
"chat +conversation-set-top": {"scoped_aliases": {"open-conversation-id": "conversation-id", "chat-id": "conversation-id", "open-conversation-ids": "conversation-ids", "chat-ids": "conversation-ids"}, "block": ["group", "groups", "group-id", "group-ids", "top", "set-top"], "scope_strict": true, "note": "Singular/list cardinality stays explicit; top/set-top cannot be rewritten to the inverse --off switch."},
|
||||
"chat +feed-group-query-item": {"scoped_aliases": {"open-conversation-ids": "conversation-ids", "chat-ids": "conversation-ids"}, "block": ["group", "groups", "group-id", "group-ids", "conversation-id", "open-conversation-id"], "scope_strict": true, "note": "The real field is an openConversationId list; group names and singular IDs are not converted."},
|
||||
"chat +flag-list": {"scoped_aliases": {"limit": "size"}, "block": ["max", "max-results", "max-size", "count", "page", "page-size", "per-page"], "scope_strict": true, "note": "Only limit and size are reviewed as the same page bound; total-count and page-number spellings are not equivalent."},
|
||||
"chat +flag-list": {"scoped_aliases": {"limit": "page-size"}, "block": ["max", "max-results", "max-size", "count", "page", "per-page"], "scope_strict": true, "note": "Native --page-size is the canonical page bound, native --size is its command-owned compatibility alias, and --limit is reviewed as value-preservingly equivalent to --page-size; total-count and page-number spellings are not equivalent."},
|
||||
"chat +messages-batch-recall-by-bot": {"block": ["msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "note": "--keys carries processQueryKey values returned by bot sending; it is not an openMessageId field."},
|
||||
"chat +messages-combine-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "Source and destination conversation roles remain explicit; role-free CID spellings cannot choose a side."},
|
||||
"chat +messages-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id", "src-open-message-id": "msg-id", "source-message-id": "msg-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "The message role is uniquely the source message, but source/destination conversation roles cannot be inferred from a generic CID."},
|
||||
@@ -361,7 +362,7 @@
|
||||
{"command": "chat +chat-update", "emitted": "id", "expect": "did-you-mean:blocked", "via": "guard:generic-id-value-domain"},
|
||||
{"command": "chat +chat-update", "emitted": "title", "expect": "name", "via": "override:scoped-group-title"},
|
||||
{"command": "chat +chat-update", "emitted": "new-title", "expect": "name", "via": "override:scoped-group-title"},
|
||||
{"command": "chat +flag-list", "emitted": "limit", "expect": "size", "via": "override:scoped-page-bound"},
|
||||
{"command": "chat +flag-list", "emitted": "limit", "expect": "page-size", "via": "override:scoped-page-bound"},
|
||||
{"command": "chat +flag-list", "emitted": "max", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
{"command": "chat +flag-list", "emitted": "max-results", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
{"command": "chat +flag-list", "emitted": "max-size", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
|
||||
@@ -592,10 +592,26 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"sheet.range_batch_clear --node": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.range_batch_clear --ranges": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --ranges is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.range_batch_clear --type": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --type is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.range_batch_set_style --batch": "Composite wrapper reads this local JSON file and performs zero or more update_range calls; the filesystem path is not a direct RPC property.",
|
||||
"sheet.range_batch_set_style --continue-on-error": "Composite wrapper consumes this flag in its local multi-call error loop and never sends it to update_range.",
|
||||
"sheet.range_batch_set_style --batch": "Composite wrapper reads this local JSON file and assembles it into a single sheet/batch_update operations array; the filesystem path is not a direct RPC property.",
|
||||
"sheet.range_batch_set_style --continue-on-error": "Reviewed unpinned adapter: sheet.range_batch_set_style has no singular pinned interface_ref; --continue-on-error is forwarded to sheet/batch_update as continueOnError and does not publish a direct interface property.",
|
||||
"sheet.range_read --range": "Reviewed unpinned adapter: sheet.range_read has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.range_read --sheet-id": "Reviewed unpinned adapter: sheet.range_read has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.range_set_style --bg-color": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.backgroundColor across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --bg-colors-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.backgroundColor across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --border-styles-json": "Composite stamp wrapper: set-style attaches this object as cells[][].borderStyles on every cell of the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-color": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontColor across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-colors-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.fontColor across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-family": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontFamily across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-line": "Composite stamp wrapper: set-style expands this single-choice flag into the cells[][].cellStyles.textUnderline and textLineThrough booleans of the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-size": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontSize across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-sizes-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.fontSize across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-style": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontStyle across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-weight": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontWeight across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-weights-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.fontWeight across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --h-align": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.horizontalAlignment across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --h-aligns-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.horizontalAlignment across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --v-align": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.verticalAlignment across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --v-aligns-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.verticalAlignment across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.reply_sheet_comment --comment-key": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --comment-key is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.reply_sheet_comment --content": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --content is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.reply_sheet_comment --emoji": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --emoji is a CLI wrapper input and does not publish a direct interface property.",
|
||||
|
||||
@@ -558,6 +558,18 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
|
||||
for _, alias := range flag.Aliases {
|
||||
RegisterFlag(cmd, flag.Kind, alias, "", flag.Usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
if registered := cmd.Flags().Lookup(alias); registered != nil {
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOf,
|
||||
flag.Name,
|
||||
)
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOrigin,
|
||||
runtimeannotate.FlagAliasOriginCorecmdV1,
|
||||
)
|
||||
}
|
||||
}
|
||||
if flag.MarkRequired {
|
||||
_ = cmd.MarkFlagRequired(flag.Name)
|
||||
|
||||
@@ -90,11 +90,17 @@ func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
// Aliases are registered with the main kind and hidden.
|
||||
for _, alias := range []string{"i-alias", "sl-alias"} {
|
||||
for alias, canonical := range map[string]string{"i-alias": "i", "sl-alias": "sl"} {
|
||||
f := cmd.Flags().Lookup(alias)
|
||||
if f == nil || !f.Hidden {
|
||||
t.Fatalf("alias %q = %#v, want registered+hidden", alias, f)
|
||||
}
|
||||
if got := f.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != canonical {
|
||||
t.Fatalf("alias %q annotation = %#v, want alias_of %q", alias, got, canonical)
|
||||
}
|
||||
if got := f.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
|
||||
t.Fatalf("alias %q origin = %#v, want corecmd FlagSpec marker", alias, got)
|
||||
}
|
||||
}
|
||||
if cmd.Flags().Lookup("i-alias").Value.Type() != "int" {
|
||||
t.Fatal("int alias must be registered as int")
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package runtimeannotate
|
||||
|
||||
// CLI flag alias evidence is kept in this narrow file so the base-owned
|
||||
// Interface Snapshot helper can add the protocol constants to an older stable
|
||||
// worktree without replacing that revision's complete runtimeannotate package.
|
||||
// Only corecmd.FlagSpec.Aliases writes the exact origin; neither field is a
|
||||
// Schema synonym or final payload-equivalence proof.
|
||||
const (
|
||||
AnnotationFlagAliasOf = "dws.compat.alias_of"
|
||||
AnnotationFlagAliasOrigin = "dws.compat.alias_origin"
|
||||
FlagAliasOriginCorecmdV1 = "corecmd.flag_spec_aliases.v1"
|
||||
)
|
||||
+30
-12
@@ -91,7 +91,10 @@ func resolveNativeChatTarget(raw string) (string, error) {
|
||||
return resolved.Selected.OpenConversationID, nil
|
||||
}
|
||||
|
||||
const maxConversationCategoryTitleRunes = 15
|
||||
const (
|
||||
maxConversationCategoryTitleRunes = 15
|
||||
chatFavoritesMaxPageSize = 30
|
||||
)
|
||||
|
||||
func validatedConversationCategoryTitle(raw string) (string, error) {
|
||||
title := strings.TrimSpace(raw)
|
||||
@@ -2039,6 +2042,11 @@ func newChatCommand() *cobra.Command {
|
||||
纯文本 / Markdown 消息(默认):
|
||||
无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。
|
||||
|
||||
返回值与后续操作:
|
||||
发送后会返回 openTaskId。如需编辑或撤回刚发送的消息,使用
|
||||
dws chat message query-send-status --open-task-id <openTaskId>
|
||||
获取 openMessageId 和 openConversationId,无需再按消息内容反查。
|
||||
|
||||
本地图片 / 文件消息:
|
||||
统一使用 --msg-type file --file-path <本地路径>。CLI 会完成上传并按 file 消息发送;
|
||||
.png/.jpg 也会显示为可下载的文件附件,不会生成 mediaId 或渲染成内联图片。
|
||||
@@ -3301,7 +3309,10 @@ func newChatCommand() *cobra.Command {
|
||||
chatMessageQuerySendStatusCmd := &cobra.Command{
|
||||
Use: "query-send-status",
|
||||
Short: "查询消息发送状态",
|
||||
Long: `查询以当前用户身份发送的消息的发送状态。需要传入发送消息时返回的 openTaskId。`,
|
||||
Long: `查询以当前用户身份发送的消息的发送状态。需要传入 chat message send 返回的 openTaskId。
|
||||
|
||||
发送成功后,查询结果会返回 openMessageId 和 openConversationId,可直接作为
|
||||
chat message edit 或 chat message recall 的 --msg-id 和 --conversation-id。`,
|
||||
Example: ` dws chat message query-send-status --open-task-id <openTaskId>
|
||||
# openTaskId 由 dws chat message send 返回`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -3348,8 +3359,11 @@ func newChatCommand() *cobra.Command {
|
||||
Short: "撤回用户发送的消息",
|
||||
Long: `撤回当前用户发送的消息。需要指定会话 ID 和消息 ID。`,
|
||||
Example: ` dws chat message recall --conversation-id <openConversationId> --msg-id <openMessageId>
|
||||
# 查询会话 ID: dws chat search --query "群名"
|
||||
# 消息 ID 可通过 dws chat message list 获取`,
|
||||
|
||||
# 发送后撤回:send -> query-send-status -> recall
|
||||
dws chat message send --group <openConversationId> --text "待撤回的内容"
|
||||
dws chat message query-send-status --open-task-id <上一步返回的openTaskId>
|
||||
dws chat message recall --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat"); err != nil {
|
||||
return err
|
||||
@@ -3406,12 +3420,16 @@ func newChatCommand() *cobra.Command {
|
||||
推荐使用 --text 和可选 --title,CLI 会按 Markdown 消息规则生成 content:{"title":"标题","text":"正文"}。
|
||||
也可以直接使用 --content 传入完整 Markdown content JSON。--text 和 --content 二选一。`,
|
||||
Example: ` dws chat message edit --conversation-id <openConversationId> --msg-id <openMessageId> --text "更新后的内容"
|
||||
|
||||
# 发送后编辑:send -> query-send-status -> edit
|
||||
dws chat message send --group <openConversationId> --text "原始内容"
|
||||
dws chat message query-send-status --open-task-id <上一步返回的openTaskId>
|
||||
dws chat message edit --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId> --text "更新后的内容"
|
||||
|
||||
dws chat message edit --group <openConversationId> --msg-id <openMessageId> --title "标题" --text "更新后的内容"
|
||||
dws chat message edit --group <openConversationId> --msg-id <openMessageId> --text "<@all> 请查看" --at-all
|
||||
dws chat message edit --group <openConversationId> --msg-id <openMessageId> --text "<@openDingTalkId1> 请查看" --at-open-dingtalk-ids <openDingTalkId1>
|
||||
dws chat message edit --group <openConversationId> --msg-id <openMessageId> --content '{"title":"标题","text":"更新后的内容"}'
|
||||
# 查询会话 ID: dws chat search --query "群名"
|
||||
# 消息 ID 可通过 dws chat message list 获取`,
|
||||
dws chat message edit --group <openConversationId> --msg-id <openMessageId> --content '{"title":"标题","text":"更新后的内容"}'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlagWithAliases(cmd, "conversation-id", "group", "id", "chat"); err != nil {
|
||||
return err
|
||||
@@ -7467,16 +7485,16 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
首次请求可省略分页参数,CLI 会按 Open 服务契约传 cursor=0、size="20"。
|
||||
返回 hasMore=true 时,将 nextCursor 作为下一次的 --cursor。`,
|
||||
Example: ` dws chat message list-favorites
|
||||
dws chat message list-favorites --size 50
|
||||
dws chat message list-favorites --cursor 20 --size 20`,
|
||||
dws chat message list-favorites --size 30
|
||||
dws chat message list-favorites --cursor 20 --size 20`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cursor, _ := cmd.Flags().GetInt64("cursor")
|
||||
if cursor < 0 {
|
||||
return apperrors.NewValidation("--cursor must be greater than or equal to 0")
|
||||
}
|
||||
size, _ := cmd.Flags().GetInt("size")
|
||||
if size < 1 || size > 100 {
|
||||
return apperrors.NewValidation("--size must be between 1 and 100")
|
||||
if size < 1 || size > chatFavoritesMaxPageSize {
|
||||
return apperrors.NewValidation("--size must be between 1 and 30")
|
||||
}
|
||||
return callMCPToolOnServer("im", "list_message_favorites", map[string]any{
|
||||
"cursor": cursor,
|
||||
@@ -7515,7 +7533,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
},
|
||||
})
|
||||
chatMessageListFavoritesCmd.Flags().Int64("cursor", 0, "数字分页游标(默认 0;翻页时传上次返回的 nextCursor)")
|
||||
chatMessageListFavoritesCmd.Flags().Int("size", 20, "一次拉取的收藏数量(默认 20,范围 1-100)")
|
||||
chatMessageListFavoritesCmd.Flags().Int("size", 20, "一次拉取的收藏数量(默认 20,范围 1-30)")
|
||||
|
||||
// ── group list-my-groups: 拉取我创建/管理的群 ──────────────
|
||||
|
||||
|
||||
@@ -190,23 +190,23 @@ func TestChatListFavoritesSuppliesOpenDefaults(t *testing.T) {
|
||||
func TestChatListFavoritesMapsExplicitPagination(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
err := executeChatFavoritesCommand(t, caller,
|
||||
"message", "list-favorites", "--cursor", "42", "--size", "50")
|
||||
"message", "list-favorites", "--cursor", "42", "--size", "30")
|
||||
if err != nil {
|
||||
t.Fatalf("list-favorites returned error: %v", err)
|
||||
}
|
||||
|
||||
want := map[string]any{"cursor": int64(42), "size": "50"}
|
||||
want := map[string]any{"cursor": int64(42), "size": "30"}
|
||||
if len(caller.calls) != 1 || caller.calls[0].productID != "im" || caller.calls[0].toolName != "list_message_favorites" || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("calls = %#v, want im/list_message_favorites %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatListFavoritesRejectsInvalidSize(t *testing.T) {
|
||||
for _, size := range []string{"-1", "0", "101"} {
|
||||
for _, size := range []string{"-1", "0", "31"} {
|
||||
t.Run(size, func(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
err := executeChatFavoritesCommand(t, caller, "message", "list-favorites", "--size", size)
|
||||
if err == nil || !strings.Contains(err.Error(), "--size must be between 1 and 100") {
|
||||
if err == nil || !strings.Contains(err.Error(), "--size must be between 1 and 30") {
|
||||
t.Fatalf("error = %v, want size validation error", err)
|
||||
}
|
||||
if got := apperrors.ExitCode(err); got != 3 {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
command string
|
||||
contains []string
|
||||
notContain string
|
||||
}{
|
||||
{
|
||||
name: "send returns task ID",
|
||||
command: "send",
|
||||
contains: []string{
|
||||
"openTaskId",
|
||||
"query-send-status --open-task-id <openTaskId>",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "query returns message and conversation IDs",
|
||||
command: "query-send-status",
|
||||
contains: []string{
|
||||
"openTaskId",
|
||||
"openMessageId",
|
||||
"openConversationId",
|
||||
"chat message edit",
|
||||
"chat message recall",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "edit includes post-send workflow",
|
||||
command: "edit",
|
||||
contains: []string{
|
||||
"send -> query-send-status -> edit",
|
||||
"query-send-status --open-task-id <上一步返回的openTaskId>",
|
||||
"edit --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
|
||||
},
|
||||
notContain: "chat message list",
|
||||
},
|
||||
{
|
||||
name: "recall includes post-send workflow",
|
||||
command: "recall",
|
||||
contains: []string{
|
||||
"send -> query-send-status -> recall",
|
||||
"query-send-status --open-task-id <上一步返回的openTaskId>",
|
||||
"recall --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
|
||||
},
|
||||
notContain: "chat message list",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
var output bytes.Buffer
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs([]string{"message", test.command, "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat message %s --help: %v\n%s", test.command, err, output.String())
|
||||
}
|
||||
|
||||
help := output.String()
|
||||
for _, want := range test.contains {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Errorf("chat message %s help missing %q:\n%s", test.command, want, help)
|
||||
}
|
||||
}
|
||||
if test.notContain != "" && strings.Contains(help, test.notContain) {
|
||||
t.Errorf("chat message %s help still contains %q:\n%s", test.command, test.notContain, help)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -170,7 +170,6 @@ func TestCrossPlatformCoverageChartMailAndSheetPureHelpersCoverage(t *testing.T)
|
||||
_ = isEmptyDataValidation(value)
|
||||
_ = isEmptyHyperlink(value)
|
||||
}
|
||||
_ = fillIntMatrix(2, 3, 4)
|
||||
_ = maxColLenStr([][]string{{"a"}, {"b", "c"}})
|
||||
_ = maxColLen2D([][]int{{1}, {2, 3}})
|
||||
_ = checkMatrixShape(1, 2, 1, 2, "x")
|
||||
@@ -213,7 +212,7 @@ func TestCrossPlatformCoverageChartMailAndSheetPureHelpersCoverage(t *testing.T)
|
||||
{styleSpec{}, 1, 1},
|
||||
}
|
||||
for _, style := range styles {
|
||||
_ = applyStyleSpec(&style.spec, style.rows, style.cols, map[string]any{})
|
||||
_, _ = buildStyleCells(&style.spec, style.rows, style.cols)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
scalar, raw string
|
||||
@@ -227,7 +226,9 @@ func TestCrossPlatformCoverageChartMailAndSheetPureHelpersCoverage(t *testing.T)
|
||||
{"", `[[""]]`, hAlignEnum},
|
||||
{"", `[["left"]]`, hAlignEnum},
|
||||
} {
|
||||
_ = apply2DString(tc.scalar, tc.raw, 1, 1, "align", "alignments", tc.enum, map[string]any{})
|
||||
if get, err := strGrid(tc.scalar, tc.raw, "align", tc.enum, 1, 1); err == nil && get != nil {
|
||||
_, _ = get(0, 0)
|
||||
}
|
||||
}
|
||||
views := []map[string]any{{"id": "one"}, {"filterViewId": "two"}}
|
||||
_, _ = findFilterViewByID(views, "one")
|
||||
|
||||
+106
-18
@@ -221,6 +221,43 @@ func runDocUpload(cmd *cobra.Command, _ []string) error {
|
||||
return callMCPTool("commit_uploaded_file", commitArgs)
|
||||
}
|
||||
|
||||
// docSpaceUploadCommitText 执行文档空间三步上传(凭证 → PUT → 入库)并
|
||||
// 返回 commit 响应原文,供 doc import 的白名单外回退链路组装结构化结果。
|
||||
// 与 runDocUpload 的区别:不打印输出、不携带 doc upload 的 --workspace
|
||||
// 兼容告警,调用方负责结果投影。
|
||||
func docSpaceUploadCommitText(ctx context.Context, filePath, fileName string, fileSize int64, folder, workspace string) (string, error) {
|
||||
step1Args := map[string]any{}
|
||||
if folder != "" {
|
||||
step1Args["folderId"] = folder
|
||||
}
|
||||
if workspace != "" {
|
||||
step1Args["workspaceId"] = workspace
|
||||
}
|
||||
text, err := callMCPToolReturnText(ctx, "get_file_upload_info", step1Args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resourceURL, uploadKey, ossHeaders, err := parseUploadInfo(text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := httpPutFile(ctx, resourceURL, ossHeaders, filePath, fileSize); err != nil {
|
||||
return "", err
|
||||
}
|
||||
commitArgs := map[string]any{
|
||||
"uploadKey": uploadKey,
|
||||
"name": fileName,
|
||||
"fileSize": float64(fileSize),
|
||||
}
|
||||
if folder != "" {
|
||||
commitArgs["folderId"] = folder
|
||||
}
|
||||
if workspace != "" {
|
||||
commitArgs["workspaceId"] = workspace
|
||||
}
|
||||
return callMCPToolReturnText(ctx, "commit_uploaded_file", commitArgs)
|
||||
}
|
||||
|
||||
// parseUploadInfo extracts resourceUrl, uploadKey and headers from the MCP tool response.
|
||||
func parseUploadInfo(text string) (resourceURL, uploadKey string, headers map[string]string, err error) {
|
||||
var data map[string]any
|
||||
@@ -495,16 +532,23 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
|
||||
fileSize := fileInfo.Size()
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
deps.Out.PrintKeyValue("操作", "上传附件并插入文档")
|
||||
deps.Out.PrintKeyValue("文档", nodeID)
|
||||
deps.Out.PrintKeyValue("文件", filePath)
|
||||
deps.Out.PrintKeyValue("名称", fileName)
|
||||
deps.Out.PrintKeyValue("类型", mimeType)
|
||||
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", fileSize))
|
||||
return nil
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"contractVersion": "doc.operation.v1",
|
||||
"dry_run": true,
|
||||
"preview_kind": "plan",
|
||||
"ok": true,
|
||||
"status": "success",
|
||||
"complete": true,
|
||||
"operation": "doc.media_insert",
|
||||
"data": map[string]any{
|
||||
"executed": false, "nodeId": nodeID, "file": filePath,
|
||||
"fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize,
|
||||
},
|
||||
"steps": []map[string]any{{"name": "validate_local_file", "status": "success"}},
|
||||
})
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Step 1: get upload credentials (uploadUrl + resourceId)
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
|
||||
@@ -531,7 +575,20 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
|
||||
"Content-Type": mimeType,
|
||||
}
|
||||
if err := httpPutFile(ctx, uploadURL, ossHeaders, filePath, fileSize); err != nil {
|
||||
return err
|
||||
return apperrors.NewAPI(
|
||||
"附件上传结果未知;尚未确认正文 block 已插入,请先检查文档媒体列表,禁止改用手写 HTTP",
|
||||
apperrors.WithOperation("doc.media_insert"),
|
||||
apperrors.WithReason("doc_media_upload_unknown"),
|
||||
apperrors.WithFailureStage("upload_oss"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithActions("运行 dws doc +media-list 检查当前文档", "确认没有对应媒体后才重新执行 +media-insert", "不要 curl 上传地址或安装本地依赖"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"contractVersion": "doc.operation.v1", "status": "unknown", "nodeId": nodeID,
|
||||
"resourceId": resourceID, "fileName": fileName, "stage": "upload_oss",
|
||||
}),
|
||||
apperrors.WithCause(err),
|
||||
)
|
||||
}
|
||||
|
||||
// Step 3: insert block into document
|
||||
@@ -588,15 +645,43 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
|
||||
if err := callMCPTool("insert_document_block", insertArgs); err != nil {
|
||||
return err
|
||||
return apperrors.NewAPI(
|
||||
"附件已上传,但正文 block 插入结果未知;请先检查媒体列表,不要重复上传或插入",
|
||||
apperrors.WithOperation("doc.media_insert"),
|
||||
apperrors.WithReason("doc_media_insert_partial"),
|
||||
apperrors.WithFailureStage("insert_block"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithActions("运行 dws doc +media-list 检查 resourceId/blockId", "不要直接重试 +media-insert,不要使用 resourceUrl 手写请求"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"contractVersion": "doc.operation.v1", "status": "partial_success", "nodeId": nodeID,
|
||||
"resourceId": resourceID, "resourceUrl": resourceURL, "fileName": fileName,
|
||||
"steps": []map[string]any{
|
||||
{"name": "resolve_upload", "status": "success"},
|
||||
{"name": "upload_oss", "status": "success"},
|
||||
{"name": "insert_block", "status": "unknown"},
|
||||
},
|
||||
}),
|
||||
apperrors.WithCause(err),
|
||||
)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(mimeType, "image/") {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("图片已插入文档: %s (resourceUrl=%s)", fileName, resourceURL))
|
||||
} else {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("附件已插入文档: %s (resourceId=%s)", fileName, resourceID))
|
||||
}
|
||||
return nil
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"contractVersion": "doc.operation.v1",
|
||||
"ok": true,
|
||||
"status": "success",
|
||||
"complete": true,
|
||||
"operation": "doc.media_insert",
|
||||
"data": map[string]any{
|
||||
"nodeId": nodeID, "resourceId": resourceID, "resourceUrl": resourceURL,
|
||||
"fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize, "inserted": true,
|
||||
},
|
||||
"steps": []map[string]any{
|
||||
{"name": "resolve_upload", "status": "success"},
|
||||
{"name": "upload_oss", "status": "success"},
|
||||
{"name": "insert_block", "status": "success"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// parseAttachmentUploadInfo extracts uploadUrl, resourceId and resourceUrl from the MCP tool response.
|
||||
@@ -3764,6 +3849,9 @@ CLI 内部自动完成全部流程:
|
||||
xlsx, xls → 电子表格
|
||||
md, txt → 文字文档
|
||||
xmind, mark → 脑图
|
||||
其他格式(html/pdf/zip 等)→ 不做在线文档转换,自动改走文件上传链路,
|
||||
以原文件形式存入 --folder/--workspace 指定位置;如需在线文档请先转换
|
||||
为 md;上传到钉盘请用 dws drive upload
|
||||
|
||||
文件大小限制: 20MB
|
||||
|
||||
@@ -3791,8 +3879,8 @@ CLI 内部自动完成全部流程:
|
||||
},
|
||||
}
|
||||
importCmd.Flags().String("file", "", "本地文件路径 (必填)")
|
||||
importCmd.Flags().String("folder", "", "目标文件夹 ID 或 URL (可选,与 --workspace 至少传一个)")
|
||||
importCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL (可选,与 --folder 至少传一个)")
|
||||
importCmd.Flags().String("folder", "", "目标文件夹 ID 或 URL (可选;folder/workspace 都不传时导入到默认根目录)")
|
||||
importCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL (可选;folder/workspace 都不传时导入到默认根目录)")
|
||||
importCmd.Flags().StringP("name", "n", "", "导入后文档名称 (可选,默认取文件名)")
|
||||
importCmd.Flags().String("folder-id", "", "")
|
||||
_ = importCmd.Flags().MarkHidden("folder-id")
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"syscall"
|
||||
"unicode/utf8"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -17,9 +18,6 @@ const (
|
||||
// Server-side OSS delta resolution is now fixed, so large chunks are safe.
|
||||
initialChunkSize = 10000
|
||||
|
||||
// minChunkSize is the floor; below this we report an error instead of retrying.
|
||||
minChunkSize = 5000
|
||||
|
||||
// longContentWarningThreshold triggers a hint to use --content-file.
|
||||
longContentWarningThreshold = 2048
|
||||
)
|
||||
@@ -95,10 +93,11 @@ func docWritePipeline(cmd *cobra.Command, toolName string, toolArgs map[string]a
|
||||
// Single write path
|
||||
nodeID, lastResponse, writeErr = singleWrite(ctx, toolName, toolArgs)
|
||||
chunksWritten = 1
|
||||
if writeErr != nil && isTimeoutError(writeErr.Error()) && runeCount > minChunkSize {
|
||||
// Timeout on single write — fallback to chunked with halved size
|
||||
deps.Out.PrintInfo("[INFO] 单次写入超时,自动切换为分片写入...")
|
||||
nodeID, chunksWritten, lastResponse, writeErr = chunkedWrite(ctx, toolName, toolArgs, markdown, operation, initialChunkSize/2)
|
||||
if writeErr != nil && isTimeoutError(writeErr.Error()) {
|
||||
// The server may have committed the write before the client observed the
|
||||
// timeout. Replaying create/append here can duplicate a document or
|
||||
// content, so fail closed and require inspection before any retry.
|
||||
writeErr = docWriteUnknownStateError(operation, nodeID, "single_write", 0, 1, writeErr)
|
||||
}
|
||||
} else {
|
||||
// Chunked write path
|
||||
@@ -156,6 +155,9 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
|
||||
len(chunks), utf8.RuneCountInString(chunks[0])))
|
||||
resultText, err := callMCPToolReturnText(ctx, "create_document", createArgs)
|
||||
if err != nil {
|
||||
if isTimeoutError(err.Error()) {
|
||||
return "", 0, resultText, docWriteUnknownStateError(operation, "", "chunk_1", 0, len(chunks), err)
|
||||
}
|
||||
return "", 0, resultText, fmt.Errorf("创建文档失败: %w", err)
|
||||
}
|
||||
nodeID = extractNodeIDFromResult(resultText)
|
||||
@@ -181,6 +183,9 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
|
||||
len(chunks), utf8.RuneCountInString(chunks[0]), firstMode))
|
||||
resultText, err := callMCPToolReturnText(ctx, "update_document", updateArgs)
|
||||
if err != nil {
|
||||
if isTimeoutError(err.Error()) {
|
||||
return nodeID, 0, resultText, docWriteUnknownStateError(operation, nodeID, "chunk_1", 0, len(chunks), err)
|
||||
}
|
||||
return nodeID, 0, resultText, fmt.Errorf("第 1 片写入失败: %w", err)
|
||||
}
|
||||
lastResponse = resultText
|
||||
@@ -209,29 +214,9 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
|
||||
resultText, err := callMCPToolReturnText(ctx, "update_document", updateArgs)
|
||||
if err != nil {
|
||||
if isTimeoutError(err.Error()) {
|
||||
newSize := chunkSize / 2
|
||||
if newSize < minChunkSize {
|
||||
return nodeID, writtenCount, resultText, &CLIError{
|
||||
Code: CodeContentTruncated,
|
||||
Message: fmt.Sprintf("分片写入持续超时,已写入 %d 片。当前分片大小 %d 字符已低于最小阈值 %d",
|
||||
writtenCount, chunkSize, minChunkSize),
|
||||
Suggestion: fmt.Sprintf("后端写入超时无法恢复。已成功写入部分内容到 nodeId=%s,请使用 dws doc read --node %s 查看已写入部分",
|
||||
nodeID, nodeID),
|
||||
Operation: operation,
|
||||
}
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 写入超时,分片大小减半为 %d 字符后重试...", newSize))
|
||||
chunkSize = newSize
|
||||
|
||||
var remaining strings.Builder
|
||||
remaining.WriteString(chunk)
|
||||
for j := i + 1; j < len(chunks); j++ {
|
||||
remaining.WriteString(chunks[j])
|
||||
}
|
||||
newChunks := splitMarkdownSafe(remaining.String(), chunkSize)
|
||||
chunks = append(chunks[:i], newChunks...)
|
||||
i-- // retry current index
|
||||
continue
|
||||
return nodeID, writtenCount, resultText, docWriteUnknownStateError(
|
||||
operation, nodeID, fmt.Sprintf("chunk_%d", i+1), writtenCount, len(chunks), err,
|
||||
)
|
||||
}
|
||||
return nodeID, writtenCount, resultText, fmt.Errorf("分片 %d 写入失败: %w", writtenCount+1, err)
|
||||
}
|
||||
@@ -243,6 +228,27 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
|
||||
return nodeID, writtenCount, lastResponse, nil
|
||||
}
|
||||
|
||||
func docWriteUnknownStateError(operation, nodeID, stage string, written, total int, cause error) error {
|
||||
details := map[string]any{
|
||||
"status": "unknown",
|
||||
"nodeId": nodeID,
|
||||
"chunksWritten": written,
|
||||
"chunksTotal": total,
|
||||
"failedStage": stage,
|
||||
}
|
||||
return apperrors.NewAPI(
|
||||
"文档写入响应超时,服务端提交状态未知;为避免重复创建或重复追加,已停止自动重试",
|
||||
apperrors.WithOperation(operation),
|
||||
apperrors.WithReason("doc_write_commit_unknown"),
|
||||
apperrors.WithFailureStage(stage),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithActions("先读取目标文档确认实际写入状态", "仅在确认服务端未提交后重新执行"),
|
||||
apperrors.WithDetails(details),
|
||||
apperrors.WithCause(cause),
|
||||
)
|
||||
}
|
||||
|
||||
// isTimeoutError checks if an error message indicates a server-side timeout.
|
||||
func isTimeoutError(msg string) bool {
|
||||
lower := strings.ToLower(msg)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -40,10 +41,12 @@ func TestCrossPlatformCoverageDocWritePipelineStrategyRemainingCoverage(t *testi
|
||||
t.Fatalf("long literal single write: %v", err)
|
||||
}
|
||||
|
||||
fallback := strings.Repeat("x", minChunkSize+100)
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("timeout")}, {text: `{}`}, {text: `{}`}}})
|
||||
if err := docWritePipeline(docWriteCoverageCommand(), "update_document", map[string]any{"nodeId": "node", "mode": "overwrite", "markdown": fallback}, fallback, "update"); err != nil {
|
||||
t.Fatalf("single timeout fallback: %v", err)
|
||||
fallback := strings.Repeat("x", 5100)
|
||||
timeoutCaller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("timeout")}, {text: `{}`}}}
|
||||
installScriptedCaller(t, timeoutCaller)
|
||||
err := docWritePipeline(docWriteCoverageCommand(), "update_document", map[string]any{"nodeId": "node", "mode": "overwrite", "markdown": fallback}, fallback, "update")
|
||||
if err == nil || !strings.Contains(err.Error(), "提交状态未知") || timeoutCaller.calls != 1 {
|
||||
t.Fatalf("single timeout must stop without replay: err=%v calls=%d", err, timeoutCaller.calls)
|
||||
}
|
||||
|
||||
chunked := strings.Repeat("x", initialChunkSize+100)
|
||||
@@ -58,9 +61,43 @@ func TestCrossPlatformCoverageChunkedWriteAdaptiveRetryRemainingCoverage(t *test
|
||||
os.Args = []string{"dws", "doc"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
markdown := strings.Repeat("x", 24000)
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {err: errors.New("HSFTimeoutException")}, {text: `{}`}}})
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {err: errors.New("HSFTimeoutException")}, {text: `{}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
_, written, _, err := chunkedWrite(context.Background(), "update_document", map[string]any{"nodeId": "node"}, markdown, "update", 10000)
|
||||
if err != nil || written < 3 {
|
||||
t.Fatalf("adaptive retry written=%d err=%v", written, err)
|
||||
if err == nil || written != 1 || caller.calls != 2 || !strings.Contains(err.Error(), "提交状态未知") {
|
||||
t.Fatalf("timeout must stop without replay: written=%d calls=%d err=%v", written, caller.calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocWriteFirstChunkTimeoutIsUnknown(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "doc"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
markdown := strings.Repeat("x", 24000)
|
||||
tests := []struct {
|
||||
name string
|
||||
tool string
|
||||
args map[string]any
|
||||
wantNode string
|
||||
}{
|
||||
{name: "create", tool: "create_document", args: map[string]any{"name": "doc"}},
|
||||
{name: "update", tool: "update_document", args: map[string]any{"nodeId": "node", "mode": "overwrite"}, wantNode: "node"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("HSFTimeoutException")}, {text: `{}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
nodeID, written, _, err := chunkedWrite(context.Background(), tc.tool, tc.args, markdown, tc.name, 10000)
|
||||
var typed *apperrors.Error
|
||||
if err == nil || !errors.As(err, &typed) {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
if nodeID != tc.wantNode || written != 0 || caller.calls != 1 {
|
||||
t.Fatalf("node=%q written=%d calls=%d", nodeID, written, caller.calls)
|
||||
}
|
||||
if typed.Reason != "doc_write_commit_unknown" || typed.FailureStage != "chunk_1" || typed.ExecutionStarted == nil || !*typed.ExecutionStarted || !typed.RetryableSet || typed.Retryable {
|
||||
t.Fatalf("unknown commit metadata = %#v", typed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,11 @@ type importFlowConfig struct {
|
||||
timeoutAsResult bool
|
||||
nextCommand string
|
||||
poll importPollPolicy
|
||||
// uploadFallback 开启后,所有不在 supportedFormats 白名单内的文件
|
||||
// (html/pdf/zip/无扩展名等)不再报错断链,统一移交文档空间文件上传
|
||||
// 链路原样入库;白名单即后端转换能力的封闭集合,无需第二份格式枚举。
|
||||
// 回退共享 prepareImportFile 的存在性 / 20MB / 空文件校验。
|
||||
uploadFallback bool
|
||||
}
|
||||
|
||||
type preparedImportFile struct {
|
||||
@@ -98,6 +103,10 @@ func docImportFlowConfig() importFlowConfig {
|
||||
workspaceFlags: []string{"workspace", "workspace-id"},
|
||||
nextCommand: "dws doc import get --task-id %s",
|
||||
poll: defaultImportPollPolicy(),
|
||||
// 白名单外的格式改走文档空间的文件上传链路
|
||||
// (与 drive upload --workspace 同一条 doc-space 上传原语),
|
||||
// 目标 flags(--folder/--workspace)与 import 同构,链路不中断。
|
||||
uploadFallback: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +174,11 @@ func prepareImportFile(cmd *cobra.Command, args []string, cfg importFlowConfig)
|
||||
}
|
||||
|
||||
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".")
|
||||
if !cfg.supportedFormats[extension] {
|
||||
// 非回退配置保持基线校验顺序:扩展名门禁先于导入目标校验
|
||||
// (sheet import 对无目标的非 Excel 文件必须先报 unsupported)。
|
||||
// uploadFallback 配置的白名单外文件继续走完共享校验,由
|
||||
// runImportCommand 分派到上传回退。
|
||||
if !cfg.supportedFormats[extension] && !cfg.uploadFallback {
|
||||
return preparedImportFile{}, fmt.Errorf("unsupported file format %q, supported: %s", extension, cfg.supportedFormatsText)
|
||||
}
|
||||
|
||||
@@ -197,11 +210,122 @@ func (cfg importFlowConfig) callTool(ctx context.Context, toolName string, args
|
||||
return callMCPToolReturnText(ctx, toolName, args)
|
||||
}
|
||||
|
||||
// runImportUploadFallback 承接白名单外格式:不再报错断链,改走文档空间
|
||||
// 文件上传链路原样入库。回退在 prepareImportFile 之后执行,共享存在性 /
|
||||
// 20MB / 空文件校验;不复用 runDocUpload,避免携带 doc upload 的
|
||||
// --workspace 兼容告警。移交事实通过 stderr 显式告知,机器可读结果统一
|
||||
// 携带 fallback=upload / converted=false 标记,防止 Agent 误判已完成
|
||||
// 在线文档转换。
|
||||
func runImportUploadFallback(cmd *cobra.Command, cfg importFlowConfig, file preparedImportFile) error {
|
||||
label := file.extension
|
||||
if label == "" {
|
||||
label = "无扩展名"
|
||||
}
|
||||
deps.Out.PrintWarning(fmt.Sprintf(
|
||||
"%s 文件不支持转换为在线文档(支持: %s),已自动改走文件上传链路,以原文件形式存入 --folder/--workspace 指定的目标位置;如需在线文档,请先将内容转换为 md 后重新执行 doc import;上传到钉盘请用 dws drive upload",
|
||||
label, cfg.supportedFormatsText))
|
||||
|
||||
// prepareImportFile 的 name 去掉了扩展名;上传保留原始文件名形态
|
||||
uploadName := file.name
|
||||
if filepath.Ext(uploadName) == "" && file.extension != "" {
|
||||
uploadName += "." + file.extension
|
||||
}
|
||||
jsonMode := deps.Caller.Format() == "json"
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
if jsonMode {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"preview_kind": "plan",
|
||||
"operation": "上传文件到钉钉文档",
|
||||
"requested_operation": cfg.operation,
|
||||
"fallback": "upload",
|
||||
"converted": false,
|
||||
"file": file.path,
|
||||
"name": uploadName,
|
||||
"format": file.extension,
|
||||
"size": file.size,
|
||||
})
|
||||
}
|
||||
deps.Out.PrintKeyValue("操作", "上传文件到钉钉文档(doc import 回退)")
|
||||
deps.Out.PrintKeyValue("文件", file.path)
|
||||
deps.Out.PrintKeyValue("名称", uploadName)
|
||||
deps.Out.PrintKeyValue("格式", file.extension)
|
||||
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", file.size))
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo("按原文件上传中(未转换为在线文档)...")
|
||||
}
|
||||
text, err := docSpaceUploadCommitText(ctx, file.path, uploadName, file.size, file.folder, file.workspace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// fail-closed:commit 响应必须可解析且带文件标识才算成功;
|
||||
// 空响应(legacy ack)、非 JSON 或缺少标识都不得包装为 success
|
||||
commit, dentryID, err := parseUploadCommitResult(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"success": true,
|
||||
"operation": "上传文件到钉钉文档",
|
||||
"requested_operation": cfg.operation,
|
||||
"fallback": "upload",
|
||||
"converted": false,
|
||||
"name": uploadName,
|
||||
"format": file.extension,
|
||||
"dentry_id": dentryID,
|
||||
"result": commit,
|
||||
})
|
||||
}
|
||||
|
||||
// uploadCommitIDKeys 是 commit_uploaded_file 响应中可作为文件标识的字段,
|
||||
// 按优先级排列;服务端可能返回平铺对象或包一层 result envelope。
|
||||
var uploadCommitIDKeys = []string{"dentryUuid", "dentryId", "nodeId", "fileId", "id"}
|
||||
|
||||
// parseUploadCommitResult 校验入库响应:拒绝空响应,要求 JSON 对象且
|
||||
// 含文件标识,返回解析后的对象与标识值。任何不满足都返回错误,
|
||||
// 由调用方向用户提示核对入库结果,而不是伪装成功。
|
||||
func parseUploadCommitResult(text string) (map[string]any, string, error) {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return nil, "", fmt.Errorf("上传入库未返回结果(commit_uploaded_file 响应为空),无法确认文件已入库;请用 dws doc list 核对目标位置")
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
|
||||
return nil, "", fmt.Errorf("上传入库响应无法解析为 JSON,无法确认文件已入库;原始响应: %s", trimmed)
|
||||
}
|
||||
payload := parsed
|
||||
if inner, ok := parsed["result"].(map[string]any); ok {
|
||||
payload = inner
|
||||
}
|
||||
for _, key := range uploadCommitIDKeys {
|
||||
if v, ok := payload[key].(string); ok && strings.TrimSpace(v) != "" {
|
||||
return parsed, v, nil
|
||||
}
|
||||
}
|
||||
return nil, "", fmt.Errorf("上传入库响应缺少文件标识(%s 均为空),无法确认文件已入库;原始响应: %s", strings.Join(uploadCommitIDKeys, "/"), trimmed)
|
||||
}
|
||||
|
||||
func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) error {
|
||||
file, err := prepareImportFile(cmd, args, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 非回退配置的白名单外文件已在 prepareImportFile 中按基线顺序拒绝
|
||||
if cfg.uploadFallback && !cfg.supportedFormats[file.extension] {
|
||||
return runImportUploadFallback(cmd, cfg, file)
|
||||
}
|
||||
jsonMode := deps.Caller.Format() == "json"
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
errUploadInfo = errors.New("upload info failed")
|
||||
errUploadPut = errors.New("put failed")
|
||||
)
|
||||
|
||||
func htmlFallbackCommand(t *testing.T, filePath string) *cobra.Command {
|
||||
t.Helper()
|
||||
// callMCPToolReturnText 从 os.Args 解析产品名(doc)
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "doc"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
cmd := &cobra.Command{Use: "import"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("folder", "", "")
|
||||
cmd.Flags().String("workspace", "", "")
|
||||
cmd.Flags().String("folder-id", "", "")
|
||||
cmd.Flags().String("workspace-id", "", "")
|
||||
if filePath != "" {
|
||||
if err := cmd.Flags().Set("file", filePath); err != nil {
|
||||
t.Fatalf("set import file: %v", err)
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
|
||||
uploadSteps := []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
|
||||
{text: `{"dentryUuid":"node-1","name":"sales.html"}`},
|
||||
}
|
||||
|
||||
t.Run("html upload fallback emits marked json without legacy warnings", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", steps: uploadSteps}
|
||||
installScriptedCaller(t, caller)
|
||||
var stdout, warnings bytes.Buffer
|
||||
deps.Out.w = &stdout
|
||||
deps.Out.errW = &warnings
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
if err := cmd.Flags().Set("workspace", "ws-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
|
||||
}
|
||||
if caller.calls != 2 || caller.tool != "commit_uploaded_file" {
|
||||
t.Fatalf("fallback calls = %d last tool = %q, want 2 calls ending in commit_uploaded_file", caller.calls, caller.tool)
|
||||
}
|
||||
if got := caller.args["workspaceId"]; got != "ws-1" {
|
||||
t.Fatalf("commit workspaceId = %v, want ws-1", got)
|
||||
}
|
||||
if got := caller.args["name"]; got != "sales.html" {
|
||||
t.Fatalf("commit name = %v, want original file name with extension", got)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("fallback result must be one JSON document: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["success"] != true || payload["fallback"] != "upload" || payload["converted"] != false {
|
||||
t.Fatalf("fallback markers missing: %#v", payload)
|
||||
}
|
||||
if payload["dentry_id"] != "node-1" {
|
||||
t.Fatalf("dentry_id = %v, want node-1", payload["dentry_id"])
|
||||
}
|
||||
if payload["requested_operation"] != "导入本地文件为在线文档" {
|
||||
t.Fatalf("requested_operation = %v", payload["requested_operation"])
|
||||
}
|
||||
if !strings.Contains(warnings.String(), "文件上传链路") {
|
||||
t.Fatalf("fallback must announce the upload on stderr, got %q", warnings.String())
|
||||
}
|
||||
if strings.Contains(warnings.String(), "deprecated") {
|
||||
t.Fatalf("fallback must not emit the doc upload deprecation warning, got %q", warnings.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("json dry run stays a single json document", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", dry: true}
|
||||
installScriptedCaller(t, caller)
|
||||
var stdout bytes.Buffer
|
||||
deps.Out.w = &stdout
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("dry run must not call MCP, calls = %d", caller.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json dry-run output is not JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["dry_run"] != true || payload["executed"] != false || payload["fallback"] != "upload" || payload["converted"] != false {
|
||||
t.Fatalf("dry-run fallback payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("any non-importable format is redirected, not enumerated", func(t *testing.T) {
|
||||
for _, ext := range []string{"pdf", "zip", "png", "mp4"} {
|
||||
caller := &scriptedToolCaller{steps: uploadSteps}
|
||||
installScriptedCaller(t, caller)
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, ext))
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand(%s) error = %v, want upload fallback success", ext, err)
|
||||
}
|
||||
if caller.tool != "commit_uploaded_file" {
|
||||
t.Fatalf("%s last tool = %q, want commit_uploaded_file", ext, caller.tool)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uppercase htm extension via positional argument", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: uploadSteps}
|
||||
installScriptedCaller(t, caller)
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
path := writeImportFixture(t, "HTM")
|
||||
cmd := htmlFallbackCommand(t, "")
|
||||
if err := runImportCommand(cmd, []string{path}, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
|
||||
}
|
||||
if caller.tool != "commit_uploaded_file" {
|
||||
t.Fatalf("last tool = %q, want commit_uploaded_file", caller.tool)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hidden folder-id alias reaches the upload chain", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: uploadSteps}
|
||||
installScriptedCaller(t, caller)
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
if err := cmd.Flags().Set("folder-id", "folder-abc"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
|
||||
}
|
||||
if got := caller.args["folderId"]; got != "folder-abc" {
|
||||
t.Fatalf("commit folderId = %v, want folder-abc from --folder-id alias", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extensionless file is redirected with a readable label", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: uploadSteps}
|
||||
installScriptedCaller(t, caller)
|
||||
var warnings bytes.Buffer
|
||||
deps.Out.errW = &warnings
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
noExt := filepath.Join(t.TempDir(), "README")
|
||||
if err := os.WriteFile(noExt, []byte("plain"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := htmlFallbackCommand(t, noExt)
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
|
||||
}
|
||||
if !strings.Contains(warnings.String(), "无扩展名") {
|
||||
t.Fatalf("warning must label the extensionless file, got %q", warnings.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fallback enforces the shared 20MB limit", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
big := filepath.Join(t.TempDir(), "big.html")
|
||||
f, err := os.Create(big)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Truncate(importMaxFileSize + 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := htmlFallbackCommand(t, big)
|
||||
err = runImportCommand(cmd, nil, docImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds 20MB limit") {
|
||||
t.Fatalf("runImportCommand() error = %v, want 20MB limit rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fallback enforces the shared empty-file guard", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
empty := filepath.Join(t.TempDir(), "empty.html")
|
||||
if err := os.WriteFile(empty, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := htmlFallbackCommand(t, empty)
|
||||
err := runImportCommand(cmd, nil, docImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), "file is empty") {
|
||||
t.Fatalf("runImportCommand() error = %v, want empty-file rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("importable formats keep the conversion path", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", dry: true}
|
||||
installScriptedCaller(t, caller)
|
||||
var stdout bytes.Buffer
|
||||
deps.Out.w = &stdout
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "md"))
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v", err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("import dry-run output is not JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["operation"] != "导入本地文件为在线文档" {
|
||||
t.Fatalf("operation = %v, want conversion path", payload["operation"])
|
||||
}
|
||||
if _, ok := payload["fallback"]; ok {
|
||||
t.Fatalf("importable format must not carry fallback marker: %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing file falls through to the import required-flag error", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
cmd := htmlFallbackCommand(t, "")
|
||||
err := runImportCommand(cmd, nil, docImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), "--file is required") {
|
||||
t.Fatalf("runImportCommand() error = %v, want --file required", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("text dry run prints the fallback plan as key values", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{dry: true}
|
||||
installScriptedCaller(t, caller)
|
||||
var stdout bytes.Buffer
|
||||
deps.Out.w = &stdout
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("dry run must not call MCP, calls = %d", caller.calls)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "doc import 回退") || !strings.Contains(stdout.String(), "sales.html") {
|
||||
t.Fatalf("text dry-run must print the fallback plan, got %q", stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload chain errors propagate", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
steps []scriptedToolStep
|
||||
putErr error
|
||||
wantErr string
|
||||
}{
|
||||
{name: "upload info request fails", steps: []scriptedToolStep{{err: errUploadInfo}}, wantErr: "upload info failed"},
|
||||
{name: "upload credentials incomplete", steps: []scriptedToolStep{{text: `{"resourceUrl":""}`}}, wantErr: "incomplete upload credentials"},
|
||||
{name: "http put fails", steps: []scriptedToolStep{{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`}}, putErr: errUploadPut, wantErr: "put failed"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: tc.steps})
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return tc.putErr })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
err := runImportCommand(cmd, nil, docImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("runImportCommand() error = %v, want %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("commit responses that cannot prove success are rejected", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
commit string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "empty legacy ack", commit: " ", wantErr: "响应为空"},
|
||||
{name: "non-json text", commit: "commit-ok-plain-text", wantErr: "无法解析为 JSON"},
|
||||
{name: "missing file identity", commit: `{"ok":true}`, wantErr: "缺少文件标识"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
|
||||
{text: tc.commit},
|
||||
}})
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
err := runImportCommand(cmd, nil, docImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("runImportCommand() error = %v, want %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nested result envelope yields the dentry id", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
|
||||
{text: `{"result":{"dentryUuid":"nested-node-9"}}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller)
|
||||
var stdout bytes.Buffer
|
||||
deps.Out.w = &stdout
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v", err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("fallback result must stay JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["dentry_id"] != "nested-node-9" {
|
||||
t.Fatalf("dentry_id = %v, want nested-node-9", payload["dentry_id"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sheet import keeps rejecting html without fallback", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
// 无导入目标时也必须先报 unsupported(基线校验顺序:扩展名先于目标)
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
err := runImportCommand(cmd, nil, sheetImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported file format") {
|
||||
t.Fatalf("sheet import (no target) error = %v, want unsupported file format", err)
|
||||
}
|
||||
|
||||
cmd = htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
if err := cmd.Flags().Set("workspace", "ws-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = runImportCommand(cmd, nil, sheetImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported file format") {
|
||||
t.Fatalf("sheet import (with target) error = %v, want unsupported file format", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -44,6 +44,7 @@ func newSheetCommand() *cobra.Command {
|
||||
|
||||
命令结构:
|
||||
dws sheet create 创建钉钉表格文档
|
||||
dws sheet create-with-data 创建表格文档并写入初始数据(可选样式)
|
||||
dws sheet list 获取全部工作表列表
|
||||
dws sheet info 获取指定工作表详情
|
||||
dws sheet new 新建工作表
|
||||
@@ -110,6 +111,7 @@ func newSheetCommand() *cobra.Command {
|
||||
dws sheet chart update 更新浮动图表
|
||||
dws sheet chart delete 删除浮动图表
|
||||
dws sheet export 导出表格为 xlsx(异步任务一站式:提交→轮询→可选下载)
|
||||
dws sheet export-csv 导出单个工作表为纯 CSV(同步,可落盘)
|
||||
dws sheet import 导入 xlsx/xls 为在线电子表格
|
||||
dws sheet template list 获取表格模板列表
|
||||
dws sheet template search 搜索表格模板
|
||||
@@ -159,6 +161,10 @@ func newSheetCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
})
|
||||
// 建表带初始数据、csv 同步导出都是与既有叶子不同的接口种类(一条 composite
|
||||
// 编排、一条 get_range_as_csv 直连),各自独立成叶子,不挂到 create / export 上。
|
||||
createWithDataCmd := newSheetCreateWithDataCmd()
|
||||
exportCsvCmd := newSheetExportCsvCmd()
|
||||
importCmd := newSheetImportCmd()
|
||||
templateCmd := newSheetTemplateCmd()
|
||||
tableCmds := newTableCmds()
|
||||
@@ -238,7 +244,7 @@ func newSheetCommand() *cobra.Command {
|
||||
standaloneCmds = append(standaloneCmds, mediaCmds...)
|
||||
standaloneCmds = append(standaloneCmds, floatImageCmds...)
|
||||
standaloneCmds = append(standaloneCmds, tableCmds...)
|
||||
standaloneCmds = append(standaloneCmds, exportCmd, importCmd, batchUpdateCmd)
|
||||
standaloneCmds = append(standaloneCmds, exportCmd, exportCsvCmd, importCmd, batchUpdateCmd, createWithDataCmd)
|
||||
|
||||
// Register cross-product aliases
|
||||
for _, cmd := range standaloneCmds {
|
||||
|
||||
@@ -517,12 +517,12 @@ func newRangeBatchClearCmd() *cobra.Command {
|
||||
}
|
||||
operations := make([]any, 0, len(ranges))
|
||||
for i, rng := range ranges {
|
||||
idx := strings.Index(rng, "!")
|
||||
if idx <= 0 || idx == len(rng)-1 {
|
||||
return fmt.Errorf("--ranges[%d] (%q) 必须包含工作表前缀,格式为 \"SheetName!A1:B3\"", i, rng)
|
||||
// 与 batch-set-style 共用同一个拆分器:此前这里自己拆,且只按原始串里
|
||||
// ! 的位置判断," !A1:B2" 会拆出空工作表名并带着 sheetId:"" 下发。
|
||||
sheetName, rangeAddr, err := splitSheetPrefixedRange(rng, i)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheetName := strings.TrimSpace(rng[:idx])
|
||||
rangeAddr := strings.TrimSpace(rng[idx+1:])
|
||||
operations = append(operations, map[string]any{
|
||||
"toolName": "clear_range",
|
||||
"input": map[string]any{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -129,6 +129,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
--match-case 区分大小写(默认 false)
|
||||
--match-entire-cell 完整单元格匹配(默认 false)
|
||||
--use-regexp 使用正则表达式匹配(默认 false)
|
||||
--match-formula 在公式文本中查找替换(默认 false,替换公式源码而非显示值)
|
||||
--include-hidden 包含隐藏行/列(默认 false)
|
||||
--range 限定替换范围,A1 表示法(不传时在整个工作表中替换)
|
||||
|
||||
@@ -166,6 +167,8 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
toolArgs["matchEntireCell"] = matchEntireCell
|
||||
useRegExp, _ := cmd.Flags().GetBool("use-regexp")
|
||||
toolArgs["useRegExp"] = useRegExp
|
||||
matchFormula, _ := cmd.Flags().GetBool("match-formula")
|
||||
toolArgs["matchFormulaText"] = matchFormula
|
||||
includeHidden, _ := cmd.Flags().GetBool("include-hidden")
|
||||
toolArgs["includeHidden"] = includeHidden
|
||||
return callMCPTool("replace_all", toolArgs)
|
||||
@@ -198,6 +201,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "find", Property: "text"},
|
||||
{Name: "match-formula", Property: "matchFormulaText"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "replacement", Property: "replaceText"},
|
||||
{Name: "use-regexp", Property: "useRegExp"},
|
||||
@@ -212,6 +216,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
replaceCmd.Flags().Bool("match-case", false, "区分大小写 (默认 false)")
|
||||
replaceCmd.Flags().Bool("match-entire-cell", false, "完整单元格匹配")
|
||||
replaceCmd.Flags().Bool("use-regexp", false, "启用正则表达式匹配")
|
||||
replaceCmd.Flags().Bool("match-formula", false, "在公式文本中查找替换(默认 false)")
|
||||
replaceCmd.Flags().Bool("include-hidden", false, "包含隐藏行/列")
|
||||
|
||||
appendCmd := &cobra.Command{
|
||||
|
||||
@@ -3,12 +3,21 @@ package helpers
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// sizeTypeEnumHint 返回该维度支持的尺寸模式列表(行高含 auto,列宽不含,与飞书一致)。
|
||||
func sizeTypeEnumHint(dimension string) string {
|
||||
if dimension == "ROWS" {
|
||||
return "pixel / standard / auto"
|
||||
}
|
||||
return "pixel / standard"
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -43,8 +52,11 @@ func newDimensionCmds() []*cobra.Command {
|
||||
return fmt.Errorf("--dimension 必须为 ROWS 或 COLUMNS,当前值: %s", dimension)
|
||||
}
|
||||
lengthStr := mustGetFlag(cmd, "length")
|
||||
var length int
|
||||
if _, err := fmt.Sscanf(lengthStr, "%d", &length); err != nil || length < 1 {
|
||||
// strconv.Atoi 要求整个字符串都是合法整数。fmt.Sscanf("%d") 只消费前缀
|
||||
// 数字,会把 "3x" / "3foo" 静默当成 3,从而对错误的行列数执行插入/删除/
|
||||
// 调整——删除方向不可回滚。
|
||||
length, lengthErr := strconv.Atoi(lengthStr)
|
||||
if lengthErr != nil || length < 1 {
|
||||
return fmt.Errorf("--length 必须为正整数(>= 1),当前值: %s", lengthStr)
|
||||
}
|
||||
if length > 5000 {
|
||||
@@ -428,8 +440,11 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
return fmt.Errorf("--dimension 必须为 ROWS 或 COLUMNS,当前值: %s", dimension)
|
||||
}
|
||||
lengthStr := mustGetFlag(cmd, "length")
|
||||
var length int
|
||||
if _, err := fmt.Sscanf(lengthStr, "%d", &length); err != nil || length < 1 {
|
||||
// strconv.Atoi 要求整个字符串都是合法整数。fmt.Sscanf("%d") 只消费前缀
|
||||
// 数字,会把 "3x" / "3foo" 静默当成 3,从而对错误的行列数执行插入/删除/
|
||||
// 调整——删除方向不可回滚。
|
||||
length, lengthErr := strconv.Atoi(lengthStr)
|
||||
if lengthErr != nil || length < 1 {
|
||||
return fmt.Errorf("--length 必须为正整数(>= 1),当前值: %s", lengthStr)
|
||||
}
|
||||
if length > 5000 {
|
||||
@@ -490,9 +505,15 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
--dimension COLUMNS 时,--start-index 为列字母,如 "A" 表示从 A 列开始、"AB" 表示从 AB 列开始
|
||||
|
||||
支持在 --start-index 中携带工作表前缀(如 "Sheet1!3" / "Sheet1!A"),此时将忽略 --sheet-id。
|
||||
--hidden 与 --pixel-size 至少必须提供一个。当同时提供时,将先应用尺寸再应用显隐,任一失败整体失败。
|
||||
--hidden 与 --pixel-size 至少必须提供一个(或用 --size-type standard/auto 让服务端决定尺寸)。
|
||||
当同时提供时,将先应用尺寸再应用显隐,任一失败整体失败。
|
||||
--pixel-size 单位为像素,dimension=ROWS 时表示行高、dimension=COLUMNS 时表示列宽。
|
||||
|
||||
尺寸模式(--size-type,对齐飞书):
|
||||
pixel 默认,按 --pixel-size 指定的像素值设置
|
||||
standard 恢复默认行高/列宽,无需 --pixel-size
|
||||
auto 按内容自适应行高,仅 ROWS 支持(列宽不提供自适应),无需 --pixel-size
|
||||
|
||||
常见场景:隐藏/显示指定连续行或列、批量调整行高/列宽、在同一次调用中同时修改尺寸与显隐。`,
|
||||
Example: ` # 隐藏第 3~4 行
|
||||
dws sheet update-dimension --node NODE_ID --sheet-id SHEET_ID --dimension ROWS --start-index "3" --length 2 --hidden
|
||||
@@ -517,8 +538,11 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
return fmt.Errorf("--dimension 必须为 ROWS 或 COLUMNS,当前值: %s", dimension)
|
||||
}
|
||||
lengthStr := mustGetFlag(cmd, "length")
|
||||
var length int
|
||||
if _, err := fmt.Sscanf(lengthStr, "%d", &length); err != nil || length < 1 {
|
||||
// strconv.Atoi 要求整个字符串都是合法整数。fmt.Sscanf("%d") 只消费前缀
|
||||
// 数字,会把 "3x" / "3foo" 静默当成 3,从而对错误的行列数执行插入/删除/
|
||||
// 调整——删除方向不可回滚。
|
||||
length, lengthErr := strconv.Atoi(lengthStr)
|
||||
if lengthErr != nil || length < 1 {
|
||||
return fmt.Errorf("--length 必须为正整数(>= 1),当前值: %s", lengthStr)
|
||||
}
|
||||
if length > 5000 {
|
||||
@@ -526,8 +550,30 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
}
|
||||
hiddenChanged := cmd.Flags().Changed("hidden")
|
||||
pixelSizeChanged := cmd.Flags().Changed("pixel-size")
|
||||
if !hiddenChanged && !pixelSizeChanged {
|
||||
return fmt.Errorf("--hidden 与 --pixel-size 至少必须提供一个")
|
||||
sizeType, _ := cmd.Flags().GetString("size-type")
|
||||
sizeType = strings.ToLower(strings.TrimSpace(sizeType))
|
||||
// 尺寸模式枚举按维度区分(与飞书一致):行高有 auto,列宽只有 pixel / standard
|
||||
switch {
|
||||
case sizeType == "" || sizeType == "pixel" || sizeType == "standard":
|
||||
case sizeType == "auto" && dimension == "ROWS":
|
||||
case sizeType == "auto":
|
||||
return fmt.Errorf("--size-type 对 COLUMNS 仅支持 pixel / standard(列宽不提供自适应)")
|
||||
default:
|
||||
return fmt.Errorf("--size-type 必须为 %s,当前值: %s", sizeTypeEnumHint(dimension), sizeType)
|
||||
}
|
||||
// standard/auto 由服务端决定尺寸,无需 --pixel-size
|
||||
sizeTypeDrivesSize := sizeType == "standard" || sizeType == "auto"
|
||||
if !hiddenChanged && !pixelSizeChanged && !sizeTypeDrivesSize {
|
||||
return fmt.Errorf("--hidden 与 --pixel-size 至少必须提供一个(或用 --size-type standard/auto 让服务端决定尺寸)")
|
||||
}
|
||||
// standard/auto 说"尺寸交给服务端",--pixel-size 又指定固定像素,
|
||||
// 两者语义直接冲突;同时下发会得到依赖服务端实现的结果,故先拒。
|
||||
if sizeTypeDrivesSize && pixelSizeChanged {
|
||||
return fmt.Errorf("--size-type %s 表示尺寸由服务端决定,不能同时指定 --pixel-size;要指定固定像素请用 --size-type pixel(或省略 --size-type)", sizeType)
|
||||
}
|
||||
// 反向:显式声明 pixel 模式却不给像素值,同样是不完整的请求。
|
||||
if sizeType == "pixel" && !pixelSizeChanged {
|
||||
return fmt.Errorf("--size-type pixel 必须配合 --pixel-size 指定像素值;若本次只改显隐,请省略 --size-type")
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
@@ -547,6 +593,9 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
}
|
||||
toolArgs["pixelSize"] = pixelSize
|
||||
}
|
||||
if sizeType != "" {
|
||||
toolArgs["sizeType"] = sizeType
|
||||
}
|
||||
return callMCPTool("update_dimension", toolArgs)
|
||||
},
|
||||
}
|
||||
@@ -587,6 +636,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
updateDimensionCmd.Flags().String("length", "", "更新数量,正整数 (必填),最大 5000")
|
||||
updateDimensionCmd.Flags().Bool("hidden", false, "是否隐藏 (true=隐藏, false=显示)")
|
||||
updateDimensionCmd.Flags().Int("pixel-size", 0, "行高或列宽(像素),ROWS 时为行高,COLUMNS 时为列宽")
|
||||
updateDimensionCmd.Flags().String("size-type", "", "尺寸模式(对齐飞书): pixel(默认,用 --pixel-size) / standard(恢复默认行高列宽) / auto(按内容自适应行高,仅 ROWS;列宽无此选项)")
|
||||
|
||||
groupDimensionCmd := &cobra.Command{
|
||||
Use: "group-dimension",
|
||||
|
||||
@@ -2,6 +2,7 @@ package helpers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -74,6 +75,84 @@ func TestCrossPlatformCoverageDimensionValidationRemainingCoverage(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// --length 必须整个值都是合法正整数。改用 strconv.Atoi 之前走的是
|
||||
// fmt.Sscanf("%d"),只消费前缀数字,"2x" 被静默当成 2 并对错误的行列数执行操作
|
||||
// ——delete 方向不可回滚。这是对既有命令的用户可见行为变更(已记入 CHANGELOG),
|
||||
// 用测试钉住,避免哪天改回宽松解析没人发现。
|
||||
func TestDimensionLengthRejectsTrailingCharacters(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
common := []string{"--node", "node", "--sheet-id", "sheet", "--dimension", "ROWS"}
|
||||
|
||||
for _, cmdName := range []string{"insert-dimension", "delete-dimension", "update-dimension"} {
|
||||
locator := []string{"--position", "1"}
|
||||
if cmdName == "update-dimension" {
|
||||
locator = []string{"--start-index", "1", "--hidden"}
|
||||
}
|
||||
for _, bad := range []string{"2x", "3foo", "1 2", "0x10", "abc", ""} {
|
||||
args := append(append(append([]string{}, common...), locator...), "--length", bad)
|
||||
err := executeDimensionCoverage(t, cmdName, args...)
|
||||
if err == nil {
|
||||
t.Errorf("%s --length %q 未报错,畸形长度被静默接受", cmdName, bad)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--length") {
|
||||
t.Errorf("%s --length %q: err = %v, want 指明 --length", cmdName, bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --size-type 的枚举按维度区分:行高有 auto,列宽只有 pixel / standard(对齐飞书)。
|
||||
func TestUpdateDimensionSizeTypeEnumIsDimensionScoped(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
common := []string{"--node", "node", "--sheet-id", "sheet", "--start-index", "1", "--length", "2"}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
dimension string
|
||||
sizeType string
|
||||
wantErr string
|
||||
}{
|
||||
// standard / auto 由服务端决定尺寸,不再要求 --hidden 或 --pixel-size
|
||||
{"rows-standard", "ROWS", "standard", ""},
|
||||
{"rows-auto", "ROWS", "auto", ""},
|
||||
{"columns-standard", "COLUMNS", "standard", ""},
|
||||
{"columns-auto-rejected", "COLUMNS", "auto", "仅支持 pixel / standard"},
|
||||
// 非法枚举的提示文案必须按维度给出不同的合法值列表
|
||||
{"rows-bogus-hint", "ROWS", "bogus", "必须为 pixel / standard / auto"},
|
||||
{"columns-bogus-hint", "COLUMNS", "bogus", "必须为 pixel / standard"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
args := append(append([]string{}, common...), "--dimension", tc.dimension, "--size-type", tc.sizeType)
|
||||
err := executeDimensionCoverage(t, "update-dimension", args...)
|
||||
if tc.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("err = %v, want nil", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("err = %v, want contains %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// pixel 仍需 --hidden 或 --pixel-size 之一
|
||||
args := append(append([]string{}, common...), "--dimension", "ROWS", "--size-type", "pixel")
|
||||
if err := executeDimensionCoverage(t, "update-dimension", args...); err == nil {
|
||||
t.Fatal("size-type=pixel 且未给 --pixel-size/--hidden 时应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizeTypeEnumHint(t *testing.T) {
|
||||
if got := sizeTypeEnumHint("ROWS"); got != "pixel / standard / auto" {
|
||||
t.Fatalf("ROWS hint = %q", got)
|
||||
}
|
||||
if got := sizeTypeEnumHint("COLUMNS"); got != "pixel / standard" {
|
||||
t.Fatalf("COLUMNS hint = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDropdownValidationRemainingCoverage(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
base := []string{"--node", "node", "--sheet-id", "sheet", "--range", "A1"}
|
||||
@@ -86,3 +165,80 @@ func TestCrossPlatformCoverageDropdownValidationRemainingCoverage(t *testing.T)
|
||||
t.Fatalf("multi-select dropdown: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --size-type standard/auto 与 --pixel-size 语义冲突(一个说交给服务端、一个
|
||||
// 指定固定像素),必须在发请求前拒;反向 pixel 模式也必须带像素值。
|
||||
func TestUpdateDimensionRejectsSizeTypeAndPixelSizeConflict(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
flags [][2]string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"standard-with-pixel-size",
|
||||
[][2]string{{"dimension", "ROWS"}, {"size-type", "standard"}, {"pixel-size", "40"}},
|
||||
"不能同时指定 --pixel-size",
|
||||
},
|
||||
{
|
||||
"auto-with-pixel-size",
|
||||
[][2]string{{"dimension", "ROWS"}, {"size-type", "auto"}, {"pixel-size", "40"}},
|
||||
"不能同时指定 --pixel-size",
|
||||
},
|
||||
{
|
||||
"explicit-pixel-without-size",
|
||||
[][2]string{{"dimension", "ROWS"}, {"size-type", "pixel"}, {"hidden", "true"}},
|
||||
"--size-type pixel 必须配合 --pixel-size",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
flags := append([][2]string{{"node", "N"}, {"sheet-id", "S"}, {"start-index", "1"}, {"length", "2"}}, tc.flags...)
|
||||
err := runUpdateDimensionForTest(t, caller, flags)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want contains %q", err, tc.want)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("calls = %d, want 0(冲突必须在发请求前拒)", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 合法组合仍然放行
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
flags [][2]string
|
||||
}{
|
||||
{"standard-alone", [][2]string{{"dimension", "ROWS"}, {"size-type", "standard"}}},
|
||||
{"auto-alone", [][2]string{{"dimension", "ROWS"}, {"size-type", "auto"}}},
|
||||
{"pixel-with-size", [][2]string{{"dimension", "ROWS"}, {"size-type", "pixel"}, {"pixel-size", "40"}}},
|
||||
{"pixel-size-without-size-type", [][2]string{{"dimension", "COLUMNS"}, {"pixel-size", "120"}}},
|
||||
{"hidden-only", [][2]string{{"dimension", "ROWS"}, {"hidden", "true"}}},
|
||||
} {
|
||||
t.Run("ok/"+tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true}`}}}
|
||||
flags := append([][2]string{{"node", "N"}, {"sheet-id", "S"}, {"start-index", "1"}, {"length", "2"}}, tc.flags...)
|
||||
if err := runUpdateDimensionForTest(t, caller, flags); err != nil {
|
||||
t.Fatalf("合法组合被拒: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runUpdateDimensionForTest(t *testing.T, caller *scriptedToolCaller, flags [][2]string) error {
|
||||
t.Helper()
|
||||
installScriptedCaller(t, caller)
|
||||
installSheetProductArgs(t)
|
||||
for _, c := range newDimensionCmds() {
|
||||
if c.Name() != "update-dimension" {
|
||||
continue
|
||||
}
|
||||
for _, kv := range flags {
|
||||
if err := c.Flags().Set(kv[0], kv[1]); err != nil {
|
||||
t.Fatalf("set --%s: %v", kv[0], err)
|
||||
}
|
||||
}
|
||||
return c.RunE(c, nil)
|
||||
}
|
||||
t.Fatal("update-dimension not found")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// sheet export-csv:把单个工作表同步导出为纯 RFC4180 CSV
|
||||
//
|
||||
// 独立成一条命令而不是给 sheet export 加 --export-format csv:sheet export 的叶子
|
||||
// 契约是 interface_mode=mcp + interface_ref=submit_export_job(xlsx 异步任务)。
|
||||
// csv 是互斥分支,走的是 get_range_as_csv,submit_export_job 根本不执行;挂在同一
|
||||
// 条叶子上会让 Agent 的接口审计、参数映射和后端能力判断在 csv 场景下全都拿到错误
|
||||
// 信息。本叶子如实声明 interface_ref=get_range_as_csv,sheet export 则保持原样。
|
||||
//
|
||||
// 与 sheet csv-get 的分工:csv-get 面向 Agent 阅读,输出带 [row=N] 行号前缀并按
|
||||
// -f 渲染,只回 stdout;本命令面向落盘,输出纯 CSV、可写文件,且截断时 fail-closed。
|
||||
// ============================================================================
|
||||
|
||||
func newSheetExportCsvCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "export-csv",
|
||||
Short: "导出单个工作表为纯 CSV(同步)",
|
||||
Long: `将钉钉在线电子表格的单个工作表同步导出为纯 RFC4180 CSV。
|
||||
|
||||
整篇表格导出为 xlsx 请用 dws sheet export(异步任务:提交→轮询→可选下载)。
|
||||
|
||||
参数说明:
|
||||
--node 表格文档 ID 或链接 URL,系统自动识别(必填)
|
||||
--sheet-id 要导出的工作表 ID 或名称(不传则第一个工作表)
|
||||
--range 导出范围,A1 表示法(不传则整表;大表可用此分块导出)
|
||||
--value-render-option 取值模式: formatted_value(默认) / raw_value / formula
|
||||
--output 本地保存路径(可选)。可为文件路径或目录(目录时存为
|
||||
sheet-export.csv);不传则把 CSV 打到 stdout
|
||||
--allow-truncated 允许数据被截断时仍然导出
|
||||
|
||||
截断行为:
|
||||
表格超出单次读取上限时默认直接报错并且不写文件,避免不完整数据被当成完整导出、
|
||||
或覆盖掉已有的完整文件。要接受不完整结果须显式加 --allow-truncated。
|
||||
|
||||
落盘保证:
|
||||
写文件走同目录临时文件 + rename 原子替换,写入中途失败(磁盘满、配额、I/O 错误)
|
||||
不会破坏已存在的目标文件。父目录不存在时报错,不会替你创建。
|
||||
|
||||
权限要求:
|
||||
当前用户对目标表格具备可查看权限。`,
|
||||
Example: ` # 导出第一个工作表到 stdout(可管道处理)
|
||||
dws sheet export-csv --node NODE_ID
|
||||
|
||||
# 导出指定工作表为本地 CSV 文件
|
||||
dws sheet export-csv --node NODE_ID --sheet-id SHEET_ID --output ./data.csv
|
||||
|
||||
# 只导出某个范围,取原始值
|
||||
dws sheet export-csv --node NODE_ID --range A1:Z1000 --value-render-option raw_value`,
|
||||
RunE: runSheetExportCsv,
|
||||
}
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "sheet",
|
||||
Name: "export_csv",
|
||||
CanonicalPath: "sheet.export_csv",
|
||||
CLIPath: "sheet export-csv",
|
||||
PrimaryCLIPath: "sheet export-csv",
|
||||
},
|
||||
Description: "同步导出单个工作表为纯 RFC4180 CSV(可落盘,截断即报错)。",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "get_range_as_csv"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "同步导出单个工作表为纯 RFC4180 CSV(可落盘,截断即报错)。",
|
||||
UseWhen: []string{"需要单个工作表的纯 CSV(写本地文件或管道处理)时"},
|
||||
AvoidWhen: []string{"要整篇表格的 xlsx 用 sheet export;只是让 Agent 读内容用 sheet csv-get(带 [row=N] 行号)"},
|
||||
Examples: []string{
|
||||
"dws sheet export-csv --node <NODE_ID> --sheet-id <SHEET_ID> --output ./data.csv",
|
||||
"dws sheet export-csv --node <NODE_ID> --range A1:Z1000",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "range", Property: "range"},
|
||||
{Name: "sheet-id", Property: "sheetId"},
|
||||
{Name: "value-render-option", Property: "valueRenderOption"},
|
||||
},
|
||||
},
|
||||
})
|
||||
cmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
cmd.Flags().String("sheet-id", "", "工作表 ID 或名称(不传则第一个工作表)")
|
||||
cmd.Flags().String("range", "", "导出范围,A1 表示法(不传则整表;大表可用此分块导出)")
|
||||
cmd.Flags().String("value-render-option", "", "取值模式: formatted_value(默认) / raw_value / formula")
|
||||
cmd.Flags().String("output", "", "本地保存路径(可选,支持文件路径或目录);不传则输出到 stdout")
|
||||
cmd.Flags().Bool("allow-truncated", false, "允许数据被截断时仍然导出。默认截断即报错并不写文件,避免不完整数据被当成完整导出")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// valueRenderOptionEnum 是 --value-render-option 的合法取值。
|
||||
var valueRenderOptionEnum = map[string]bool{
|
||||
"formatted_value": true, "raw_value": true, "formula": true,
|
||||
}
|
||||
|
||||
// runSheetExportCsv 导出单个工作表为纯 CSV(同步,复用 get_range_as_csv,annotateRowNumbers=false)。
|
||||
func runSheetExportCsv(cmd *cobra.Command, _ []string) error {
|
||||
nodeID := mustGetFlag(cmd, "node")
|
||||
if nodeID == "" {
|
||||
return fmt.Errorf("flag --node is required")
|
||||
}
|
||||
sheetID, _ := cmd.Flags().GetString("sheet-id")
|
||||
rangeAddr, _ := cmd.Flags().GetString("range")
|
||||
valueRenderOption, _ := cmd.Flags().GetString("value-render-option")
|
||||
valueRenderOption = strings.ToLower(strings.TrimSpace(valueRenderOption))
|
||||
if valueRenderOption != "" && !valueRenderOptionEnum[valueRenderOption] {
|
||||
return fmt.Errorf("--value-render-option 必须为 formatted_value / raw_value / formula,当前值: %s", valueRenderOption)
|
||||
}
|
||||
outputPath, _ := cmd.Flags().GetString("output")
|
||||
allowTruncated, _ := cmd.Flags().GetBool("allow-truncated")
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
deps.Out.PrintKeyValue("操作", "导出工作表为 CSV")
|
||||
deps.Out.PrintKeyValue("节点", nodeID)
|
||||
if sheetID != "" {
|
||||
deps.Out.PrintKeyValue("工作表", sheetID)
|
||||
}
|
||||
if outputPath != "" {
|
||||
deps.Out.PrintKeyValue("输出", outputPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"annotateRowNumbers": false,
|
||||
}
|
||||
if sheetID != "" {
|
||||
toolArgs["sheetId"] = sheetID
|
||||
}
|
||||
if rangeAddr != "" {
|
||||
toolArgs["range"] = rangeAddr
|
||||
}
|
||||
if valueRenderOption != "" {
|
||||
toolArgs["valueRenderOption"] = valueRenderOption
|
||||
}
|
||||
|
||||
// CSV 正文走 stdout,进度/警告一律不能污染它。
|
||||
text, err := callMCPToolReturnText(ctx, "get_range_as_csv", toolArgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取 CSV 失败: %w", err)
|
||||
}
|
||||
|
||||
csvContent, hasMore, err := parseGetRangeAsCsvResult(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 截断必须 fail-closed:只打 stderr 警告然后照常落盘 + 报"导出完成" + 退出码 0,
|
||||
// 会让自动化调用方(和没留意 stderr 的人)把不完整文件当成完整导出,且若目标
|
||||
// 文件已存在还会被截断数据覆盖。默认在写文件/输出之前就失败,要接受不完整结果
|
||||
// 必须显式加 --allow-truncated。
|
||||
if hasMore && !allowTruncated {
|
||||
return fmt.Errorf("表格数据超出单次读取上限,CSV 会被截断,已中止导出(未写入 %s);"+
|
||||
"请用 --range 分块导出(如 --range A1:Z1000、A1001:Z2000 ...)、改用 dws sheet export 导出完整表格的 xlsx,"+
|
||||
"或确认可接受不完整数据后加 --allow-truncated",
|
||||
firstNonEmpty(outputPath, "stdout"))
|
||||
}
|
||||
if hasMore {
|
||||
deps.Out.PrintWarning("表格数据超出单次读取上限,CSV 已被截断(--allow-truncated 已显式放行)。" +
|
||||
"请用 --range 分块导出(如 --range A1:Z1000、A1001:Z2000 ...),或改用 dws sheet export 导出完整表格的 xlsx。")
|
||||
}
|
||||
|
||||
if outputPath == "" {
|
||||
deps.Out.PrintRaw(csvContent)
|
||||
return nil
|
||||
}
|
||||
if fi, statErr := os.Stat(outputPath); statErr == nil && fi.IsDir() {
|
||||
outputPath = filepath.Join(outputPath, "sheet-export.csv")
|
||||
}
|
||||
// 必须原子替换:os.WriteFile 会先把已存在的 CSV 截断,写入中途失败(磁盘满、
|
||||
// 配额、I/O 错误)就把用户的原文件毁掉了。AtomicWrite 写同目录临时文件再 rename,
|
||||
// 失败时原文件保持不变。
|
||||
// AtomicWrite 会 MkdirAll 父目录,先探一次,保持与 sheet export 一致的「父目录
|
||||
// 不存在即报错」语义,避免把拼错的路径悄悄建成目录。
|
||||
if _, statErr := os.Stat(filepath.Dir(outputPath)); statErr != nil {
|
||||
return fmt.Errorf("写入 CSV 文件失败: %w", statErr)
|
||||
}
|
||||
if err := AtomicWrite(outputPath, []byte(csvContent), 0o644); err != nil {
|
||||
return fmt.Errorf("写入 CSV 文件失败: %w", err)
|
||||
}
|
||||
if hasMore {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("导出完成(数据已截断,不是完整表格): %s", outputPath))
|
||||
return nil
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("导出完成: %s", outputPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseGetRangeAsCsvResult 从 get_range_as_csv 的 MCP 响应中提取 csv 文本与 hasMore 标志。
|
||||
//
|
||||
// csv 字段缺失或类型不对,必须报错而不是当成空表:调用方会把空内容写进
|
||||
// --output,用 0 字节覆盖已有文件并打印"导出完成",等于静默数据丢失。
|
||||
// 「字段存在且为空串」是合法的(真的空区域),与「字段缺失」区分开。
|
||||
func parseGetRangeAsCsvResult(text string) (csv string, hasMore bool, err error) {
|
||||
var data map[string]any
|
||||
if e := json.Unmarshal([]byte(text), &data); e != nil {
|
||||
return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: %w", e)
|
||||
}
|
||||
if raw, wrapped := data["result"]; wrapped {
|
||||
result, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: result 不是对象,响应: %s", text)
|
||||
}
|
||||
data = result
|
||||
}
|
||||
raw, exists := data["csv"]
|
||||
if !exists {
|
||||
return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: 缺少 csv 字段,响应: %s", text)
|
||||
}
|
||||
csvVal, ok := raw.(string)
|
||||
if !ok {
|
||||
return "", false, fmt.Errorf("解析 get_range_as_csv 响应失败: csv 字段不是字符串(%T),响应: %s", raw, text)
|
||||
}
|
||||
csv = csvVal
|
||||
if hm, ok := data["hasMore"].(bool); ok {
|
||||
hasMore = hm
|
||||
}
|
||||
return csv, hasMore, nil
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func executeSheetExportCsv(t *testing.T, caller *scriptedToolCaller, args ...string) error {
|
||||
t.Helper()
|
||||
installScriptedCaller(t, caller)
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "sheet"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
cmd := newSheetExportCsvCmd()
|
||||
for index := 0; index < len(args); index += 2 {
|
||||
if err := cmd.Flags().Set(args[index], args[index+1]); err != nil {
|
||||
t.Fatalf("set %s: %v", args[index], err)
|
||||
}
|
||||
}
|
||||
return runSheetExportCsv(cmd, nil)
|
||||
}
|
||||
|
||||
// export-csv 是独立叶子,不能和 xlsx 的 sheet export 共用一套 flag:混在一条命令里
|
||||
// 时,自动化漏写 --export-format csv 会让 --range 被静默丢掉、导出整篇工作簿而仍报
|
||||
// 成功。这里钉住两条命令的 flag 面互不渗透。
|
||||
func TestSheetExportAndExportCsvFlagsDoNotLeak(t *testing.T) {
|
||||
xlsxOnly := []string{}
|
||||
newExportCmd().Flags().VisitAll(func(f *pflag.Flag) { xlsxOnly = append(xlsxOnly, f.Name) })
|
||||
sort.Strings(xlsxOnly)
|
||||
if want := []string{"node", "output"}; !reflect.DeepEqual(xlsxOnly, want) {
|
||||
t.Errorf("sheet export flags = %v, want %v(csv 专属 flag 不能回到 xlsx 命令上)", xlsxOnly, want)
|
||||
}
|
||||
|
||||
csvFlags := map[string]bool{}
|
||||
newSheetExportCsvCmd().Flags().VisitAll(func(f *pflag.Flag) { csvFlags[f.Name] = true })
|
||||
for _, name := range []string{"node", "output", "sheet-id", "range", "value-render-option", "allow-truncated"} {
|
||||
if !csvFlags[name] {
|
||||
t.Errorf("sheet export-csv 缺少 flag --%s", name)
|
||||
}
|
||||
}
|
||||
// 不能出现格式路由 flag:格式由命令名决定,避免又回到单命令多分支的老问题。
|
||||
if csvFlags["export-format"] {
|
||||
t.Error("sheet export-csv 不应有 --export-format(格式已由命令名区分)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportCsvRequiresNode(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := executeSheetExportCsv(t, caller)
|
||||
if err == nil || !strings.Contains(err.Error(), "--node is required") {
|
||||
t.Fatalf("err = %v, want required node", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("calls = %d, want 0", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// --value-render-option 的枚举必须在发请求之前校验,否则非法取值会被静默透传。
|
||||
func TestSheetExportCsvValidatesValueRenderOption(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := executeSheetExportCsv(t, caller, "node", "NODE", "value-render-option", "pretty")
|
||||
if err == nil ||
|
||||
!strings.Contains(err.Error(), "--value-render-option 必须为 formatted_value / raw_value / formula") {
|
||||
t.Fatalf("err = %v, want enum rejection", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("calls = %d, want 0(校验必须在发请求之前)", caller.calls)
|
||||
}
|
||||
|
||||
for _, option := range []string{"formatted_value", "raw_value", "formula", "FORMULA", " raw_value "} {
|
||||
ok := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}}
|
||||
if err := executeSheetExportCsv(t, ok, "node", "NODE", "value-render-option", option); err != nil {
|
||||
t.Fatalf("value-render-option=%q 应被接受: %v", option, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportCsvDryRunPreviewsSelectors(t *testing.T) {
|
||||
caller := &scriptedToolCaller{dry: true}
|
||||
if err := executeSheetExportCsv(t, caller,
|
||||
"node", "NODE", "sheet-id", "SHEET_1", "output", "out.csv"); err != nil {
|
||||
t.Fatalf("csv dry run: %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("dry run 不应发请求, calls = %d", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportCsvForwardsSelectorsAndWarnsOnTruncation(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"csv":"a,b\n","hasMore":true}`},
|
||||
}}
|
||||
// 截断默认失败,所以要显式放行才能走到落盘/输出。
|
||||
if err := executeSheetExportCsv(t, caller,
|
||||
"node", "NODE", "range", "A1:Z1000", "sheet-id", "SHEET_1",
|
||||
"value-render-option", "raw_value", "allow-truncated", "true"); err != nil {
|
||||
t.Fatalf("csv export: %v", err)
|
||||
}
|
||||
for _, kv := range [][2]string{
|
||||
{"range", "A1:Z1000"},
|
||||
{"sheetId", "SHEET_1"},
|
||||
{"valueRenderOption", "raw_value"},
|
||||
} {
|
||||
if got := caller.args[kv[0]]; got != kv[1] {
|
||||
t.Errorf("%s 未透传: %#v", kv[0], got)
|
||||
}
|
||||
}
|
||||
// csv 正文不带行号前缀,annotateRowNumbers 必须显式关掉。
|
||||
if caller.args["annotateRowNumbers"] != false {
|
||||
t.Fatalf("annotateRowNumbers = %#v, want false", caller.args["annotateRowNumbers"])
|
||||
}
|
||||
}
|
||||
|
||||
// 截断必须 fail-closed:默认报错、不写文件、不报“导出完成”。否则自动化调用方会把
|
||||
// 不完整文件当成完整导出,且已存在的目标文件会被截断数据覆盖。
|
||||
func TestSheetExportCsvFailsOnTruncationWithoutOptIn(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "out.csv")
|
||||
if err := os.WriteFile(out, []byte("PREEXISTING\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"csv":"a,b\n","hasMore":true}`},
|
||||
}}
|
||||
err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out)
|
||||
if err == nil {
|
||||
t.Fatal("截断时未放行应报错,而非静默成功")
|
||||
}
|
||||
for _, want := range []string{"截断", "已中止导出", "--allow-truncated"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("错误信息缺少 %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
// 关键:既有文件必须保持原样,不能被截断数据覆盖。
|
||||
body, readErr := os.ReadFile(out)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read output: %v", readErr)
|
||||
}
|
||||
if string(body) != "PREEXISTING\n" {
|
||||
t.Fatalf("目标文件被截断数据覆盖了: %q", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// 显式放行后允许落盘,但成功信息必须点明数据不完整。
|
||||
func TestSheetExportCsvAllowTruncatedWritesAndFlagsIncompleteness(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "out.csv")
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"csv":"a,b\n","hasMore":true}`},
|
||||
}}
|
||||
if err := executeSheetExportCsv(t, caller,
|
||||
"node", "NODE", "output", out, "allow-truncated", "true"); err != nil {
|
||||
t.Fatalf("allow-truncated 应允许导出: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(out)
|
||||
if err != nil {
|
||||
t.Fatalf("read output: %v", err)
|
||||
}
|
||||
if string(body) != "a,b\n" {
|
||||
t.Fatalf("csv 内容 = %q", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// 未截断时不受影响:不需要 --allow-truncated,成功信息也不提不完整。
|
||||
func TestSheetExportCsvCompleteReadNeedsNoOptIn(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := filepath.Join(dir, "out.csv")
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n","hasMore":false}`}}}
|
||||
if err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out); err != nil {
|
||||
t.Fatalf("完整读取不应要求放行: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(out)
|
||||
if err != nil || string(body) != "a,b\n" {
|
||||
t.Fatalf("csv 内容 = %q, err = %v", string(body), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportCsvWritesIntoDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}}
|
||||
if err := executeSheetExportCsv(t, caller, "node", "NODE", "output", dir); err != nil {
|
||||
t.Fatalf("csv export: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(dir, "sheet-export.csv"))
|
||||
if err != nil {
|
||||
t.Fatalf("read exported csv: %v", err)
|
||||
}
|
||||
if string(body) != "a,b\n" {
|
||||
t.Fatalf("csv 内容 = %q", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// 落盘失败必须保住已有文件:os.WriteFile 会先截断目标,写到一半失败(磁盘满、
|
||||
// 配额、I/O 错误)用户的原文件就没了。走 AtomicWrite 后失败只丢临时文件。
|
||||
func TestSheetExportCsvWriteFailureKeepsExistingFile(t *testing.T) {
|
||||
out := filepath.Join(t.TempDir(), "existing.csv")
|
||||
const original = "原有重要数据\n1,2,3\n"
|
||||
if err := os.WriteFile(out, []byte(original), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 模拟"临时文件写完、替换目标时失败",这是 os.WriteFile 会毁掉原文件的时刻。
|
||||
previousRename := atomicRename
|
||||
t.Cleanup(func() { atomicRename = previousRename })
|
||||
atomicRename = func(string, string) error { return errors.New("no space left on device") }
|
||||
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}}
|
||||
err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out)
|
||||
if err == nil || !strings.Contains(err.Error(), "写入 CSV 文件失败") {
|
||||
t.Fatalf("err = %v, want write failure", err)
|
||||
}
|
||||
|
||||
body, readErr := os.ReadFile(out)
|
||||
if readErr != nil {
|
||||
t.Fatalf("read output: %v", readErr)
|
||||
}
|
||||
if string(body) != original {
|
||||
t.Fatalf("写入失败后原文件被破坏为 %q,应保持不变", string(body))
|
||||
}
|
||||
|
||||
// 临时文件不能留在目录里冒充导出产物。
|
||||
entries, readDirErr := os.ReadDir(filepath.Dir(out))
|
||||
if readDirErr != nil {
|
||||
t.Fatalf("read dir: %v", readDirErr)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].Name() != filepath.Base(out) {
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
t.Fatalf("目录残留临时文件: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportCsvReportsWriteFailure(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\n"}`}}}
|
||||
missing := filepath.Join(t.TempDir(), "no-such-dir", "out.csv")
|
||||
err := executeSheetExportCsv(t, caller, "node", "NODE", "output", missing)
|
||||
if err == nil || !strings.Contains(err.Error(), "写入 CSV 文件失败") {
|
||||
t.Fatalf("err = %v, want write failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetExportCsvReportsReadFailure(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"errorCode":"NoPermission"}`}}}
|
||||
err := executeSheetExportCsv(t, caller, "node", "NODE")
|
||||
if err == nil || !strings.Contains(err.Error(), "读取 CSV 失败") {
|
||||
t.Fatalf("err = %v, want read failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGetRangeAsCsvResult(t *testing.T) {
|
||||
// 兼容裸响应与 result 包装两种形状
|
||||
for _, body := range []string{
|
||||
`{"csv":"a,b\n","hasMore":true}`,
|
||||
`{"result":{"csv":"a,b\n","hasMore":true}}`,
|
||||
} {
|
||||
csv, hasMore, err := parseGetRangeAsCsvResult(body)
|
||||
if err != nil || csv != "a,b\n" || !hasMore {
|
||||
t.Fatalf("parseGetRangeAsCsvResult(%s) = (%q,%v,%v)", body, csv, hasMore, err)
|
||||
}
|
||||
}
|
||||
// 字段存在但为空串是合法的(真的空区域)
|
||||
csv, hasMore, err := parseGetRangeAsCsvResult(`{"csv":""}`)
|
||||
if err != nil || csv != "" || hasMore {
|
||||
t.Fatalf(`csv:"" = (%q,%v,%v), want ("",false,nil)`, csv, hasMore, err)
|
||||
}
|
||||
}
|
||||
|
||||
// csv 字段缺失或类型不对必须报错。此前会被当成空表,配合 --output 会用
|
||||
// 0 字节覆盖已有文件并打印"导出完成",属于静默数据丢失。
|
||||
func TestParseGetRangeAsCsvResultRejectsMissingOrBadCsvField(t *testing.T) {
|
||||
for _, tc := range []struct{ body, want string }{
|
||||
{"not json", "解析 get_range_as_csv 响应失败"},
|
||||
{`{"message":"something odd"}`, "缺少 csv 字段"},
|
||||
{`{"csv":123}`, "csv 字段不是字符串"},
|
||||
{`{"csv":null}`, "csv 字段不是字符串"},
|
||||
{`{"result":"not-an-object"}`, "result 不是对象"},
|
||||
{`{"result":{"message":"odd"}}`, "缺少 csv 字段"},
|
||||
} {
|
||||
if _, _, err := parseGetRangeAsCsvResult(tc.body); err == nil ||
|
||||
!strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("parseGetRangeAsCsvResult(%s) err = %v, want contains %q", tc.body, err, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:响应缺 csv 字段时,绝不能覆盖 --output 指向的已有文件。
|
||||
func TestSheetExportCsvNeverTruncatesOutputOnBadResponse(t *testing.T) {
|
||||
out := filepath.Join(t.TempDir(), "existing.csv")
|
||||
const original = "原有重要数据\n1,2,3\n"
|
||||
if err := os.WriteFile(out, []byte(original), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"message":"something odd"}`}}}
|
||||
err := executeSheetExportCsv(t, caller, "node", "NODE", "output", out)
|
||||
if err == nil || !strings.Contains(err.Error(), "缺少 csv 字段") {
|
||||
t.Fatalf("err = %v, want missing csv field", err)
|
||||
}
|
||||
body, readErr := os.ReadFile(out)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
if string(body) != original {
|
||||
t.Fatalf("已有文件被改写为 %q,原内容应保持不变", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// CSV 正文走 stdout,截断警告必须只走 stderr:否则管道/重定向拿到的文件里会
|
||||
// 混入 [WARN] 文本,不再是合法 RFC4180 CSV。而大表恰恰最容易触发这个分支。
|
||||
func TestSheetExportCsvKeepsStdoutPureAndWarnsOnStderr(t *testing.T) {
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"csv":"a,b\nc,d\n","hasMore":true}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
installSheetProductArgs(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
deps.Out.w, deps.Out.errW = &stdout, &stderr
|
||||
|
||||
cmd := newSheetExportCsvCmd()
|
||||
// 截断默认 fail-closed;本用例验证的是放行后 stdout 仍然纯净、警告只走 stderr。
|
||||
for _, kv := range [][2]string{{"node", "NODE"}, {"allow-truncated", "true"}} {
|
||||
if err := cmd.Flags().Set(kv[0], kv[1]); err != nil {
|
||||
t.Fatalf("set --%s: %v", kv[0], err)
|
||||
}
|
||||
}
|
||||
if err := runSheetExportCsv(cmd, nil); err != nil {
|
||||
t.Fatalf("csv export: %v", err)
|
||||
}
|
||||
|
||||
if got := stdout.String(); got != "a,b\nc,d\n" {
|
||||
t.Fatalf("stdout = %q, want 纯 CSV 正文", got)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "WARN") || strings.Contains(stdout.String(), "截断") {
|
||||
t.Fatalf("stdout 混入了警告文本: %q", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "CSV 已被截断") {
|
||||
t.Fatalf("stderr = %q, want 截断提示", stderr.String())
|
||||
}
|
||||
}
|
||||
+599
-373
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,376 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// 跨 range 累计单元格预算(对齐飞书 checkBatchStampBudget)。
|
||||
//
|
||||
// 每个 "S!A1:Z1000" 是 26×1000=26000 格:单独看都在 buildStyleCells 的
|
||||
// 单区域上限(rows≤1000、rows×cols≤30000)之内,所以只有累计预算能拦住它们。
|
||||
// 7 个 = 182000 通过,8 个 = 208000 越过 200000 上限。
|
||||
const budgetTestRange = "A1:Z1000"
|
||||
|
||||
func budgetTestRanges(n int) []string {
|
||||
out := make([]string, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, fmt.Sprintf("Sheet%d!%s", i+1, budgetTestRange))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newBatchStyleCmdWithStyle(t *testing.T) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd := newRangeBatchSetStyleCmd()
|
||||
if err := cmd.Flags().Set("bg-color", "#FFF2CC"); err != nil {
|
||||
t.Fatalf("set --bg-color: %v", err)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestBatchStyleRangesRespectAggregateCellBudget(t *testing.T) {
|
||||
// 7 个区域刚好在预算内:必须成功,且每个区域各产出一个 op。
|
||||
within, err := json.Marshal(budgetTestRanges(7))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ops, err := buildBatchStyleOpsFromRanges(newBatchStyleCmdWithStyle(t), string(within))
|
||||
if err != nil {
|
||||
t.Fatalf("7 个区域(累计 182000 格)应在预算内: %v", err)
|
||||
}
|
||||
if len(ops) != 7 {
|
||||
t.Fatalf("operations = %d, want 7", len(ops))
|
||||
}
|
||||
|
||||
// 第 8 个把累计推到 208000,越过 200000 上限。
|
||||
over, err := json.Marshal(budgetTestRanges(8))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = buildBatchStyleOpsFromRanges(newBatchStyleCmdWithStyle(t), string(over))
|
||||
if err == nil {
|
||||
t.Fatal("8 个区域(累计 208000 格)必须被累计预算拒绝")
|
||||
}
|
||||
for _, want := range []string{"--ranges[7]", "208000", "200000"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %v, want contains %q", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchStyleFileRespectsAggregateCellBudgetAndItemCap(t *testing.T) {
|
||||
writeBatch := func(t *testing.T, items []map[string]any) string {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "styles.json")
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("write batch fixture: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
item := func(i int) map[string]any {
|
||||
return map[string]any{
|
||||
"sheetId": fmt.Sprintf("Sheet%d", i+1),
|
||||
"range": budgetTestRange,
|
||||
"fontWeight": "bold",
|
||||
}
|
||||
}
|
||||
|
||||
// 累计预算:8 条 × 26000 = 208000 > 200000。
|
||||
overBudget := make([]map[string]any, 0, 8)
|
||||
for i := 0; i < 8; i++ {
|
||||
overBudget = append(overBudget, item(i))
|
||||
}
|
||||
_, err := buildBatchStyleOpsFromFile(writeBatch(t, overBudget))
|
||||
if err == nil {
|
||||
t.Fatal("--batch 累计 208000 格必须被拒绝")
|
||||
}
|
||||
for _, want := range []string{"第 8/8 条", "208000", "200000"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %v, want contains %q", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 条数上限与 --ranges 对齐:101 条应在展开任何矩阵之前就被拒。
|
||||
tooMany := make([]map[string]any, 0, maxBatchStyleRanges+1)
|
||||
for i := 0; i < maxBatchStyleRanges+1; i++ {
|
||||
tooMany = append(tooMany, map[string]any{
|
||||
"sheetId": "Sheet1", "range": "A1", "fontWeight": "bold",
|
||||
})
|
||||
}
|
||||
_, err = buildBatchStyleOpsFromFile(writeBatch(t, tooMany))
|
||||
if err == nil || !strings.Contains(err.Error(), "--batch 最多 100 条") {
|
||||
t.Fatalf("error = %v, want --batch item cap", err)
|
||||
}
|
||||
|
||||
// 预算内的正常配置仍然照常展开。
|
||||
ops, err := buildBatchStyleOpsFromFile(writeBatch(t, []map[string]any{item(0), item(1)}))
|
||||
if err != nil {
|
||||
t.Fatalf("2 条应在预算内: %v", err)
|
||||
}
|
||||
if len(ops) != 2 {
|
||||
t.Fatalf("operations = %d, want 2", len(ops))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddBatchStyleCellsAccumulatesAcrossCalls(t *testing.T) {
|
||||
var total int64
|
||||
if err := addBatchStyleCells(&total, 1000, 26); err != nil {
|
||||
t.Fatalf("first range: %v", err)
|
||||
}
|
||||
if total != 26000 {
|
||||
t.Fatalf("total = %d, want 26000", total)
|
||||
}
|
||||
// 单次就越界也必须拦住(累计器同时覆盖单区域巨量输入)。
|
||||
var single int64
|
||||
if err := addBatchStyleCells(&single, 1000, 1000); err == nil {
|
||||
t.Fatal("1000x1000=1000000 必须超出累计预算")
|
||||
}
|
||||
}
|
||||
|
||||
// styleFlagNames 是手工维护的,必须与 bindStyleFlags 实际绑定的 flag 严格一致:
|
||||
// 漏登记会让 --batch 的互斥检查放过一个静默失效的 flag。
|
||||
func TestStyleFlagNamesMatchBoundFlags(t *testing.T) {
|
||||
probe := &cobra.Command{Use: "probe"}
|
||||
bindStyleFlags(probe)
|
||||
|
||||
bound := map[string]bool{}
|
||||
probe.Flags().VisitAll(func(f *pflag.Flag) { bound[f.Name] = true })
|
||||
|
||||
listed := map[string]bool{}
|
||||
for _, name := range styleFlagNames {
|
||||
if listed[name] {
|
||||
t.Fatalf("styleFlagNames 重复登记 %q", name)
|
||||
}
|
||||
listed[name] = true
|
||||
if !bound[name] {
|
||||
t.Errorf("styleFlagNames 登记了 %q,但 bindStyleFlags 没有绑定它", name)
|
||||
}
|
||||
}
|
||||
for name := range bound {
|
||||
if !listed[name] {
|
||||
t.Errorf("bindStyleFlags 绑定了 %q,但 styleFlagNames 漏登记(--batch 互斥检查会放过它)", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchSetStyleRejectsStyleFlagsInBatchMode(t *testing.T) {
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
batchPath := filepath.Join(t.TempDir(), "styles.json")
|
||||
if err := os.WriteFile(batchPath, []byte(`[{"sheetId":"Sheet1","range":"A1:B2","fontWeight":"bold"}]`), 0o600); err != nil {
|
||||
t.Fatalf("write batch fixture: %v", err)
|
||||
}
|
||||
|
||||
caller := &sheetStyleDryRunCaller{format: "json"}
|
||||
InitDeps(caller)
|
||||
|
||||
cmd := newRangeBatchSetStyleCmd()
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs([]string{"--node", "NODE_ID", "--batch", batchPath, "--bg-color", "#FFF2CC", "--font-family", "Arial"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("--batch 搭配命令行样式 flag 必须报错,而不是静默忽略")
|
||||
}
|
||||
for _, want := range []string{"--batch 模式下样式来自配置文件", "--bg-color", "--font-family"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %v, want contains %q", err, want)
|
||||
}
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// buildBatchStyleOpsFromRanges 的每条前置校验都必须在组装 operations 之前拦住。
|
||||
func TestBatchStyleRangesRejectsInvalidInput(t *testing.T) {
|
||||
tooMany := make([]string, 0, maxBatchStyleRanges+1)
|
||||
for i := 0; i <= maxBatchStyleRanges; i++ {
|
||||
tooMany = append(tooMany, `"Sheet1!A1"`)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
ranges string
|
||||
style bool
|
||||
want string
|
||||
}{
|
||||
{name: "bad-json", ranges: `[`, style: true, want: "--ranges JSON 解析失败"},
|
||||
{name: "empty-array", ranges: `[]`, style: true, want: "--ranges 不能为空数组"},
|
||||
{
|
||||
name: "over-range-cap",
|
||||
ranges: "[" + strings.Join(tooMany, ",") + "]",
|
||||
style: true,
|
||||
want: "--ranges 最多 100 项",
|
||||
},
|
||||
{
|
||||
name: "missing-sheet-prefix",
|
||||
ranges: `["A1:B2"]`,
|
||||
style: true,
|
||||
want: "必须包含工作表前缀",
|
||||
},
|
||||
{
|
||||
name: "blank-sheet-prefix",
|
||||
ranges: `[" !A1:B2"]`,
|
||||
style: true,
|
||||
want: "必须包含工作表前缀",
|
||||
},
|
||||
{
|
||||
name: "blank-range-after-prefix",
|
||||
ranges: `["Sheet1! "]`,
|
||||
style: true,
|
||||
want: "必须包含工作表前缀",
|
||||
},
|
||||
{
|
||||
name: "bad-range-address",
|
||||
ranges: `["Sheet1!zz"]`,
|
||||
style: true,
|
||||
want: "解析失败",
|
||||
},
|
||||
{
|
||||
name: "no-style-flag",
|
||||
ranges: `["Sheet1!A1:B2"]`,
|
||||
style: false,
|
||||
want: "至少需要指定一个样式参数",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := newRangeBatchSetStyleCmd()
|
||||
if tc.style {
|
||||
if err := cmd.Flags().Set("bg-color", "#FFF2CC"); err != nil {
|
||||
t.Fatalf("set --bg-color: %v", err)
|
||||
}
|
||||
}
|
||||
_, err := buildBatchStyleOpsFromRanges(cmd, tc.ranges)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want contains %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 空白工作表标识必须在下发之前被拒绝 —— 三条入口共用同一条不变量。
|
||||
//
|
||||
// 只按原始串里 ! 的位置判断," !A1:B2" 会拆出空工作表名,操作却照样带着
|
||||
// sheetId:"" 提交:服务端要么让整批 batch_update 失败,要么落到默认工作表而不是
|
||||
// 用户指定的那张表(后者更糟,因为命令报成功)。--batch 的纯空白 sheetId 同理,
|
||||
// 它此前只挡 == ""。三条路径都必须在发请求之前失败。
|
||||
func TestBlankSheetIdentifierIsRejectedBeforeAnyRemoteCall(t *testing.T) {
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
batchDir := t.TempDir()
|
||||
writeBatch := func(t *testing.T, name, body string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(batchDir, name)
|
||||
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write batch fixture: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cmd func() *cobra.Command
|
||||
args func(t *testing.T) []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "set-style-ranges-blank-sheet",
|
||||
cmd: newRangeBatchSetStyleCmd,
|
||||
args: func(*testing.T) []string {
|
||||
return []string{"--node", "NODE_ID", "--ranges", `[" !A1:B2"]`, "--bg-color", "#FFF2CC"}
|
||||
},
|
||||
want: "必须包含工作表前缀",
|
||||
},
|
||||
{
|
||||
name: "set-style-batch-blank-sheet-id",
|
||||
cmd: newRangeBatchSetStyleCmd,
|
||||
args: func(t *testing.T) []string {
|
||||
p := writeBatch(t, "blank-sheet.json", `[{"sheetId":" ","range":"A1:B2","fontWeight":"bold"}]`)
|
||||
return []string{"--node", "NODE_ID", "--batch", p}
|
||||
},
|
||||
want: "缺少 sheetId 或 range",
|
||||
},
|
||||
{
|
||||
name: "set-style-batch-blank-range",
|
||||
cmd: newRangeBatchSetStyleCmd,
|
||||
args: func(t *testing.T) []string {
|
||||
p := writeBatch(t, "blank-range.json", `[{"sheetId":"Sheet1","range":" ","fontWeight":"bold"}]`)
|
||||
return []string{"--node", "NODE_ID", "--batch", p}
|
||||
},
|
||||
want: "缺少 sheetId 或 range",
|
||||
},
|
||||
{
|
||||
name: "batch-clear-ranges-blank-sheet",
|
||||
cmd: rangeBatchClearCoverageCommand,
|
||||
args: func(*testing.T) []string {
|
||||
return []string{"--node", "NODE_ID", "--ranges", `[" !A1:B2"]`}
|
||||
},
|
||||
want: "必须包含工作表前缀",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &sheetStyleDryRunCaller{format: "json"}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
|
||||
cmd := tc.cmd()
|
||||
cmd.SetArgs(tc.args(t))
|
||||
cmd.SilenceUsage, cmd.SilenceErrors = true, true
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want contains %q", err, tc.want)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 判空看修剪后的值,但下发仍用原值:sheetId 可以是工作表**名**,名字允许带首尾
|
||||
// 空格,替用户修剪会指向另一张表。--batch 是需要精确名字时的入口,必须原样透传。
|
||||
func TestBatchStyleSheetIDIsSentVerbatimNotTrimmed(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "padded.json")
|
||||
if err := os.WriteFile(p, []byte(`[{"sheetId":" Sheet1 ","range":" A1:B2 ","fontWeight":"bold"}]`), 0o600); err != nil {
|
||||
t.Fatalf("write batch fixture: %v", err)
|
||||
}
|
||||
|
||||
ops, err := buildBatchStyleOpsFromFile(p)
|
||||
if err != nil {
|
||||
t.Fatalf("buildBatchStyleOpsFromFile = %v, want accepted", err)
|
||||
}
|
||||
if len(ops) != 1 {
|
||||
t.Fatalf("operations = %d, want 1", len(ops))
|
||||
}
|
||||
op, _ := ops[0].(map[string]any)
|
||||
input, _ := op["input"].(map[string]any)
|
||||
if input["sheetId"] != " Sheet1 " {
|
||||
t.Fatalf("sheetId = %#v, want %q (verbatim)", input["sheetId"], " Sheet1 ")
|
||||
}
|
||||
if input["rangeAddress"] != " A1:B2 " {
|
||||
t.Fatalf("rangeAddress = %#v, want %q (verbatim)", input["rangeAddress"], " A1:B2 ")
|
||||
}
|
||||
}
|
||||
@@ -12,17 +12,16 @@ func TestCrossPlatformCoverageSheetStyleRangeAndFontCoverage(t *testing.T) {
|
||||
for _, raw := range []string{"", "Sheet1!A1:B2", "B2:A1", "A1", "A", "1", "A0", "A-1", "A1:bad"} {
|
||||
_, _, _ = parseA1Range(raw)
|
||||
}
|
||||
_ = fillStringMatrix(2, 2, "x")
|
||||
_ = fillIntMatrix(2, 2, 1)
|
||||
for _, spec := range []*styleSpec{
|
||||
{FontSize: 1, FontSizesJSON: `[[1]]`},
|
||||
{FontSize: -1},
|
||||
{FontSize: 12},
|
||||
{FontSizesJSON: `{`},
|
||||
{FontSizesJSON: `[[1,2]]`},
|
||||
{FontSizesJSON: `[[1]]`},
|
||||
for _, tc := range []struct {
|
||||
scalar int
|
||||
jsonStr string
|
||||
}{
|
||||
{1, `[[1]]`}, {-1, ""}, {12, ""}, {0, `{`}, {0, `[[1,2]]`}, {0, `[[1]]`}, {0, ""},
|
||||
} {
|
||||
_ = applyFontSize(spec, 1, 1, map[string]any{})
|
||||
if get, err := intGrid(tc.scalar, tc.jsonStr, "font-size", 1, 1); err == nil && get != nil {
|
||||
_, _ = get(0, 0)
|
||||
_, _ = get(9, 9)
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
scalar, raw string
|
||||
@@ -32,7 +31,17 @@ func TestCrossPlatformCoverageSheetStyleRangeAndFontCoverage(t *testing.T) {
|
||||
{"", "", nil}, {"", `{`, nil}, {"", `[["x","y"]]`, nil},
|
||||
{"", `[[""]]`, hAlignEnum}, {"", `[["bad"]]`, hAlignEnum}, {"", `[["left"]]`, hAlignEnum},
|
||||
} {
|
||||
_ = apply2DString(tc.scalar, tc.raw, 1, 1, "align", "alignments", tc.enum, map[string]any{})
|
||||
if get, err := strGrid(tc.scalar, tc.raw, "align", tc.enum, 1, 1); err == nil && get != nil {
|
||||
_, _ = get(0, 0)
|
||||
_, _ = get(9, 9)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"", `{`, `{}`, `{"nope":{"style":"solid"}}`, `{"top":{}}`,
|
||||
`{"top":{"style":"bogus"}}`, `{"top":{"style":"solid"}}`, `{"top":{"style":"solid","color":"#000"}}`} {
|
||||
_, _ = parseBorderStyles(raw)
|
||||
}
|
||||
for _, raw := range []string{"Sheet1!A1:B2", "A1:B2", "Sheet1!", "!A1"} {
|
||||
_, _, _ = splitSheetPrefixedRange(raw, 0)
|
||||
}
|
||||
_ = maxColLenStr([][]string{{}, {"a", "b"}})
|
||||
_ = maxColLen2D([][]int{{}, {1, 2}})
|
||||
@@ -46,8 +55,16 @@ func TestCrossPlatformCoverageSheetStyleRangeAndFontCoverage(t *testing.T) {
|
||||
{styleSpec{}, 0, 1}, {styleSpec{}, 1001, 1}, {styleSpec{}, 1000, 31},
|
||||
{styleSpec{WordWrap: "invalid"}, 1, 1}, {styleSpec{WordWrap: "clip"}, 1, 1},
|
||||
{styleSpec{NumberFormat: "General"}, 1, 1}, {styleSpec{}, 1, 1},
|
||||
{styleSpec{FontStyle: "bogus"}, 1, 1}, {styleSpec{FontStyle: "italic"}, 1, 1},
|
||||
{styleSpec{FontLine: "bogus"}, 1, 1}, {styleSpec{FontLine: "underline"}, 1, 1},
|
||||
{styleSpec{FontLine: "line-through"}, 1, 1}, {styleSpec{FontLine: "none"}, 1, 1},
|
||||
{styleSpec{FontFamily: "Arial"}, 1, 1},
|
||||
{styleSpec{BorderStylesJSON: `{"top":{"style":"solid"}}`}, 1, 1},
|
||||
{styleSpec{BorderStylesJSON: `{`}, 1, 1},
|
||||
{styleSpec{BgColor: "#FFF", FontSize: 12, HAlign: "center", VAlign: "top",
|
||||
FontColor: "#000", FontWeight: "bold"}, 2, 2},
|
||||
} {
|
||||
_ = applyStyleSpec(&tc.spec, tc.rows, tc.cols, map[string]any{})
|
||||
_, _ = buildStyleCells(&tc.spec, tc.rows, tc.cols)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,16 +62,22 @@ func TestCrossPlatformCoverageRangeBatchSetStyleDryRunNeverCallsRemote(t *testin
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("JSON dry-run stdout must be one document: %v\n%s", err, preview)
|
||||
}
|
||||
results, _ := payload["results"].([]any)
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("JSON dry-run results = %#v", payload["results"])
|
||||
// batch-set-style 现在组装为一次 batch_update 原子提交,
|
||||
// dry-run 因此是单条标准记录,而不是本地循环产生的 results 数组。
|
||||
if payload["tool"] != "batch_update" {
|
||||
t.Fatalf("JSON dry-run tool = %#v, want batch_update", payload["tool"])
|
||||
}
|
||||
entry, _ := results[0].(map[string]any)
|
||||
if entry["dryRun"] != true || entry["tool"] != "update_range" {
|
||||
t.Fatalf("JSON dry-run result = %#v", entry)
|
||||
args, _ := payload["arguments"].(map[string]any)
|
||||
ops, _ := args["operations"].([]any)
|
||||
if len(ops) != 1 {
|
||||
t.Fatalf("JSON dry-run operations = %#v, want 1 item", args["operations"])
|
||||
}
|
||||
op, _ := ops[0].(map[string]any)
|
||||
if op["toolName"] != "set_cell_range" {
|
||||
t.Fatalf("JSON dry-run operation toolName = %#v", op["toolName"])
|
||||
}
|
||||
} else {
|
||||
for _, want := range []string{"Tool:", "update_range", "Arguments:"} {
|
||||
for _, want := range []string{"Tool:", "batch_update", "set_cell_range", "Arguments:"} {
|
||||
if !strings.Contains(preview, want) {
|
||||
t.Fatalf("dry-run preview missing %q:\n%s", want, preview)
|
||||
}
|
||||
@@ -104,3 +110,104 @@ func TestRangeBatchSetStylePropagatesJSONWriteFailure(t *testing.T) {
|
||||
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 边框每条边只认 style / color。写错的键(colour)或类型不对的 color 此前被静默
|
||||
// 忽略:命令报成功,却画出一条没有颜色的边框。这与 --sheets / --styles 拒绝未知键
|
||||
// 是同一条不变量,三条使用路径(单区域 flag、batch、create-with-data --styles)共用
|
||||
// parseBorderStyles,所以核心在这里一次验完,再逐路径确认拒绝发生在发请求之前。
|
||||
func TestParseBorderStylesRejectsUnknownAndMistypedEdgeFields(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
json string
|
||||
want string
|
||||
}{
|
||||
{"near-miss-color-key", `{"top":{"style":"solid","colour":"#f00"}}`, `未知字段 "colour"`},
|
||||
{"unknown-key", `{"top":{"style":"solid","width":2}}`, `未知字段 "width"`},
|
||||
{"camel-case-alias-hinted", `{"top":{"style":"solid","Color":"#f00"}}`, `应为 "color"`},
|
||||
{"color-not-string", `{"top":{"style":"solid","color":123}}`, "color 必须是字符串,实际是 float64"},
|
||||
{"color-empty-string", `{"top":{"style":"solid","color":""}}`, "color 不能为空字符串"},
|
||||
{"style-not-string", `{"top":{"style":123}}`, "style 必须是字符串,实际是 float64"},
|
||||
{"style-empty-string", `{"top":{"style":""}}`, "缺少 style"},
|
||||
{"style-missing", `{"top":{"color":"#f00"}}`, "缺少 style"},
|
||||
{"style-null", `{"top":{"style":null}}`, "缺少 style"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := parseBorderStyles(tc.json)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want contains %q", err, tc.want)
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "--border-styles-json.top") {
|
||||
t.Fatalf("err = %v, want the edge name in the message", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 合法形态不能被误拒:只给 style、给 style+color、显式 null color(等同省略)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
json string
|
||||
wantColor any
|
||||
}{
|
||||
{"style-only", `{"top":{"style":"solid"}}`, nil},
|
||||
{"style-and-color", `{"top":{"style":"solid","color":"#FF0000"}}`, "#FF0000"},
|
||||
{"explicit-null-color", `{"top":{"style":"solid","color":null}}`, nil},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := parseBorderStyles(tc.json)
|
||||
if err != nil {
|
||||
t.Fatalf("parseBorderStyles(%s) = %v, want accepted", tc.json, err)
|
||||
}
|
||||
edge, _ := out["top"].(map[string]any)
|
||||
if edge["style"] != "solid" {
|
||||
t.Fatalf("style = %#v, want solid", edge["style"])
|
||||
}
|
||||
if edge["color"] != tc.wantColor {
|
||||
t.Fatalf("color = %#v, want %#v", edge["color"], tc.wantColor)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 单区域与 batch 两条路径:非法边框配置必须在组装/下发之前失败。
|
||||
func TestBorderStyleRejectionHappensBeforeAnyRemoteCall(t *testing.T) {
|
||||
const badBorder = `{"top":{"style":"solid","colour":"#f00"}}`
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
batchPath := filepath.Join(t.TempDir(), "styles.json")
|
||||
fixture := `[{"sheetId":"Sheet1","range":"A1:B2","borderStylesJson":"{\"top\":{\"style\":\"solid\",\"colour\":\"#f00\"}}"}]`
|
||||
if err := os.WriteFile(batchPath, []byte(fixture), 0o600); err != nil {
|
||||
t.Fatalf("write batch fixture: %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"single-range", []string{"--node", "NODE_ID", "--sheet-id", "S", "--range", "A1:B2", "--border-styles-json", badBorder}},
|
||||
{"batch-ranges", []string{"--node", "NODE_ID", "--ranges", `["Sheet1!A1:B2"]`, "--border-styles-json", badBorder}},
|
||||
{"batch-file", []string{"--node", "NODE_ID", "--batch", batchPath}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &sheetStyleDryRunCaller{format: "json"}
|
||||
InitDeps(caller)
|
||||
var output bytes.Buffer
|
||||
deps.Out.w = &output
|
||||
deps.Out.errW = &output
|
||||
|
||||
cmd := newRangeSetStyleCmd()
|
||||
if tc.name != "single-range" {
|
||||
cmd = newRangeBatchSetStyleCmd()
|
||||
}
|
||||
cmd.SetArgs(tc.args)
|
||||
cmd.SilenceUsage, cmd.SilenceErrors = true, true
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), `未知字段 "colour"`) {
|
||||
t.Fatalf("err = %v, want the unknown border field rejected", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("remote CallTool count = %d, want 0", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSheetAndMinutesSmallRemainingBranches(t *testing.T) {
|
||||
if err := applyStyleSpec(&styleSpec{FontColorsJSON: "{"}, 1, 1, map[string]any{}); err == nil {
|
||||
if _, err := buildStyleCells(&styleSpec{FontColorsJSON: "{"}, 1, 1); err == nil {
|
||||
t.Fatal("invalid font colors JSON returned nil")
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,10 @@ func CompareAll(current Snapshot, references map[string]Snapshot) Report {
|
||||
// become required; an existing path may not gain a new required flag,
|
||||
// - new commands and flags are allowed.
|
||||
//
|
||||
// The single exception to the type rule is an individually reviewed migration
|
||||
// listed in reviewedFlagTypeChanges, and only when nothing else about the flag
|
||||
// changed. See reviewed.go.
|
||||
//
|
||||
// Comparing the effective local + inherited set is intentional. It catches a
|
||||
// persistent flag whose scope is accidentally narrowed to its declaring
|
||||
// command, while allowing a local flag to move to an ancestor without breaking
|
||||
@@ -183,13 +187,20 @@ func compareEffectiveFlags(result *Comparison, acceptedPath string, oldCommand,
|
||||
continue
|
||||
}
|
||||
if oldFlag.Type != newFlag.Type {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "flag_type_changed",
|
||||
Path: acceptedPath,
|
||||
Flag: name,
|
||||
Before: oldFlag.Type,
|
||||
After: newFlag.Type,
|
||||
})
|
||||
// Resolve the exemption against the canonical path, not acceptedPath.
|
||||
// An aliased command reaches this function once per accepted spelling,
|
||||
// so keying on acceptedPath would let every alias spelling re-report a
|
||||
// reviewed migration.
|
||||
if !reviewedFlagTypeChange(newCommand.Path, name, oldFlag.Type, newFlag.Type) ||
|
||||
flagContractOtherwiseChanged(oldFlag, newFlag) {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "flag_type_changed",
|
||||
Path: acceptedPath,
|
||||
Flag: name,
|
||||
Before: oldFlag.Type,
|
||||
After: newFlag.Type,
|
||||
})
|
||||
}
|
||||
}
|
||||
if !oldFlag.Required && newFlag.Required {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
@@ -218,6 +229,15 @@ func compareEffectiveFlags(result *Comparison, acceptedPath string, oldCommand,
|
||||
After: newFlag.NoOpt,
|
||||
})
|
||||
}
|
||||
if oldFlag.AliasOf != "" && newFlag.AliasOf != oldFlag.AliasOf {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "flag_alias_target_changed",
|
||||
Path: acceptedPath,
|
||||
Flag: name,
|
||||
Before: oldFlag.AliasOf,
|
||||
After: newFlag.AliasOf,
|
||||
})
|
||||
}
|
||||
if !oldFlag.Hidden && newFlag.Hidden {
|
||||
result.Blocking = append(result.Blocking, Change{
|
||||
Kind: "flag_became_hidden",
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
|
||||
package interfacesnapshot
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageCompareAdmissionPolicy(t *testing.T) {
|
||||
base := testSnapshot(
|
||||
@@ -247,6 +251,40 @@ func TestCrossPlatformCoverageCompareBlocksCallableMetadataRegressions(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareBlocksFlagAliasTargetRegression(t *testing.T) {
|
||||
baseLegacy := testFlag("legacy-id", "string", false)
|
||||
baseLegacy.AliasOf = "message-id"
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws send", baseLegacy, testFlag("message-id", "string", false)),
|
||||
)
|
||||
|
||||
for _, target := range []string{"", "other-id"} {
|
||||
t.Run("target_"+target, func(t *testing.T) {
|
||||
currentLegacy := baseLegacy
|
||||
currentLegacy.AliasOf = target
|
||||
current := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand(
|
||||
"dws send",
|
||||
currentLegacy,
|
||||
testFlag("message-id", "string", false),
|
||||
testFlag("other-id", "string", false),
|
||||
),
|
||||
)
|
||||
comparison := Compare(current, base, "base")
|
||||
if comparison.Compatible || !hasFlagChange(
|
||||
comparison.Blocking,
|
||||
"flag_alias_target_changed",
|
||||
"dws send",
|
||||
"legacy-id",
|
||||
) {
|
||||
t.Fatalf("alias target regression was not blocked: %#v", comparison)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareUsesEffectiveFlagsAtEveryCommandPath(t *testing.T) {
|
||||
base := testSnapshot(
|
||||
testCommand("dws", testFlag("profile", "string", false)),
|
||||
@@ -310,6 +348,511 @@ func TestCrossPlatformCoverageCompareAllRequiresBothReferencesToPass(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsAuthorizesOnlyTheExactTransition(t *testing.T) {
|
||||
before := testFlagMigrationSnapshot(false, true)
|
||||
authority := testFlagMigrationManifest(FlagMigrationPending)
|
||||
candidate := testFlagMigrationManifest(FlagMigrationConsumed)
|
||||
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
testFlagMigrationSnapshot(true, true),
|
||||
map[string]Snapshot{
|
||||
"merge-base": before,
|
||||
"stable": before,
|
||||
},
|
||||
authority,
|
||||
candidate,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("exact base-owned migration was rejected: %v", err)
|
||||
}
|
||||
if !report.Compatible {
|
||||
t.Fatalf("exact migration remained incompatible: %#v", report.Comparisons)
|
||||
}
|
||||
for _, comparison := range report.Comparisons {
|
||||
if len(comparison.Blocking) != 0 {
|
||||
t.Fatalf("reference %q retained blocking changes: %#v", comparison.Reference, comparison.Blocking)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsComposesWithReviewedTypeChange(t *testing.T) {
|
||||
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
|
||||
|
||||
before := testFlagMigrationSnapshot(false, true)
|
||||
before.Commands = append(before.Commands, testCommand("dws search", testFlag("query", "string", false)))
|
||||
after := testFlagMigrationSnapshot(true, true)
|
||||
after.Commands = append(after.Commands, testCommand("dws search", testFlag("query", "int", false)))
|
||||
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{
|
||||
"merge-base": before,
|
||||
"stable": before,
|
||||
},
|
||||
testFlagMigrationManifest(FlagMigrationPending),
|
||||
testFlagMigrationManifest(FlagMigrationConsumed),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("composed reviewed migrations were rejected: %v", err)
|
||||
}
|
||||
if !report.Compatible {
|
||||
t.Fatalf("composed reviewed migrations remained incompatible: %#v", report.Comparisons)
|
||||
}
|
||||
}
|
||||
|
||||
// registerReviewedFixture puts a fixture command in the reviewed table for the
|
||||
// duration of one test, so the behaviour tests exercise the real lookup instead
|
||||
// of depending on whichever production entries happen to exist.
|
||||
func registerReviewedFixture(t *testing.T, change flagTypeChange) {
|
||||
t.Helper()
|
||||
if _, exists := reviewedFlagTypeChanges[change]; exists {
|
||||
t.Fatalf("fixture %+v collides with a production entry", change)
|
||||
}
|
||||
reviewedFlagTypeChanges[change] = struct{}{}
|
||||
t.Cleanup(func() { delete(reviewedFlagTypeChanges, change) })
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAcceptsReviewedFlagTypeChange(t *testing.T) {
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "string", false)),
|
||||
)
|
||||
current := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "int", false)),
|
||||
)
|
||||
|
||||
// Without an entry the migration is still a blocking change.
|
||||
if comparison := Compare(current, base, "base"); !hasChangeKind(comparison.Blocking, "flag_type_changed") {
|
||||
t.Fatalf("an unreviewed type change should block: %#v", comparison.Blocking)
|
||||
}
|
||||
|
||||
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
|
||||
if comparison := Compare(current, base, "base"); !comparison.Compatible {
|
||||
t.Fatalf("a reviewed string->int migration should pass: %#v", comparison.Blocking)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeIsDirectionSensitive(t *testing.T) {
|
||||
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
|
||||
|
||||
// The reverse migration shares command and flag but not direction, so the
|
||||
// string->int entry must not admit it.
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "int", false)),
|
||||
)
|
||||
current := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "string", false)),
|
||||
)
|
||||
if comparison := Compare(current, base, "base"); comparison.Compatible {
|
||||
t.Fatal("int->string must not be admitted by a string->int entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeRejectsOtherCommandsAndFlags(t *testing.T) {
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "string", false)),
|
||||
)
|
||||
current := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", testFlag("query", "int", false)),
|
||||
)
|
||||
|
||||
// Same flag and direction, different command.
|
||||
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws status", Flag: "query", From: "string", To: "int"})
|
||||
if comparison := Compare(current, base, "base"); comparison.Compatible {
|
||||
t.Fatal("an entry for another command must not admit this migration")
|
||||
}
|
||||
|
||||
// Same command and direction, different flag.
|
||||
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "elsewhere", From: "string", To: "int"})
|
||||
if comparison := Compare(current, base, "base"); comparison.Compatible {
|
||||
t.Fatal("an entry for another flag must not admit this migration")
|
||||
}
|
||||
}
|
||||
|
||||
// A reviewed migration is only accepted when the rest of the flag's contract
|
||||
// held still. Each case bundles one unrelated regression with the reviewed type
|
||||
// change and requires the type failure to reappear, which also anchors
|
||||
// flagContractOtherwiseChanged against the checks it mirrors: a condition that
|
||||
// drifts out of sync fails here instead of silently widening the exemption.
|
||||
func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeRejectsBundledRegression(t *testing.T) {
|
||||
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
base Flag
|
||||
next Flag
|
||||
kind string
|
||||
}{
|
||||
{
|
||||
name: "became required",
|
||||
base: Flag{Name: "query", Type: "string"},
|
||||
next: Flag{Name: "query", Type: "int", Required: true},
|
||||
kind: "flag_became_required",
|
||||
},
|
||||
{
|
||||
name: "lost shorthand",
|
||||
base: Flag{Name: "query", Type: "string", Shorthand: "q"},
|
||||
next: Flag{Name: "query", Type: "int"},
|
||||
kind: "flag_shorthand_changed",
|
||||
},
|
||||
{
|
||||
name: "changed no-opt",
|
||||
base: Flag{Name: "query", Type: "string", NoOpt: "all"},
|
||||
next: Flag{Name: "query", Type: "int", NoOpt: "1"},
|
||||
kind: "flag_no_opt_changed",
|
||||
},
|
||||
{
|
||||
name: "became hidden",
|
||||
base: Flag{Name: "query", Type: "string"},
|
||||
next: Flag{Name: "query", Type: "int", Hidden: true},
|
||||
kind: "flag_became_hidden",
|
||||
},
|
||||
{
|
||||
name: "changed alias target",
|
||||
base: Flag{Name: "query", Type: "string", AliasOf: "old-query"},
|
||||
next: Flag{Name: "query", Type: "int", AliasOf: "new-query"},
|
||||
kind: "flag_alias_target_changed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
base := testSnapshot(testCommand("dws"), testCommand("dws search", test.base))
|
||||
current := testSnapshot(testCommand("dws"), testCommand("dws search", test.next))
|
||||
comparison := Compare(current, base, "base")
|
||||
if !hasChangeKind(comparison.Blocking, test.kind) {
|
||||
t.Fatalf("blocking=%#v, want kind %q", comparison.Blocking, test.kind)
|
||||
}
|
||||
if !hasChangeKind(comparison.Blocking, "flag_type_changed") {
|
||||
t.Fatalf("a bundled regression must re-report the type change: %#v", comparison.Blocking)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The exemption is resolved against the canonical Command.Path, never the
|
||||
// alias-expanded accepted path. An aliased command enters compareEffectiveFlags
|
||||
// once per accepted spelling, so keying on the accepted path would let every
|
||||
// alias spelling re-report a reviewed migration.
|
||||
func TestCrossPlatformCoverageCompareResolvesReviewedFlagTypeChangeByCanonicalPath(t *testing.T) {
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommandWithAliases("dws search", []string{"find"}, testFlag("query", "string", false)),
|
||||
)
|
||||
current := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommandWithAliases("dws search", []string{"find"}, testFlag("query", "int", false)),
|
||||
)
|
||||
|
||||
// The canonical path admits the migration for every accepted spelling.
|
||||
canonical := flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"}
|
||||
registerReviewedFixture(t, canonical)
|
||||
if comparison := Compare(current, base, "base"); !comparison.Compatible {
|
||||
t.Fatalf("canonical path should admit the migration on every alias: %#v", comparison.Blocking)
|
||||
}
|
||||
delete(reviewedFlagTypeChanges, canonical)
|
||||
|
||||
// An entry spelled with the alias path matches nothing, because the lookup
|
||||
// never sees the accepted spelling.
|
||||
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws find", Flag: "query", From: "string", To: "int"})
|
||||
if comparison := Compare(current, base, "base"); comparison.Compatible {
|
||||
t.Fatal("an alias-spelled entry must not admit the migration")
|
||||
}
|
||||
}
|
||||
|
||||
// A string -> int migration always changes the recorded default ("" to "0").
|
||||
// Defaults are not part of what this gate enforces, and folding them into
|
||||
// flagContractOtherwiseChanged would make every table entry dead on arrival.
|
||||
func TestCrossPlatformCoverageCompareReviewedFlagTypeChangeToleratesDefaultDrift(t *testing.T) {
|
||||
registerReviewedFixture(t, flagTypeChange{CommandPath: "dws search", Flag: "query", From: "string", To: "int"})
|
||||
|
||||
base := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", Flag{Name: "query", Type: "string", Default: ""}),
|
||||
)
|
||||
current := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws search", Flag{Name: "query", Type: "int", Default: "0"}),
|
||||
)
|
||||
if comparison := Compare(current, base, "base"); !comparison.Compatible {
|
||||
t.Fatalf("default drift must not defeat a reviewed migration: %#v", comparison.Blocking)
|
||||
}
|
||||
}
|
||||
|
||||
// reviewedEntryPattern matches one table entry as gofmt writes it on a single
|
||||
// line. It is used to read the sibling copy of the table out of the policy
|
||||
// helper, which cannot be imported: that directory is copied into a worktree at
|
||||
// a historical revision and built there, so it must not depend on this package.
|
||||
var reviewedEntryPattern = regexp.MustCompile(
|
||||
`\{CommandPath:\s*"([^"]*)",\s*Flag:\s*"([^"]*)",\s*From:\s*"([^"]*)",\s*To:\s*"([^"]*)"\}`)
|
||||
|
||||
const interfaceBaselineReviewedPath = "../../scripts/policy/interface-baseline/reviewed.go"
|
||||
|
||||
// The modern authority and legacy smoke helper keep mirrored tables so local
|
||||
// smoke checks cannot disagree with the authoritative comparison.
|
||||
func TestCrossPlatformCoverageReviewedFlagTypeTableMatchesInterfaceBaseline(t *testing.T) {
|
||||
source, err := os.ReadFile(interfaceBaselineReviewedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("读取 %s 失败: %v", interfaceBaselineReviewedPath, err)
|
||||
}
|
||||
|
||||
sibling := make(map[flagTypeChange]struct{})
|
||||
for _, match := range reviewedEntryPattern.FindAllStringSubmatch(string(source), -1) {
|
||||
sibling[flagTypeChange{CommandPath: match[1], Flag: match[2], From: match[3], To: match[4]}] = struct{}{}
|
||||
}
|
||||
|
||||
// Without this the whole guard degrades into comparing two empty sets the
|
||||
// moment the pattern stops matching, which is exactly how a duplicated
|
||||
// allowlist goes stale unnoticed.
|
||||
if len(sibling) == 0 {
|
||||
t.Fatalf("未能从 %s 提取到任何条目:正则或表的书写形态已漂移,这条守卫已失效", interfaceBaselineReviewedPath)
|
||||
}
|
||||
|
||||
for change := range reviewedFlagTypeChanges {
|
||||
if _, ok := sibling[change]; !ok {
|
||||
t.Errorf("%+v 只存在于 interfacesnapshot,缺在 %s", change, interfaceBaselineReviewedPath)
|
||||
}
|
||||
}
|
||||
for change := range sibling {
|
||||
if _, ok := reviewedFlagTypeChanges[change]; !ok {
|
||||
t.Errorf("%+v 只存在于 %s,缺在 interfacesnapshot", change, interfaceBaselineReviewedPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRejectsInvalidLifecycleTransitions(t *testing.T) {
|
||||
empty := testEmptyFlagMigrationManifest()
|
||||
pending := testFlagMigrationManifest(FlagMigrationPending)
|
||||
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
|
||||
before := testFlagMigrationSnapshot(false, true)
|
||||
after := testFlagMigrationSnapshot(true, true)
|
||||
|
||||
partial := testFlagMigrationSnapshot(false, true)
|
||||
partial.Commands[1].LocalFlags[0].Hidden = true
|
||||
partial.Commands[1].LocalFlags[0].Required = false
|
||||
partial.Commands[1].LocalFlags[0].AliasOf = "message-id"
|
||||
|
||||
typeDrift := testFlagMigrationSnapshot(true, true)
|
||||
typeDrift.Commands[1].LocalFlags[0].Type = "stringSlice"
|
||||
noOptDrift := testFlagMigrationSnapshot(true, true)
|
||||
noOptDrift.Commands[1].LocalFlags[0].NoOpt = ""
|
||||
shorthandDrift := testFlagMigrationSnapshot(true, true)
|
||||
shorthandDrift.Commands[1].LocalFlags[0].Shorthand = "x"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
current Snapshot
|
||||
authority FlagMigrationManifest
|
||||
candidate FlagMigrationManifest
|
||||
}{
|
||||
{
|
||||
name: "candidate-added pending record cannot approve its own surface change",
|
||||
current: after,
|
||||
authority: empty,
|
||||
candidate: pending,
|
||||
},
|
||||
{
|
||||
name: "candidate-added consumed record cannot approve its own surface change",
|
||||
current: after,
|
||||
authority: empty,
|
||||
candidate: consumed,
|
||||
},
|
||||
{
|
||||
name: "partial migration is rejected",
|
||||
current: partial,
|
||||
authority: pending,
|
||||
candidate: consumed,
|
||||
},
|
||||
{
|
||||
name: "pending record cannot be falsely consumed before the surface changes",
|
||||
current: before,
|
||||
authority: pending,
|
||||
candidate: consumed,
|
||||
},
|
||||
{
|
||||
name: "completed surface change must consume its pending record",
|
||||
current: after,
|
||||
authority: pending,
|
||||
candidate: pending,
|
||||
},
|
||||
{
|
||||
name: "legacy type drift is outside the approval",
|
||||
current: typeDrift,
|
||||
authority: pending,
|
||||
candidate: consumed,
|
||||
},
|
||||
{
|
||||
name: "legacy no-opt drift is outside the approval",
|
||||
current: noOptDrift,
|
||||
authority: pending,
|
||||
candidate: consumed,
|
||||
},
|
||||
{
|
||||
name: "legacy shorthand drift is outside the approval",
|
||||
current: shorthandDrift,
|
||||
authority: pending,
|
||||
candidate: consumed,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
test.current,
|
||||
map[string]Snapshot{
|
||||
"merge-base": before,
|
||||
"stable": before,
|
||||
},
|
||||
test.authority,
|
||||
test.candidate,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("invalid transition passed: %#v", report)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsAllowsApprovalCreationBeforeSurfaceChange(t *testing.T) {
|
||||
before := testFlagMigrationSnapshot(false, true)
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
before,
|
||||
map[string]Snapshot{
|
||||
"merge-base": before,
|
||||
"stable": before,
|
||||
},
|
||||
testEmptyFlagMigrationManifest(),
|
||||
testFlagMigrationManifest(FlagMigrationPending),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("pending approval creation was rejected before the surface changed: %v", err)
|
||||
}
|
||||
if !report.Compatible {
|
||||
t.Fatalf("pending approval creation changed compatibility: %#v", report.Comparisons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsKeepsUnrelatedBreakageBlocking(t *testing.T) {
|
||||
before := testFlagMigrationSnapshot(false, true)
|
||||
pending := testFlagMigrationManifest(FlagMigrationPending)
|
||||
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*Snapshot)
|
||||
kind string
|
||||
flag string
|
||||
}{
|
||||
{
|
||||
name: "unrelated flag removal",
|
||||
mutate: func(snapshot *Snapshot) {
|
||||
snapshot.Commands[1].LocalFlags = snapshot.Commands[1].LocalFlags[:2]
|
||||
},
|
||||
kind: "flag_removed",
|
||||
flag: "format",
|
||||
},
|
||||
{
|
||||
name: "unrelated flag type change",
|
||||
mutate: func(snapshot *Snapshot) {
|
||||
snapshot.Commands[1].LocalFlags[2].Type = "stringSlice"
|
||||
},
|
||||
kind: "flag_type_changed",
|
||||
flag: "format",
|
||||
},
|
||||
{
|
||||
name: "unrelated flag no-opt change",
|
||||
mutate: func(snapshot *Snapshot) {
|
||||
snapshot.Commands[1].LocalFlags[2].NoOpt = ""
|
||||
},
|
||||
kind: "flag_no_opt_changed",
|
||||
flag: "format",
|
||||
},
|
||||
{
|
||||
name: "unrelated flag shorthand change",
|
||||
mutate: func(snapshot *Snapshot) {
|
||||
snapshot.Commands[1].LocalFlags[2].Shorthand = ""
|
||||
},
|
||||
kind: "flag_shorthand_changed",
|
||||
flag: "format",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
current := testFlagMigrationSnapshot(true, true)
|
||||
test.mutate(¤t)
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
current,
|
||||
map[string]Snapshot{
|
||||
"merge-base": before,
|
||||
"stable": before,
|
||||
},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("valid migration with unrelated breakage returned a lifecycle error: %v", err)
|
||||
}
|
||||
if report.Compatible {
|
||||
t.Fatalf("unrelated breakage was hidden by migration approval: %#v", report)
|
||||
}
|
||||
for _, comparison := range report.Comparisons {
|
||||
if !hasFlagChange(comparison.Blocking, test.kind, "dws chat send", test.flag) {
|
||||
t.Fatalf("reference %q blocking=%#v, want %s for --%s", comparison.Reference, comparison.Blocking, test.kind, test.flag)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRetainsConsumedReceiptUntilStableCatchesUp(t *testing.T) {
|
||||
before := testFlagMigrationSnapshot(false, true)
|
||||
after := testFlagMigrationSnapshot(true, true)
|
||||
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
|
||||
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{
|
||||
"merge-base": after,
|
||||
"stable": before,
|
||||
},
|
||||
consumed,
|
||||
consumed,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("consumed receipt was rejected while stable still needed it: %v", err)
|
||||
}
|
||||
if !report.Compatible {
|
||||
t.Fatalf("stable comparison did not honor the consumed receipt: %#v", report.Comparisons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresCleanupAfterAllReferencesCatchUp(t *testing.T) {
|
||||
after := testFlagMigrationSnapshot(true, true)
|
||||
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
|
||||
references := map[string]Snapshot{
|
||||
"merge-base": after,
|
||||
"stable": after,
|
||||
}
|
||||
|
||||
if report, err := CompareAllWithFlagMigrations(after, references, consumed, consumed); err == nil {
|
||||
t.Fatalf("stale consumed receipt was accepted after every reference caught up: %#v", report)
|
||||
}
|
||||
|
||||
report, err := CompareAllWithFlagMigrations(after, references, consumed, testEmptyFlagMigrationManifest())
|
||||
if err != nil {
|
||||
t.Fatalf("cleanup of stale consumed receipt was rejected: %v", err)
|
||||
}
|
||||
if !report.Compatible {
|
||||
t.Fatalf("cleanup-only candidate was incompatible: %#v", report.Comparisons)
|
||||
}
|
||||
}
|
||||
|
||||
func testSnapshot(commands ...Command) Snapshot {
|
||||
return Snapshot{
|
||||
SchemaVersion: SchemaVersion,
|
||||
@@ -348,6 +891,92 @@ func testFlag(name, flagType string, required bool) Flag {
|
||||
return Flag{Name: name, Type: flagType, Required: required}
|
||||
}
|
||||
|
||||
func testFlagMigrationSnapshot(after, includeUnrelated bool) Snapshot {
|
||||
legacy := Flag{
|
||||
Name: "legacy-id",
|
||||
Shorthand: "l",
|
||||
Type: "string",
|
||||
NoOpt: "auto",
|
||||
Required: true,
|
||||
}
|
||||
flags := []Flag{legacy}
|
||||
if after {
|
||||
legacy.Required = false
|
||||
legacy.Hidden = true
|
||||
legacy.AliasOf = "message-id"
|
||||
flags = []Flag{
|
||||
legacy,
|
||||
{
|
||||
Name: "message-id",
|
||||
Type: "string",
|
||||
Required: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
if includeUnrelated {
|
||||
flags = append(flags, Flag{
|
||||
Name: "format",
|
||||
Shorthand: "f",
|
||||
Type: "string",
|
||||
NoOpt: "json",
|
||||
})
|
||||
}
|
||||
return testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommand("dws chat send", flags...),
|
||||
)
|
||||
}
|
||||
|
||||
func testFlagMigrationManifest(state string) FlagMigrationManifest {
|
||||
return FlagMigrationManifest{
|
||||
Version: FlagMigrationManifestVersion,
|
||||
Migrations: []FlagMigration{
|
||||
{
|
||||
Command: "dws chat send",
|
||||
Legacy: FlagMigrationSide{
|
||||
Name: "legacy-id",
|
||||
Before: FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
Required: true,
|
||||
Shorthand: "l",
|
||||
NoOpt: "auto",
|
||||
Scope: "local",
|
||||
},
|
||||
After: FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
Hidden: true,
|
||||
Shorthand: "l",
|
||||
NoOpt: "auto",
|
||||
Scope: "local",
|
||||
AliasOf: "message-id",
|
||||
},
|
||||
},
|
||||
Canonical: FlagMigrationSide{
|
||||
Name: "message-id",
|
||||
Before: FlagMigrationState{},
|
||||
After: FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
Required: true,
|
||||
Scope: "local",
|
||||
},
|
||||
},
|
||||
State: state,
|
||||
Reason: "rename the public flag while preserving the executable legacy alias",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testEmptyFlagMigrationManifest() FlagMigrationManifest {
|
||||
return FlagMigrationManifest{
|
||||
Version: FlagMigrationManifestVersion,
|
||||
Migrations: []FlagMigration{},
|
||||
}
|
||||
}
|
||||
|
||||
func hasChangeKind(changes []Change, kind string) bool {
|
||||
for _, change := range changes {
|
||||
if change.Kind == kind {
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package interfacesnapshot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const FlagMigrationManifestVersion = 1
|
||||
|
||||
const (
|
||||
FlagMigrationPending = "pending"
|
||||
FlagMigrationConsumed = "consumed"
|
||||
)
|
||||
|
||||
type FlagMigrationManifest struct {
|
||||
Version int `json:"version"`
|
||||
Migrations []FlagMigration `json:"migrations"`
|
||||
}
|
||||
|
||||
type FlagMigration struct {
|
||||
Command string `json:"command"`
|
||||
Legacy FlagMigrationSide `json:"legacy"`
|
||||
Canonical FlagMigrationSide `json:"canonical"`
|
||||
State string `json:"state"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type FlagMigrationSide struct {
|
||||
Name string `json:"name"`
|
||||
Before FlagMigrationState `json:"before"`
|
||||
After FlagMigrationState `json:"after"`
|
||||
}
|
||||
|
||||
type FlagMigrationState struct {
|
||||
Present bool `json:"present"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
Shorthand string `json:"shorthand,omitempty"`
|
||||
NoOpt string `json:"no_opt,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
AliasOf string `json:"alias_of,omitempty"`
|
||||
}
|
||||
|
||||
func ReadFlagMigrationManifest(r io.Reader) (FlagMigrationManifest, error) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return FlagMigrationManifest{}, fmt.Errorf("read flag migration manifest: %w", err)
|
||||
}
|
||||
var manifest FlagMigrationManifest
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&manifest); err != nil {
|
||||
return FlagMigrationManifest{}, err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return FlagMigrationManifest{}, fmt.Errorf("flag migration manifest contains trailing multiple JSON values")
|
||||
}
|
||||
return FlagMigrationManifest{}, fmt.Errorf("read trailing flag migration manifest data: %w", err)
|
||||
}
|
||||
if err := validateFlagMigrationJSONSchema(data); err != nil {
|
||||
return FlagMigrationManifest{}, err
|
||||
}
|
||||
if err := manifest.Validate(); err != nil {
|
||||
return FlagMigrationManifest{}, err
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func validateFlagMigrationJSONSchema(data []byte) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.UseNumber()
|
||||
if err := validateMigrationJSONValue(decoder, "$", reflect.TypeOf(FlagMigrationManifest{})); err != nil {
|
||||
return err
|
||||
}
|
||||
if token, err := decoder.Token(); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("flag migration manifest contains trailing JSON value %v", token)
|
||||
}
|
||||
return fmt.Errorf("read trailing flag migration manifest data: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMigrationJSONValue(decoder *json.Decoder, path string, schema reflect.Type) error {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read flag migration manifest value at %s: %w", path, err)
|
||||
}
|
||||
|
||||
switch schema.Kind() {
|
||||
case reflect.Struct:
|
||||
if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' {
|
||||
return migrationJSONTypeError(path, schema, token)
|
||||
}
|
||||
fields := migrationJSONFields(schema)
|
||||
seen := make(map[string]bool, len(fields))
|
||||
for decoder.More() {
|
||||
keyToken, keyErr := decoder.Token()
|
||||
if keyErr != nil {
|
||||
return fmt.Errorf("read flag migration manifest field at %s: %w", path, keyErr)
|
||||
}
|
||||
// encoding/json guarantees object member names are string tokens.
|
||||
key := keyToken.(string)
|
||||
fieldSchema, exists := fields[key]
|
||||
if !exists {
|
||||
for canonical := range fields {
|
||||
if strings.EqualFold(key, canonical) {
|
||||
return fmt.Errorf(
|
||||
"flag migration manifest contains non-canonical field %q at %s (want %q)",
|
||||
key,
|
||||
path,
|
||||
canonical,
|
||||
)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("flag migration manifest contains unknown field %q at %s", key, path)
|
||||
}
|
||||
if seen[key] {
|
||||
return fmt.Errorf("flag migration manifest contains duplicate field %q at %s", key, path)
|
||||
}
|
||||
seen[key] = true
|
||||
if err := validateMigrationJSONValue(decoder, path+"."+key, fieldSchema); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, closeErr := decoder.Token(); closeErr != nil {
|
||||
return fmt.Errorf("close flag migration manifest object at %s: %w", path, closeErr)
|
||||
}
|
||||
return nil
|
||||
case reflect.Slice:
|
||||
if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' {
|
||||
return migrationJSONTypeError(path, schema, token)
|
||||
}
|
||||
for index := 0; decoder.More(); index++ {
|
||||
if err := validateMigrationJSONValue(decoder, fmt.Sprintf("%s[%d]", path, index), schema.Elem()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, closeErr := decoder.Token(); closeErr != nil {
|
||||
return fmt.Errorf("close flag migration manifest array at %s: %w", path, closeErr)
|
||||
}
|
||||
return nil
|
||||
case reflect.String:
|
||||
if _, ok := token.(string); !ok {
|
||||
return migrationJSONTypeError(path, schema, token)
|
||||
}
|
||||
return nil
|
||||
case reflect.Int:
|
||||
if _, ok := token.(json.Number); !ok {
|
||||
return migrationJSONTypeError(path, schema, token)
|
||||
}
|
||||
return nil
|
||||
case reflect.Bool:
|
||||
if _, ok := token.(bool); !ok {
|
||||
return migrationJSONTypeError(path, schema, token)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("flag migration manifest value at %s has unsupported Go schema type %s", path, schema)
|
||||
}
|
||||
}
|
||||
|
||||
func migrationJSONFields(schema reflect.Type) map[string]reflect.Type {
|
||||
fields := make(map[string]reflect.Type, schema.NumField())
|
||||
for index := 0; index < schema.NumField(); index++ {
|
||||
field := schema.Field(index)
|
||||
name := field.Tag.Get("json")
|
||||
if comma := strings.IndexByte(name, ','); comma >= 0 {
|
||||
name = name[:comma]
|
||||
}
|
||||
if name == "-" {
|
||||
continue
|
||||
}
|
||||
if name == "" {
|
||||
name = field.Name
|
||||
}
|
||||
fields[name] = field.Type
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func migrationJSONTypeError(path string, want reflect.Type, token json.Token) error {
|
||||
return fmt.Errorf(
|
||||
"flag migration manifest value at %s must be %s, got %s",
|
||||
path,
|
||||
migrationJSONKindDescription(want),
|
||||
migrationJSONTokenDescription(token),
|
||||
)
|
||||
}
|
||||
|
||||
func migrationJSONKindDescription(schema reflect.Type) string {
|
||||
switch schema.Kind() {
|
||||
case reflect.Struct:
|
||||
return "an object"
|
||||
case reflect.Slice:
|
||||
return "an array"
|
||||
case reflect.String:
|
||||
return "a string"
|
||||
case reflect.Int:
|
||||
return "a number"
|
||||
case reflect.Bool:
|
||||
return "a boolean"
|
||||
default:
|
||||
return "the declared JSON type"
|
||||
}
|
||||
}
|
||||
|
||||
func migrationJSONTokenDescription(token json.Token) string {
|
||||
if token == nil {
|
||||
return "null"
|
||||
}
|
||||
if delimiter, ok := token.(json.Delim); ok {
|
||||
switch delimiter {
|
||||
case '{':
|
||||
return "object"
|
||||
case '[':
|
||||
return "array"
|
||||
default:
|
||||
return fmt.Sprintf("delimiter %q", delimiter)
|
||||
}
|
||||
}
|
||||
switch token.(type) {
|
||||
case string:
|
||||
return "string"
|
||||
case json.Number, float64:
|
||||
return "number"
|
||||
case bool:
|
||||
return "boolean"
|
||||
default:
|
||||
return fmt.Sprintf("%T", token)
|
||||
}
|
||||
}
|
||||
|
||||
func (m FlagMigrationManifest) Validate() error {
|
||||
if m.Version != FlagMigrationManifestVersion {
|
||||
return fmt.Errorf(
|
||||
"unsupported flag migration manifest version %d (want %d)",
|
||||
m.Version,
|
||||
FlagMigrationManifestVersion,
|
||||
)
|
||||
}
|
||||
if m.Migrations == nil {
|
||||
return fmt.Errorf("flag migration manifest migrations must be an array")
|
||||
}
|
||||
seen := make(map[string]bool, len(m.Migrations))
|
||||
legacyTargets := make(map[string]string, len(m.Migrations))
|
||||
for index, migration := range m.Migrations {
|
||||
if err := migration.validate(); err != nil {
|
||||
return fmt.Errorf("flag migration %d: %w", index, err)
|
||||
}
|
||||
key := migration.key()
|
||||
if seen[key] {
|
||||
return fmt.Errorf("flag migration %d duplicates %s", index, key)
|
||||
}
|
||||
seen[key] = true
|
||||
legacyKey := migration.Command + "\x00" + migration.Legacy.Name
|
||||
if canonical, exists := legacyTargets[legacyKey]; exists && canonical != migration.Canonical.Name {
|
||||
return fmt.Errorf(
|
||||
"flag migration %d maps %s --%s to both --%s and --%s",
|
||||
index,
|
||||
migration.Command,
|
||||
migration.Legacy.Name,
|
||||
canonical,
|
||||
migration.Canonical.Name,
|
||||
)
|
||||
}
|
||||
legacyTargets[legacyKey] = migration.Canonical.Name
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m FlagMigration) validate() error {
|
||||
if !isExactCommandPath(m.Command) {
|
||||
return fmt.Errorf("command must be an exact command path rooted at dws: %q", m.Command)
|
||||
}
|
||||
if !isExactFlagName(m.Legacy.Name) {
|
||||
return fmt.Errorf("legacy name must be an exact legacy flag: %q", m.Legacy.Name)
|
||||
}
|
||||
if !isExactFlagName(m.Canonical.Name) {
|
||||
return fmt.Errorf("canonical name must be an exact canonical flag: %q", m.Canonical.Name)
|
||||
}
|
||||
if m.Legacy.Name == m.Canonical.Name {
|
||||
return fmt.Errorf("legacy and canonical flags must differ: --%s", m.Legacy.Name)
|
||||
}
|
||||
if strings.TrimSpace(m.Reason) == "" {
|
||||
return fmt.Errorf("migration must include a non-empty reason")
|
||||
}
|
||||
if m.Reason != strings.TrimSpace(m.Reason) {
|
||||
return fmt.Errorf("migration reason must be trimmed")
|
||||
}
|
||||
if m.State != FlagMigrationPending && m.State != FlagMigrationConsumed {
|
||||
return fmt.Errorf("invalid state %q", m.State)
|
||||
}
|
||||
if err := m.Legacy.Before.validate("legacy before"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.Legacy.After.validate("legacy after"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.Canonical.Before.validate("canonical before"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.Canonical.After.validate("canonical after"); err != nil {
|
||||
return err
|
||||
}
|
||||
if !m.Legacy.Before.Present || !m.Legacy.After.Present {
|
||||
return fmt.Errorf("legacy flag must remain present before and after migration")
|
||||
}
|
||||
if m.Legacy.Before.Hidden || !m.Legacy.After.Hidden {
|
||||
return fmt.Errorf("legacy flag must migrate exactly from visible to hidden")
|
||||
}
|
||||
if !m.Canonical.After.Present {
|
||||
return fmt.Errorf("canonical flag must be present after migration")
|
||||
}
|
||||
if m.Canonical.After.Hidden {
|
||||
return fmt.Errorf("canonical flag must remain visible")
|
||||
}
|
||||
if !m.Canonical.After.Required {
|
||||
return fmt.Errorf("canonical flag must be required after migration")
|
||||
}
|
||||
if m.Canonical.Before.Present && m.Canonical.Before.Required {
|
||||
return fmt.Errorf("canonical flag must be absent or optional before migration")
|
||||
}
|
||||
if m.Legacy.After.AliasOf != m.Canonical.Name {
|
||||
return fmt.Errorf(
|
||||
"legacy flag after state must declare alias_of %q",
|
||||
m.Canonical.Name,
|
||||
)
|
||||
}
|
||||
if m.Legacy.Before.AliasOf != "" || m.Canonical.Before.AliasOf != "" || m.Canonical.After.AliasOf != "" {
|
||||
return fmt.Errorf("alias_of is only valid on the legacy after state")
|
||||
}
|
||||
if m.Canonical.Before.Present {
|
||||
if m.Canonical.Before.Type != m.Canonical.After.Type {
|
||||
return fmt.Errorf("canonical flag type must remain unchanged")
|
||||
}
|
||||
if m.Canonical.Before.Shorthand != m.Canonical.After.Shorthand {
|
||||
return fmt.Errorf("canonical flag shorthand must remain unchanged")
|
||||
}
|
||||
if m.Canonical.Before.NoOpt != m.Canonical.After.NoOpt {
|
||||
return fmt.Errorf("canonical flag no_opt must remain unchanged")
|
||||
}
|
||||
if m.Canonical.Before.Scope != m.Canonical.After.Scope {
|
||||
return fmt.Errorf("canonical flag scope must remain unchanged")
|
||||
}
|
||||
}
|
||||
if m.Legacy.Before.Type != m.Legacy.After.Type || m.Legacy.After.Type != m.Canonical.After.Type {
|
||||
return fmt.Errorf("legacy and canonical flag types must match exactly")
|
||||
}
|
||||
if m.Legacy.Before.Shorthand != m.Legacy.After.Shorthand {
|
||||
return fmt.Errorf("legacy flag shorthand must remain unchanged")
|
||||
}
|
||||
if m.Legacy.Before.NoOpt != m.Legacy.After.NoOpt {
|
||||
return fmt.Errorf("legacy flag no_opt must remain unchanged")
|
||||
}
|
||||
if m.Legacy.Before.Scope != m.Legacy.After.Scope {
|
||||
return fmt.Errorf("legacy flag scope must remain unchanged")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s FlagMigrationState) validate(label string) error {
|
||||
if !s.Present {
|
||||
if s.Type != "" || s.Required || s.Hidden || s.Shorthand != "" || s.NoOpt != "" || s.Scope != "" || s.AliasOf != "" {
|
||||
return fmt.Errorf("%s absent state must not declare flag attributes", label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(s.Type) == "" {
|
||||
return fmt.Errorf("%s present state requires type", label)
|
||||
}
|
||||
if s.Scope != "local" && s.Scope != "inherited" {
|
||||
return fmt.Errorf("%s present state has invalid scope %q", label, s.Scope)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m FlagMigration) key() string {
|
||||
return m.Command + "\x00" + m.Legacy.Name + "\x00" + m.Canonical.Name
|
||||
}
|
||||
|
||||
func isExactCommandPath(path string) bool {
|
||||
return path != "" &&
|
||||
path == strings.TrimSpace(path) &&
|
||||
strings.Join(strings.Fields(path), " ") == path &&
|
||||
(path == "dws" || strings.HasPrefix(path, "dws ")) &&
|
||||
!strings.ContainsAny(path, "*?[]{}")
|
||||
}
|
||||
|
||||
func isExactFlagName(name string) bool {
|
||||
return name != "" &&
|
||||
name == strings.TrimSpace(name) &&
|
||||
!strings.HasPrefix(name, "-") &&
|
||||
!strings.ContainsAny(name, "*?[]{} /\t\r\n")
|
||||
}
|
||||
|
||||
// CompareAllWithFlagMigrations applies the ordinary compatibility policy and
|
||||
// then consumes only exact, merge-base-owned flag migrations. Candidate-owned
|
||||
// records participate in the lifecycle check, but never authorize their own
|
||||
// interface change.
|
||||
func CompareAllWithFlagMigrations(
|
||||
current Snapshot,
|
||||
references map[string]Snapshot,
|
||||
authority FlagMigrationManifest,
|
||||
candidate FlagMigrationManifest,
|
||||
) (Report, error) {
|
||||
authorizations, err := AuthorizeFlagMigrations(current, references, authority, candidate)
|
||||
if err != nil {
|
||||
return Report{}, err
|
||||
}
|
||||
report := CompareAll(current, references)
|
||||
if len(authorizations) == 0 {
|
||||
return report, nil
|
||||
}
|
||||
|
||||
for index := range report.Comparisons {
|
||||
comparison := &report.Comparisons[index]
|
||||
reference := references[comparison.Reference]
|
||||
filtered := comparison.Blocking[:0]
|
||||
for _, change := range comparison.Blocking {
|
||||
if flagMigrationAuthorizesChange(current, reference, change, authorizations) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, change)
|
||||
}
|
||||
comparison.Blocking = filtered
|
||||
comparison.Compatible = len(filtered) == 0
|
||||
}
|
||||
report.Compatible = true
|
||||
for _, comparison := range report.Comparisons {
|
||||
if !comparison.Compatible {
|
||||
report.Compatible = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// AuthorizeFlagMigrations validates both snapshots and manifests, enforces the
|
||||
// base-owned migration lifecycle, and returns only approvals that may authorize
|
||||
// the current exact interface transition. A non-empty lifecycle requires both a
|
||||
// main/merge-base authority reference and a stable reference. Candidate-added
|
||||
// records never appear in the returned authorization set.
|
||||
func AuthorizeFlagMigrations(
|
||||
current Snapshot,
|
||||
references map[string]Snapshot,
|
||||
authority FlagMigrationManifest,
|
||||
candidate FlagMigrationManifest,
|
||||
) ([]FlagMigration, error) {
|
||||
if err := current.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validate current interface snapshot: %w", err)
|
||||
}
|
||||
for label, snapshot := range references {
|
||||
if err := snapshot.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validate %s interface snapshot: %w", label, err)
|
||||
}
|
||||
}
|
||||
if err := authority.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validate approved flag migrations: %w", err)
|
||||
}
|
||||
if err := candidate.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validate candidate flag migrations: %w", err)
|
||||
}
|
||||
return evaluateFlagMigrationLifecycle(current, references, authority, candidate)
|
||||
}
|
||||
|
||||
type flagMigrationPhase string
|
||||
|
||||
const (
|
||||
flagMigrationBefore flagMigrationPhase = "before"
|
||||
flagMigrationAfter flagMigrationPhase = "after"
|
||||
flagMigrationPartial flagMigrationPhase = "partial"
|
||||
)
|
||||
|
||||
func evaluateFlagMigrationLifecycle(
|
||||
current Snapshot,
|
||||
references map[string]Snapshot,
|
||||
authority FlagMigrationManifest,
|
||||
candidate FlagMigrationManifest,
|
||||
) ([]FlagMigration, error) {
|
||||
if len(authority.Migrations) == 0 && len(candidate.Migrations) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if _, ok := references["stable"]; !ok {
|
||||
return nil, fmt.Errorf("flag migration lifecycle requires a stable reference")
|
||||
}
|
||||
mergeBase, label, ok := flagMigrationAuthoritySnapshot(references)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("flag migration lifecycle requires a main or merge-base reference")
|
||||
}
|
||||
|
||||
authorityByKey := flagMigrationIndex(authority)
|
||||
candidateByKey := flagMigrationIndex(candidate)
|
||||
authorizations := make([]FlagMigration, 0, len(authority.Migrations))
|
||||
|
||||
for _, approved := range authority.Migrations {
|
||||
basePhase := matchFlagMigrationPhase(mergeBase, approved)
|
||||
wantBasePhase := flagMigrationBefore
|
||||
if approved.State == FlagMigrationConsumed {
|
||||
wantBasePhase = flagMigrationAfter
|
||||
}
|
||||
if basePhase != wantBasePhase {
|
||||
return nil, fmt.Errorf(
|
||||
"approved flag migration %s is %s in %s, want exact %s state for %s",
|
||||
approved.displayKey(),
|
||||
basePhase,
|
||||
label,
|
||||
wantBasePhase,
|
||||
approved.State,
|
||||
)
|
||||
}
|
||||
|
||||
proposed, exists := candidateByKey[approved.key()]
|
||||
if exists && !sameFlagMigrationApproval(approved, proposed) {
|
||||
return nil, fmt.Errorf("candidate modified base-owned flag migration %s", approved.displayKey())
|
||||
}
|
||||
|
||||
currentPhase := matchFlagMigrationPhase(current, approved)
|
||||
switch approved.State {
|
||||
case FlagMigrationPending:
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("candidate removed pending flag migration %s", approved.displayKey())
|
||||
}
|
||||
switch currentPhase {
|
||||
case flagMigrationBefore:
|
||||
if proposed.State != FlagMigrationPending {
|
||||
return nil, fmt.Errorf("candidate falsely consumed unchanged flag migration %s", approved.displayKey())
|
||||
}
|
||||
case flagMigrationAfter:
|
||||
if proposed.State != FlagMigrationConsumed {
|
||||
return nil, fmt.Errorf("candidate completed flag migration %s without marking it consumed", approved.displayKey())
|
||||
}
|
||||
authorizations = append(authorizations, approved)
|
||||
default:
|
||||
return nil, fmt.Errorf("candidate partially applied flag migration %s", approved.displayKey())
|
||||
}
|
||||
case FlagMigrationConsumed:
|
||||
if currentPhase != flagMigrationAfter {
|
||||
return nil, fmt.Errorf("candidate drifted from consumed flag migration %s", approved.displayKey())
|
||||
}
|
||||
allReferencesAfter := true
|
||||
for _, reference := range references {
|
||||
if matchFlagMigrationPhase(reference, approved) != flagMigrationAfter {
|
||||
allReferencesAfter = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allReferencesAfter {
|
||||
if exists {
|
||||
return nil, fmt.Errorf("consumed flag migration %s is stale after all references reached the after state", approved.displayKey())
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("candidate removed consumed flag migration %s before every reference reached the after state", approved.displayKey())
|
||||
}
|
||||
if proposed.State != FlagMigrationConsumed {
|
||||
return nil, fmt.Errorf("candidate changed consumed flag migration %s back to pending", approved.displayKey())
|
||||
}
|
||||
authorizations = append(authorizations, approved)
|
||||
}
|
||||
}
|
||||
|
||||
for _, proposed := range candidate.Migrations {
|
||||
if _, exists := authorityByKey[proposed.key()]; exists {
|
||||
continue
|
||||
}
|
||||
if proposed.State != FlagMigrationPending {
|
||||
return nil, fmt.Errorf("candidate-added flag migration %s must start pending", proposed.displayKey())
|
||||
}
|
||||
if matchFlagMigrationPhase(mergeBase, proposed) != flagMigrationBefore {
|
||||
return nil, fmt.Errorf("candidate-added flag migration %s does not match the merge-base before state", proposed.displayKey())
|
||||
}
|
||||
if matchFlagMigrationPhase(current, proposed) != flagMigrationBefore {
|
||||
return nil, fmt.Errorf("candidate-added flag migration %s cannot authorize its own interface change", proposed.displayKey())
|
||||
}
|
||||
}
|
||||
|
||||
return authorizations, nil
|
||||
}
|
||||
|
||||
func flagMigrationAuthoritySnapshot(references map[string]Snapshot) (Snapshot, string, bool) {
|
||||
for _, label := range []string{"merge-base", "main"} {
|
||||
if snapshot, ok := references[label]; ok {
|
||||
return snapshot, label, true
|
||||
}
|
||||
}
|
||||
return Snapshot{}, "", false
|
||||
}
|
||||
|
||||
func flagMigrationIndex(manifest FlagMigrationManifest) map[string]FlagMigration {
|
||||
index := make(map[string]FlagMigration, len(manifest.Migrations))
|
||||
for _, migration := range manifest.Migrations {
|
||||
index[migration.key()] = migration
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func sameFlagMigrationApproval(left, right FlagMigration) bool {
|
||||
left.State = ""
|
||||
right.State = ""
|
||||
return reflect.DeepEqual(left, right)
|
||||
}
|
||||
|
||||
func matchFlagMigrationPhase(snapshot Snapshot, migration FlagMigration) flagMigrationPhase {
|
||||
command, exists := commandIndex(snapshot)[migration.Command]
|
||||
if !exists {
|
||||
return flagMigrationPartial
|
||||
}
|
||||
legacy := flagMigrationStateForCommand(command, migration.Legacy.Name)
|
||||
canonical := flagMigrationStateForCommand(command, migration.Canonical.Name)
|
||||
if legacy == migration.Legacy.Before && canonical == migration.Canonical.Before {
|
||||
return flagMigrationBefore
|
||||
}
|
||||
if legacy == migration.Legacy.After && canonical == migration.Canonical.After {
|
||||
return flagMigrationAfter
|
||||
}
|
||||
return flagMigrationPartial
|
||||
}
|
||||
|
||||
func flagMigrationStateForCommand(command Command, name string) FlagMigrationState {
|
||||
for _, flag := range command.LocalFlags {
|
||||
if flag.Name == name {
|
||||
return flagMigrationState(flag, "local")
|
||||
}
|
||||
}
|
||||
for _, flag := range command.InheritedFlags {
|
||||
if flag.Name == name {
|
||||
return flagMigrationState(flag, "inherited")
|
||||
}
|
||||
}
|
||||
return FlagMigrationState{}
|
||||
}
|
||||
|
||||
func flagMigrationState(flag Flag, scope string) FlagMigrationState {
|
||||
return FlagMigrationState{
|
||||
Present: true,
|
||||
Type: flag.Type,
|
||||
Required: flag.Required,
|
||||
Hidden: flag.Hidden,
|
||||
Shorthand: flag.Shorthand,
|
||||
NoOpt: flag.NoOpt,
|
||||
Scope: scope,
|
||||
AliasOf: flag.AliasOf,
|
||||
}
|
||||
}
|
||||
|
||||
func flagMigrationAuthorizesChange(
|
||||
current Snapshot,
|
||||
reference Snapshot,
|
||||
change Change,
|
||||
authorizations []FlagMigration,
|
||||
) bool {
|
||||
canonicalPath := acceptedPathIndex(reference)[change.Path]
|
||||
if canonicalPath == "" {
|
||||
canonicalPath = change.Path
|
||||
}
|
||||
for _, migration := range authorizations {
|
||||
if canonicalPath != migration.Command ||
|
||||
matchFlagMigrationPhase(reference, migration) != flagMigrationBefore ||
|
||||
matchFlagMigrationPhase(current, migration) != flagMigrationAfter {
|
||||
continue
|
||||
}
|
||||
if change.Flag == migration.Legacy.Name && change.Kind == "flag_became_hidden" {
|
||||
return true
|
||||
}
|
||||
if change.Flag != migration.Canonical.Name {
|
||||
continue
|
||||
}
|
||||
if !migration.Canonical.Before.Present &&
|
||||
migration.Canonical.After.Required &&
|
||||
change.Kind == "required_flag_added" {
|
||||
return true
|
||||
}
|
||||
if migration.Canonical.Before.Present &&
|
||||
!migration.Canonical.Before.Required &&
|
||||
migration.Canonical.After.Required &&
|
||||
change.Kind == "flag_became_required" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m FlagMigration) displayKey() string {
|
||||
return fmt.Sprintf("%q --%s -> --%s", m.Command, m.Legacy.Name, m.Canonical.Name)
|
||||
}
|
||||
@@ -0,0 +1,966 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package interfacesnapshot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageFlagMigrationStrictJSONSchemaErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "schema validator rejects a trailing value",
|
||||
input: coverageManifestJSON() + ` {}`,
|
||||
wantErr: "trailing JSON value",
|
||||
},
|
||||
{
|
||||
name: "schema validator reports malformed trailing data",
|
||||
input: coverageManifestJSON() + ` ?`,
|
||||
wantErr: "read trailing flag migration manifest data",
|
||||
},
|
||||
{
|
||||
name: "schema validator reports an empty document",
|
||||
input: "",
|
||||
wantErr: "read flag migration manifest value at $",
|
||||
},
|
||||
{
|
||||
name: "schema validator rejects an unknown field",
|
||||
input: `{"version":1,"migrations":[],"unexpected":true}`,
|
||||
wantErr: `unknown field "unexpected"`,
|
||||
},
|
||||
{
|
||||
name: "schema validator reports a malformed field token",
|
||||
input: `{"`,
|
||||
wantErr: "flag migration manifest",
|
||||
},
|
||||
{
|
||||
name: "schema validator reports an unclosed object",
|
||||
input: `{"version":1`,
|
||||
wantErr: "close flag migration manifest object",
|
||||
},
|
||||
{
|
||||
name: "schema validator reports an unclosed array",
|
||||
input: `{"version":1,"migrations":[`,
|
||||
wantErr: "close flag migration manifest array",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := validateFlagMigrationJSONSchema([]byte(test.input))
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("validateFlagMigrationJSONSchema() error = %v, want %q", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagMigrationStrictJSONTypeDiagnostics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
schema reflect.Type
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "manifest must be an object",
|
||||
input: `[]`,
|
||||
schema: reflect.TypeOf(FlagMigrationManifest{}),
|
||||
wantErr: "must be an object, got array",
|
||||
},
|
||||
{
|
||||
name: "migrations must be an array",
|
||||
input: `{}`,
|
||||
schema: reflect.TypeOf([]FlagMigration{}),
|
||||
wantErr: "must be an array, got object",
|
||||
},
|
||||
{
|
||||
name: "integer rejects string",
|
||||
input: `"one"`,
|
||||
schema: reflect.TypeOf(int(0)),
|
||||
wantErr: "must be a number, got string",
|
||||
},
|
||||
{
|
||||
name: "string rejects number",
|
||||
input: `1`,
|
||||
schema: reflect.TypeOf(""),
|
||||
wantErr: "must be a string, got number",
|
||||
},
|
||||
{
|
||||
name: "string rejects boolean",
|
||||
input: `false`,
|
||||
schema: reflect.TypeOf(""),
|
||||
wantErr: "must be a string, got boolean",
|
||||
},
|
||||
{
|
||||
name: "unsupported schema fails closed",
|
||||
input: `1`,
|
||||
schema: reflect.TypeOf(float64(0)),
|
||||
wantErr: "unsupported Go schema type float64",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
decoder := json.NewDecoder(strings.NewReader(test.input))
|
||||
decoder.UseNumber()
|
||||
err := validateMigrationJSONValue(decoder, "$", test.schema)
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("validateMigrationJSONValue() error = %v, want %q", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagMigrationStrictJSONSchemaHelpers(t *testing.T) {
|
||||
type fieldFixture struct {
|
||||
DefaultName string
|
||||
Ignored string `json:"-"`
|
||||
Tagged string `json:"tagged,omitempty"`
|
||||
}
|
||||
fields := migrationJSONFields(reflect.TypeOf(fieldFixture{}))
|
||||
if _, exists := fields["Ignored"]; exists {
|
||||
t.Fatal("migrationJSONFields() retained a json:- field")
|
||||
}
|
||||
if fields["DefaultName"] != reflect.TypeOf("") || fields["tagged"] != reflect.TypeOf("") {
|
||||
t.Fatalf("migrationJSONFields() = %#v, want default and tagged string fields", fields)
|
||||
}
|
||||
|
||||
if got := migrationJSONKindDescription(reflect.TypeOf(float64(0))); got != "the declared JSON type" {
|
||||
t.Fatalf("migrationJSONKindDescription(float64) = %q", got)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
token json.Token
|
||||
want string
|
||||
}{
|
||||
{name: "closing delimiter", token: json.Delim('}'), want: `delimiter "}"`},
|
||||
{name: "unsupported token", token: struct{}{}, want: "struct {}"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := migrationJSONTokenDescription(test.token); got != test.want {
|
||||
t.Fatalf("migrationJSONTokenDescription() = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagMigrationManifestParserEdges(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "initial malformed JSON",
|
||||
input: `{`,
|
||||
wantErr: "unexpected EOF",
|
||||
},
|
||||
{
|
||||
name: "multiple JSON values",
|
||||
input: coverageManifestJSON() + ` {}`,
|
||||
wantErr: "trailing multiple JSON values",
|
||||
},
|
||||
{
|
||||
name: "malformed trailing JSON",
|
||||
input: coverageManifestJSON() + ` {`,
|
||||
wantErr: "read trailing flag migration manifest data",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := ReadFlagMigrationManifest(strings.NewReader(test.input))
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("ReadFlagMigrationManifest() error = %v, want %q", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagMigrationManifestRejectsEveryContractDrift(t *testing.T) {
|
||||
optionalCanonical := func() FlagMigrationManifest {
|
||||
manifest := coverageManifest(FlagMigrationPending)
|
||||
manifest.Migrations[0].Canonical.Before = FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
Scope: "local",
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
make func() FlagMigrationManifest
|
||||
mutate func(*FlagMigrationManifest)
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "canonical flag name is exact",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Name = "--message-id" },
|
||||
wantErr: "canonical name must be an exact canonical flag",
|
||||
},
|
||||
{
|
||||
name: "legacy and canonical names differ",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Name = m.Migrations[0].Legacy.Name },
|
||||
wantErr: "legacy and canonical flags must differ",
|
||||
},
|
||||
{
|
||||
name: "legacy before state validates",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before.Scope = "global" },
|
||||
wantErr: "legacy before present state has invalid scope",
|
||||
},
|
||||
{
|
||||
name: "legacy after state validates",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Type = "" },
|
||||
wantErr: "legacy after present state requires type",
|
||||
},
|
||||
{
|
||||
name: "canonical before state validates",
|
||||
make: optionalCanonical,
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.Type = "" },
|
||||
wantErr: "canonical before present state requires type",
|
||||
},
|
||||
{
|
||||
name: "canonical after state validates",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After.Scope = "" },
|
||||
wantErr: "canonical after present state has invalid scope",
|
||||
},
|
||||
{
|
||||
name: "legacy before remains present",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before = FlagMigrationState{} },
|
||||
wantErr: "legacy flag must remain present before and after migration",
|
||||
},
|
||||
{
|
||||
name: "legacy after remains present",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After = FlagMigrationState{} },
|
||||
wantErr: "legacy flag must remain present before and after migration",
|
||||
},
|
||||
{
|
||||
name: "legacy starts visible",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before.Hidden = true },
|
||||
wantErr: "legacy flag must migrate exactly from visible to hidden",
|
||||
},
|
||||
{
|
||||
name: "legacy ends hidden",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Hidden = false },
|
||||
wantErr: "legacy flag must migrate exactly from visible to hidden",
|
||||
},
|
||||
{
|
||||
name: "canonical after remains present",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After = FlagMigrationState{} },
|
||||
wantErr: "canonical flag must be present after migration",
|
||||
},
|
||||
{
|
||||
name: "legacy after declares alias target",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.AliasOf = "other-id" },
|
||||
wantErr: `legacy flag after state must declare alias_of "message-id"`,
|
||||
},
|
||||
{
|
||||
name: "legacy before cannot declare alias",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before.AliasOf = "message-id" },
|
||||
wantErr: "alias_of is only valid on the legacy after state",
|
||||
},
|
||||
{
|
||||
name: "canonical before cannot declare alias",
|
||||
make: optionalCanonical,
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.AliasOf = "message-id" },
|
||||
wantErr: "alias_of is only valid on the legacy after state",
|
||||
},
|
||||
{
|
||||
name: "canonical after cannot declare alias",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After.AliasOf = "message-id" },
|
||||
wantErr: "alias_of is only valid on the legacy after state",
|
||||
},
|
||||
{
|
||||
name: "canonical type remains stable",
|
||||
make: optionalCanonical,
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.Type = "stringSlice" },
|
||||
wantErr: "canonical flag type must remain unchanged",
|
||||
},
|
||||
{
|
||||
name: "canonical shorthand remains stable",
|
||||
make: optionalCanonical,
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.Shorthand = "m" },
|
||||
wantErr: "canonical flag shorthand must remain unchanged",
|
||||
},
|
||||
{
|
||||
name: "canonical no-opt remains stable",
|
||||
make: optionalCanonical,
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.NoOpt = "auto" },
|
||||
wantErr: "canonical flag no_opt must remain unchanged",
|
||||
},
|
||||
{
|
||||
name: "canonical scope remains stable",
|
||||
make: optionalCanonical,
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.Before.Scope = "inherited" },
|
||||
wantErr: "canonical flag scope must remain unchanged",
|
||||
},
|
||||
{
|
||||
name: "legacy before type matches legacy after",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.Before.Type = "stringSlice" },
|
||||
wantErr: "legacy and canonical flag types must match exactly",
|
||||
},
|
||||
{
|
||||
name: "legacy after type matches canonical after",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Canonical.After.Type = "stringSlice" },
|
||||
wantErr: "legacy and canonical flag types must match exactly",
|
||||
},
|
||||
{
|
||||
name: "legacy shorthand remains stable",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Shorthand = "x" },
|
||||
wantErr: "legacy flag shorthand must remain unchanged",
|
||||
},
|
||||
{
|
||||
name: "legacy no-opt remains stable",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.NoOpt = "" },
|
||||
wantErr: "legacy flag no_opt must remain unchanged",
|
||||
},
|
||||
{
|
||||
name: "legacy scope remains stable",
|
||||
make: func() FlagMigrationManifest { return coverageManifest(FlagMigrationPending) },
|
||||
mutate: func(m *FlagMigrationManifest) { m.Migrations[0].Legacy.After.Scope = "inherited" },
|
||||
wantErr: "legacy flag scope must remain unchanged",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
manifest := test.make()
|
||||
test.mutate(&manifest)
|
||||
if err := manifest.Validate(); err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("Validate() error = %v, want %q", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagMigrationManifestRejectsAbsentAttributesAndTargetConflicts(t *testing.T) {
|
||||
stateMutations := []struct {
|
||||
name string
|
||||
mutate func(*FlagMigrationState)
|
||||
}{
|
||||
{name: "type", mutate: func(s *FlagMigrationState) { s.Type = "string" }},
|
||||
{name: "required", mutate: func(s *FlagMigrationState) { s.Required = true }},
|
||||
{name: "hidden", mutate: func(s *FlagMigrationState) { s.Hidden = true }},
|
||||
{name: "shorthand", mutate: func(s *FlagMigrationState) { s.Shorthand = "m" }},
|
||||
{name: "no-opt", mutate: func(s *FlagMigrationState) { s.NoOpt = "auto" }},
|
||||
{name: "scope", mutate: func(s *FlagMigrationState) { s.Scope = "local" }},
|
||||
{name: "alias", mutate: func(s *FlagMigrationState) { s.AliasOf = "message-id" }},
|
||||
}
|
||||
for _, test := range stateMutations {
|
||||
t.Run("absent state rejects "+test.name, func(t *testing.T) {
|
||||
manifest := coverageManifest(FlagMigrationPending)
|
||||
test.mutate(&manifest.Migrations[0].Canonical.Before)
|
||||
if err := manifest.Validate(); err == nil || !strings.Contains(err.Error(), "absent state must not declare flag attributes") {
|
||||
t.Fatalf("Validate() error = %v, want absent-state rejection", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
manifest := coverageManifest(FlagMigrationPending)
|
||||
second := coverageManifest(FlagMigrationPending).Migrations[0]
|
||||
second.Canonical.Name = "canonical-two"
|
||||
second.Legacy.After.AliasOf = second.Canonical.Name
|
||||
manifest.Migrations = append(manifest.Migrations, second)
|
||||
if err := manifest.Validate(); err == nil || !strings.Contains(err.Error(), "to both --message-id and --canonical-two") {
|
||||
t.Fatalf("Validate() error = %v, want one legacy to two canonical targets rejected", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsValidatesEveryInput(t *testing.T) {
|
||||
before := coverageMigrationSnapshot(coverageManifest(FlagMigrationPending).Migrations[0], false, false)
|
||||
empty := coverageEmptyManifest()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
current Snapshot
|
||||
references map[string]Snapshot
|
||||
authority FlagMigrationManifest
|
||||
candidate FlagMigrationManifest
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "current snapshot",
|
||||
current: Snapshot{},
|
||||
references: map[string]Snapshot{"merge-base": before},
|
||||
authority: empty,
|
||||
candidate: empty,
|
||||
wantErr: "validate current interface snapshot",
|
||||
},
|
||||
{
|
||||
name: "reference snapshot",
|
||||
current: before,
|
||||
references: map[string]Snapshot{"merge-base": {}},
|
||||
authority: empty,
|
||||
candidate: empty,
|
||||
wantErr: "validate merge-base interface snapshot",
|
||||
},
|
||||
{
|
||||
name: "authority manifest",
|
||||
current: before,
|
||||
references: map[string]Snapshot{"merge-base": before},
|
||||
authority: FlagMigrationManifest{},
|
||||
candidate: empty,
|
||||
wantErr: "validate approved flag migrations",
|
||||
},
|
||||
{
|
||||
name: "candidate manifest",
|
||||
current: before,
|
||||
references: map[string]Snapshot{"merge-base": before},
|
||||
authority: empty,
|
||||
candidate: FlagMigrationManifest{},
|
||||
wantErr: "validate candidate flag migrations",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := CompareAllWithFlagMigrations(test.current, test.references, test.authority, test.candidate)
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("CompareAllWithFlagMigrations() error = %v, want %q", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
report, err := CompareAllWithFlagMigrations(before, map[string]Snapshot{"merge-base": before}, empty, empty)
|
||||
if err != nil || !report.Compatible {
|
||||
t.Fatalf("empty migration lifecycle = (%#v, %v), want compatible", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsLifecycleErrors(t *testing.T) {
|
||||
pending := coverageManifest(FlagMigrationPending)
|
||||
consumed := coverageManifest(FlagMigrationConsumed)
|
||||
empty := coverageEmptyManifest()
|
||||
migration := pending.Migrations[0]
|
||||
before := coverageMigrationSnapshot(migration, false, false)
|
||||
after := coverageMigrationSnapshot(migration, true, false)
|
||||
partial := coverageMigrationSnapshot(migration, false, false)
|
||||
partial.Commands[len(partial.Commands)-1].LocalFlags[0].Hidden = true
|
||||
|
||||
modified := coverageManifest(FlagMigrationPending)
|
||||
modified.Migrations[0].Reason = "A different reviewed reason."
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
current Snapshot
|
||||
references map[string]Snapshot
|
||||
authority FlagMigrationManifest
|
||||
candidate FlagMigrationManifest
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "requires merge-base or main",
|
||||
current: before,
|
||||
references: map[string]Snapshot{"stable": before},
|
||||
authority: pending,
|
||||
candidate: pending,
|
||||
wantErr: "requires a main or merge-base reference",
|
||||
},
|
||||
{
|
||||
name: "pending authority requires before base",
|
||||
current: after,
|
||||
references: map[string]Snapshot{"merge-base": after, "stable": after},
|
||||
authority: pending,
|
||||
candidate: pending,
|
||||
wantErr: "want exact before state for pending",
|
||||
},
|
||||
{
|
||||
name: "consumed authority requires after base",
|
||||
current: before,
|
||||
references: map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
authority: consumed,
|
||||
candidate: consumed,
|
||||
wantErr: "want exact after state for consumed",
|
||||
},
|
||||
{
|
||||
name: "missing base command is partial",
|
||||
current: before,
|
||||
references: map[string]Snapshot{"merge-base": coverageRootOnlySnapshot(), "stable": before},
|
||||
authority: pending,
|
||||
candidate: pending,
|
||||
wantErr: "is partial in merge-base",
|
||||
},
|
||||
{
|
||||
name: "candidate cannot modify base receipt",
|
||||
current: before,
|
||||
references: map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
authority: pending,
|
||||
candidate: modified,
|
||||
wantErr: "candidate modified base-owned flag migration",
|
||||
},
|
||||
{
|
||||
name: "candidate cannot remove pending receipt",
|
||||
current: before,
|
||||
references: map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
authority: pending,
|
||||
candidate: empty,
|
||||
wantErr: "candidate removed pending flag migration",
|
||||
},
|
||||
{
|
||||
name: "candidate cannot falsely consume pending receipt",
|
||||
current: before,
|
||||
references: map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
authority: pending,
|
||||
candidate: consumed,
|
||||
wantErr: "candidate falsely consumed unchanged flag migration",
|
||||
},
|
||||
{
|
||||
name: "candidate must consume completed receipt",
|
||||
current: after,
|
||||
references: map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
authority: pending,
|
||||
candidate: pending,
|
||||
wantErr: "without marking it consumed",
|
||||
},
|
||||
{
|
||||
name: "candidate rejects partial application",
|
||||
current: partial,
|
||||
references: map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
authority: pending,
|
||||
candidate: consumed,
|
||||
wantErr: "candidate partially applied flag migration",
|
||||
},
|
||||
{
|
||||
name: "consumed receipt rejects current revert",
|
||||
current: before,
|
||||
references: map[string]Snapshot{"merge-base": after, "stable": before},
|
||||
authority: consumed,
|
||||
candidate: consumed,
|
||||
wantErr: "candidate drifted from consumed flag migration",
|
||||
},
|
||||
{
|
||||
name: "consumed receipt cannot be removed early",
|
||||
current: after,
|
||||
references: map[string]Snapshot{"merge-base": after, "stable": before},
|
||||
authority: consumed,
|
||||
candidate: empty,
|
||||
wantErr: "before every reference reached the after state",
|
||||
},
|
||||
{
|
||||
name: "consumed receipt cannot revert to pending",
|
||||
current: after,
|
||||
references: map[string]Snapshot{"merge-base": after, "stable": before},
|
||||
authority: consumed,
|
||||
candidate: pending,
|
||||
wantErr: "back to pending",
|
||||
},
|
||||
{
|
||||
name: "consumed receipt becomes stale",
|
||||
current: after,
|
||||
references: map[string]Snapshot{"merge-base": after, "stable": after},
|
||||
authority: consumed,
|
||||
candidate: consumed,
|
||||
wantErr: "is stale after all references reached the after state",
|
||||
},
|
||||
{
|
||||
name: "candidate-added receipt starts pending",
|
||||
current: before,
|
||||
references: map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
authority: empty,
|
||||
candidate: consumed,
|
||||
wantErr: "must start pending",
|
||||
},
|
||||
{
|
||||
name: "candidate-added receipt matches base before",
|
||||
current: before,
|
||||
references: map[string]Snapshot{"merge-base": after, "stable": after},
|
||||
authority: empty,
|
||||
candidate: pending,
|
||||
wantErr: "does not match the merge-base before state",
|
||||
},
|
||||
{
|
||||
name: "candidate-added receipt cannot self-authorize",
|
||||
current: after,
|
||||
references: map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
authority: empty,
|
||||
candidate: pending,
|
||||
wantErr: "cannot authorize its own interface change",
|
||||
},
|
||||
{
|
||||
name: "missing current command is partial",
|
||||
current: coverageRootOnlySnapshot(),
|
||||
references: map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
authority: pending,
|
||||
candidate: pending,
|
||||
wantErr: "candidate partially applied flag migration",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := CompareAllWithFlagMigrations(test.current, test.references, test.authority, test.candidate)
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("CompareAllWithFlagMigrations() error = %v, want %q", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsMainAuthorityAndCleanup(t *testing.T) {
|
||||
pending := coverageManifest(FlagMigrationPending)
|
||||
consumed := coverageManifest(FlagMigrationConsumed)
|
||||
empty := coverageEmptyManifest()
|
||||
migration := pending.Migrations[0]
|
||||
before := coverageMigrationSnapshot(migration, false, false)
|
||||
after := coverageMigrationSnapshot(migration, true, false)
|
||||
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"main": before, "stable": before},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil || !report.Compatible {
|
||||
t.Fatalf("main-owned migration = (%#v, %v), want compatible", report, err)
|
||||
}
|
||||
|
||||
report, err = CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": before, "main": after, "stable": before},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil || !report.Compatible {
|
||||
t.Fatalf("merge-base precedence = (%#v, %v), want compatible", report, err)
|
||||
}
|
||||
|
||||
report, err = CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": after, "stable": after},
|
||||
consumed,
|
||||
empty,
|
||||
)
|
||||
if err != nil || !report.Compatible {
|
||||
t.Fatalf("consumed receipt cleanup = (%#v, %v), want compatible", report, err)
|
||||
}
|
||||
|
||||
report, err = CompareAllWithFlagMigrations(
|
||||
before,
|
||||
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
empty,
|
||||
pending,
|
||||
)
|
||||
if err != nil || !report.Compatible {
|
||||
t.Fatalf("pending receipt creation = (%#v, %v), want compatible", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresStableForReceiptCleanup(t *testing.T) {
|
||||
consumed := coverageManifest(FlagMigrationConsumed)
|
||||
empty := coverageEmptyManifest()
|
||||
after := coverageMigrationSnapshot(consumed.Migrations[0], true, false)
|
||||
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": after},
|
||||
consumed,
|
||||
empty,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires a stable reference") {
|
||||
t.Fatalf("receipt cleanup without stable = (%#v, %v), want stable-reference error", report, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsInheritedAndOptionalCanonical(t *testing.T) {
|
||||
t.Run("inherited exact state", func(t *testing.T) {
|
||||
pending := coverageManifest(FlagMigrationPending)
|
||||
pending.Migrations[0].Legacy.Before.Scope = "inherited"
|
||||
pending.Migrations[0].Legacy.After.Scope = "inherited"
|
||||
pending.Migrations[0].Canonical.After.Scope = "inherited"
|
||||
consumed := pending
|
||||
consumed.Migrations = append([]FlagMigration(nil), pending.Migrations...)
|
||||
consumed.Migrations[0].State = FlagMigrationConsumed
|
||||
before := coverageMigrationSnapshot(pending.Migrations[0], false, false)
|
||||
after := coverageMigrationSnapshot(pending.Migrations[0], true, false)
|
||||
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil || !report.Compatible {
|
||||
t.Fatalf("inherited migration = (%#v, %v), want compatible", report, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("existing optional canonical becomes required", func(t *testing.T) {
|
||||
pending := coverageManifest(FlagMigrationPending)
|
||||
pending.Migrations[0].Canonical.Before = FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
Scope: "local",
|
||||
}
|
||||
consumed := pending
|
||||
consumed.Migrations = append([]FlagMigration(nil), pending.Migrations...)
|
||||
consumed.Migrations[0].State = FlagMigrationConsumed
|
||||
before := coverageMigrationSnapshot(pending.Migrations[0], false, false)
|
||||
after := coverageMigrationSnapshot(pending.Migrations[0], true, false)
|
||||
|
||||
ordinary := Compare(after, before, "merge-base")
|
||||
if !hasFlagChange(ordinary.Blocking, "flag_became_required", pending.Migrations[0].Command, pending.Migrations[0].Canonical.Name) {
|
||||
t.Fatalf("fixture did not create flag_became_required: %#v", ordinary.Blocking)
|
||||
}
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil || !report.Compatible {
|
||||
t.Fatalf("optional-to-required migration = (%#v, %v), want compatible", report, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsNormalizesAliasesAndKeepsOtherBreakage(t *testing.T) {
|
||||
pending := coverageManifest(FlagMigrationPending)
|
||||
consumed := coverageManifest(FlagMigrationConsumed)
|
||||
migration := pending.Migrations[0]
|
||||
before := coverageMigrationSnapshot(migration, false, true)
|
||||
after := coverageMigrationSnapshot(migration, true, true)
|
||||
|
||||
ordinary := Compare(after, before, "merge-base")
|
||||
if !hasFlagChange(ordinary.Blocking, "flag_became_hidden", "dws chat deliver", migration.Legacy.Name) {
|
||||
t.Fatalf("alias fixture did not create alias-path finding: %#v", ordinary.Blocking)
|
||||
}
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil || !report.Compatible {
|
||||
t.Fatalf("alias-path migration = (%#v, %v), want compatible", report, err)
|
||||
}
|
||||
|
||||
beforeWithOther := before
|
||||
afterWithOther := after
|
||||
beforeWithOther.Commands = append(beforeWithOther.Commands, coverageCommand("dws files", nil, []Flag{{Name: "format", Type: "string"}}, nil))
|
||||
afterWithOther.Commands = append(afterWithOther.Commands, coverageCommand("dws files", nil, nil, nil))
|
||||
report, err = CompareAllWithFlagMigrations(
|
||||
afterWithOther,
|
||||
map[string]Snapshot{"merge-base": beforeWithOther, "stable": beforeWithOther},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unrelated breakage returned lifecycle error: %v", err)
|
||||
}
|
||||
if report.Compatible || !hasFlagChange(report.Comparisons[0].Blocking, "flag_removed", "dws files", "format") {
|
||||
t.Fatalf("unrelated breakage was hidden: %#v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsDoesNotAuthorizePartialReferenceOrRuleDrift(t *testing.T) {
|
||||
pending := coverageManifest(FlagMigrationPending)
|
||||
consumed := coverageManifest(FlagMigrationConsumed)
|
||||
migration := pending.Migrations[0]
|
||||
before := coverageMigrationSnapshot(migration, false, false)
|
||||
after := coverageMigrationSnapshot(migration, true, false)
|
||||
|
||||
partialReference := coverageMigrationSnapshot(migration, false, false)
|
||||
partialReference.Commands[len(partialReference.Commands)-1].LocalFlags[0].Hidden = true
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": before, "stable": partialReference},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("partial non-authority reference returned lifecycle error: %v", err)
|
||||
}
|
||||
if report.Compatible {
|
||||
t.Fatalf("partial reference was incorrectly authorized: %#v", report)
|
||||
}
|
||||
|
||||
beforeWithoutRoot := coverageMigrationSnapshot(migration, false, false)
|
||||
afterWithoutRoot := coverageMigrationSnapshot(migration, true, false)
|
||||
beforeWithoutRoot.Commands = beforeWithoutRoot.Commands[len(beforeWithoutRoot.Commands)-1:]
|
||||
afterWithoutRoot.Commands = afterWithoutRoot.Commands[len(afterWithoutRoot.Commands)-1:]
|
||||
afterWithoutRoot.Rules.ExcludedFlags = []string{"help", "version"}
|
||||
report, err = CompareAllWithFlagMigrations(
|
||||
afterWithoutRoot,
|
||||
map[string]Snapshot{"merge-base": beforeWithoutRoot, "stable": beforeWithoutRoot},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("rule drift returned lifecycle error: %v", err)
|
||||
}
|
||||
if report.Compatible || !hasChangeKind(report.Comparisons[0].Blocking, "snapshot_rules_changed") {
|
||||
t.Fatalf("snapshot rule drift was incorrectly authorized: %#v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func coverageManifestJSON() string {
|
||||
return `{
|
||||
"version": 1,
|
||||
"migrations": [
|
||||
{
|
||||
"command": "dws chat send",
|
||||
"legacy": {
|
||||
"name": "legacy-id",
|
||||
"before": {"present": true, "type": "string", "required": true, "shorthand": "l", "no_opt": "auto", "scope": "local"},
|
||||
"after": {"present": true, "type": "string", "hidden": true, "shorthand": "l", "no_opt": "auto", "scope": "local", "alias_of": "message-id"}
|
||||
},
|
||||
"canonical": {
|
||||
"name": "message-id",
|
||||
"before": {"present": false},
|
||||
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Reviewed exact flag migration."
|
||||
}
|
||||
]
|
||||
}`
|
||||
}
|
||||
|
||||
func coverageManifest(state string) FlagMigrationManifest {
|
||||
return FlagMigrationManifest{
|
||||
Version: FlagMigrationManifestVersion,
|
||||
Migrations: []FlagMigration{
|
||||
{
|
||||
Command: "dws chat send",
|
||||
Legacy: FlagMigrationSide{
|
||||
Name: "legacy-id",
|
||||
Before: FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
Required: true,
|
||||
Shorthand: "l",
|
||||
NoOpt: "auto",
|
||||
Scope: "local",
|
||||
},
|
||||
After: FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
Hidden: true,
|
||||
Shorthand: "l",
|
||||
NoOpt: "auto",
|
||||
Scope: "local",
|
||||
AliasOf: "message-id",
|
||||
},
|
||||
},
|
||||
Canonical: FlagMigrationSide{
|
||||
Name: "message-id",
|
||||
Before: FlagMigrationState{},
|
||||
After: FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
Required: true,
|
||||
Scope: "local",
|
||||
},
|
||||
},
|
||||
State: state,
|
||||
Reason: "Reviewed exact flag migration.",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func coverageEmptyManifest() FlagMigrationManifest {
|
||||
return FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}}
|
||||
}
|
||||
|
||||
func coverageMigrationSnapshot(migration FlagMigration, after, commandAlias bool) Snapshot {
|
||||
legacyState := migration.Legacy.Before
|
||||
canonicalState := migration.Canonical.Before
|
||||
if after {
|
||||
legacyState = migration.Legacy.After
|
||||
canonicalState = migration.Canonical.After
|
||||
}
|
||||
local := []Flag{}
|
||||
inherited := []Flag{}
|
||||
add := func(name string, state FlagMigrationState) {
|
||||
if !state.Present {
|
||||
return
|
||||
}
|
||||
flag := Flag{
|
||||
Name: name,
|
||||
Shorthand: state.Shorthand,
|
||||
Type: state.Type,
|
||||
NoOpt: state.NoOpt,
|
||||
Required: state.Required,
|
||||
Hidden: state.Hidden,
|
||||
AliasOf: state.AliasOf,
|
||||
}
|
||||
if state.Scope == "inherited" {
|
||||
inherited = append(inherited, flag)
|
||||
return
|
||||
}
|
||||
local = append(local, flag)
|
||||
}
|
||||
add(migration.Legacy.Name, legacyState)
|
||||
add(migration.Canonical.Name, canonicalState)
|
||||
aliases := []string{}
|
||||
if commandAlias {
|
||||
aliases = []string{"deliver"}
|
||||
}
|
||||
return coverageSnapshot(
|
||||
coverageCommand("dws", nil, nil, nil),
|
||||
coverageCommand("dws chat", nil, nil, nil),
|
||||
coverageCommand(migration.Command, aliases, local, inherited),
|
||||
)
|
||||
}
|
||||
|
||||
func coverageRootOnlySnapshot() Snapshot {
|
||||
return coverageSnapshot(coverageCommand("dws", nil, nil, nil))
|
||||
}
|
||||
|
||||
func coverageSnapshot(commands ...Command) Snapshot {
|
||||
return Snapshot{
|
||||
SchemaVersion: SchemaVersion,
|
||||
Rules: Rules{
|
||||
ExcludedCommandSubtrees: append([]string(nil), excludedCommandSubtrees...),
|
||||
ExcludedFlags: []string{"help"},
|
||||
},
|
||||
Commands: commands,
|
||||
}
|
||||
}
|
||||
|
||||
func coverageCommand(path string, aliases []string, local, inherited []Flag) Command {
|
||||
if aliases == nil {
|
||||
aliases = []string{}
|
||||
}
|
||||
if local == nil {
|
||||
local = []Flag{}
|
||||
}
|
||||
if inherited == nil {
|
||||
inherited = []Flag{}
|
||||
}
|
||||
return Command{
|
||||
Path: path,
|
||||
Runnable: true,
|
||||
Aliases: aliases,
|
||||
LocalFlags: local,
|
||||
InheritedFlags: inherited,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package interfacesnapshot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type flagMigrationErrorReader struct{}
|
||||
|
||||
func (flagMigrationErrorReader) Read([]byte) (int, error) {
|
||||
return 0, errors.New("injected read failure")
|
||||
}
|
||||
|
||||
const validFlagMigrationManifestJSON = `{
|
||||
"version": 1,
|
||||
"migrations": [
|
||||
{
|
||||
"command": "dws chat message recall",
|
||||
"legacy": {
|
||||
"name": "msg-id",
|
||||
"before": {"present": true, "type": "string", "required": true, "scope": "local"},
|
||||
"after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "message-id"}
|
||||
},
|
||||
"canonical": {
|
||||
"name": "message-id",
|
||||
"before": {"present": false},
|
||||
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Reviewed message identifier flag migration."
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsUnknownFields(t *testing.T) {
|
||||
_, err := ReadFlagMigrationManifest(strings.NewReader(`{
|
||||
"version": 1,
|
||||
"migrations": [],
|
||||
"allow_all_chat_flags": true
|
||||
}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown field") {
|
||||
t.Fatalf("ReadFlagMigrationManifest() error = %v, want unknown field rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsDuplicateFields(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`{"version": 999, "version": 1, "migrations": []}`,
|
||||
strings.Replace(validFlagMigrationManifestJSON, `"state": "pending"`, `"state": "consumed", "state": "pending"`, 1),
|
||||
} {
|
||||
_, err := ReadFlagMigrationManifest(strings.NewReader(input))
|
||||
if err == nil || !strings.Contains(err.Error(), "duplicate field") {
|
||||
t.Fatalf("ReadFlagMigrationManifest() error = %v, want duplicate field rejection", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsNonCanonicalFields(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
`{"Version": 1, "migrations": []}`,
|
||||
`{"version": 1, "Version": 1, "migrations": []}`,
|
||||
strings.Replace(validFlagMigrationManifestJSON, `"state": "pending"`, `"State": "pending"`, 1),
|
||||
} {
|
||||
_, err := ReadFlagMigrationManifest(strings.NewReader(input))
|
||||
if err == nil || !strings.Contains(err.Error(), "non-canonical field") {
|
||||
t.Fatalf("ReadFlagMigrationManifest() error = %v, want non-canonical field rejection", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsNullScalars(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
strings.Replace(validFlagMigrationManifestJSON, `"required": true`, `"required": null`, 1),
|
||||
strings.Replace(validFlagMigrationManifestJSON, `"scope": "local"`, `"scope": "local", "shorthand": null`, 1),
|
||||
`{"version": null, "migrations": []}`,
|
||||
} {
|
||||
_, err := ReadFlagMigrationManifest(strings.NewReader(input))
|
||||
if err == nil || !strings.Contains(err.Error(), "must be") || !strings.Contains(err.Error(), "null") {
|
||||
t.Fatalf("ReadFlagMigrationManifest() error = %v, want scalar null rejection", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadFlagMigrationManifestRejectsWrongJSONTypes(t *testing.T) {
|
||||
for _, input := range []string{
|
||||
strings.Replace(validFlagMigrationManifestJSON, `"required": true`, `"required": "true"`, 1),
|
||||
strings.Replace(validFlagMigrationManifestJSON, `"scope": "local"`, `"scope": false`, 1),
|
||||
`{"version": "1", "migrations": []}`,
|
||||
`{"version": 1, "migrations": {}}`,
|
||||
} {
|
||||
if _, err := ReadFlagMigrationManifest(strings.NewReader(input)); err == nil {
|
||||
t.Fatal("ReadFlagMigrationManifest() accepted a field with the wrong JSON type")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadFlagMigrationManifestReportsReaderFailure(t *testing.T) {
|
||||
_, err := ReadFlagMigrationManifest(flagMigrationErrorReader{})
|
||||
if err == nil || !strings.Contains(err.Error(), "injected read failure") {
|
||||
t.Fatalf("ReadFlagMigrationManifest() error = %v, want reader failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t *testing.T) {
|
||||
if _, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON)); err != nil {
|
||||
t.Fatalf("ReadFlagMigrationManifest(valid) error = %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "unsupported version",
|
||||
input: strings.Replace(validFlagMigrationManifestJSON, `"version": 1`, `"version": 2`, 1),
|
||||
wantErr: "unsupported flag migration manifest version",
|
||||
},
|
||||
{
|
||||
name: "missing migrations array",
|
||||
input: `{"version": 1}`,
|
||||
wantErr: "migrations must be an array",
|
||||
},
|
||||
{
|
||||
name: "null migrations array",
|
||||
input: `{"version": 1, "migrations": null}`,
|
||||
wantErr: "migrations must be an array",
|
||||
},
|
||||
{
|
||||
name: "wildcard command",
|
||||
input: strings.Replace(validFlagMigrationManifestJSON, "dws chat message recall", "dws chat *", 1),
|
||||
wantErr: "exact command path",
|
||||
},
|
||||
{
|
||||
name: "wildcard flag",
|
||||
input: strings.Replace(validFlagMigrationManifestJSON, `"name": "msg-id"`, `"name": "msg-*"`, 1),
|
||||
wantErr: "exact legacy flag",
|
||||
},
|
||||
{
|
||||
name: "empty reason",
|
||||
input: strings.Replace(validFlagMigrationManifestJSON, "Reviewed message identifier flag migration.", "", 1),
|
||||
wantErr: "non-empty reason",
|
||||
},
|
||||
{
|
||||
name: "invalid state",
|
||||
input: strings.Replace(validFlagMigrationManifestJSON, `"state": "pending"`, `"state": "approved"`, 1),
|
||||
wantErr: "invalid state",
|
||||
},
|
||||
{
|
||||
name: "legacy removed",
|
||||
input: strings.Replace(validFlagMigrationManifestJSON, `"after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "message-id"}`, `"after": {"present": false}`, 1),
|
||||
wantErr: "legacy flag must remain present",
|
||||
},
|
||||
{
|
||||
name: "canonical hidden",
|
||||
input: strings.Replace(validFlagMigrationManifestJSON, `"after": {"present": true, "type": "string", "required": true, "scope": "local"}`, `"after": {"present": true, "type": "string", "required": true, "hidden": true, "scope": "local"}`, 1),
|
||||
wantErr: "canonical flag must remain visible",
|
||||
},
|
||||
{
|
||||
name: "canonical remains optional",
|
||||
input: strings.Replace(
|
||||
validFlagMigrationManifestJSON,
|
||||
`"after": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
||||
`"after": {"present": true, "type": "string", "scope": "local"}`,
|
||||
1,
|
||||
),
|
||||
wantErr: "canonical flag must be required after migration",
|
||||
},
|
||||
{
|
||||
name: "canonical was already required",
|
||||
input: strings.Replace(
|
||||
validFlagMigrationManifestJSON,
|
||||
`"before": {"present": false}`,
|
||||
`"before": {"present": true, "type": "string", "required": true, "scope": "local"}`,
|
||||
1,
|
||||
),
|
||||
wantErr: "canonical flag must be absent or optional before migration",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := ReadFlagMigrationManifest(strings.NewReader(test.input))
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("ReadFlagMigrationManifest() error = %v, want %q", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactContracts(t *testing.T) {
|
||||
manifest, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
duplicate := manifest
|
||||
duplicate.Migrations = append(duplicate.Migrations, duplicate.Migrations[0])
|
||||
if err := duplicate.Validate(); err == nil || !strings.Contains(err.Error(), "duplicates") {
|
||||
t.Fatalf("duplicate migration error = %v", err)
|
||||
}
|
||||
|
||||
whitespaceReason := manifest
|
||||
whitespaceReason.Migrations = append([]FlagMigration(nil), manifest.Migrations...)
|
||||
whitespaceReason.Migrations[0].Reason = " reviewed "
|
||||
if err := whitespaceReason.Validate(); err == nil || !strings.Contains(err.Error(), "trimmed") {
|
||||
t.Fatalf("whitespace reason error = %v", err)
|
||||
}
|
||||
|
||||
canonicalDrift := manifest
|
||||
canonicalDrift.Migrations = append([]FlagMigration(nil), manifest.Migrations...)
|
||||
canonicalDrift.Migrations[0].Canonical.Before = FlagMigrationState{
|
||||
Present: true,
|
||||
Type: "string",
|
||||
Scope: "local",
|
||||
}
|
||||
canonicalDrift.Migrations[0].Canonical.After.Type = "stringSlice"
|
||||
if err := canonicalDrift.Validate(); err == nil || !strings.Contains(err.Error(), "canonical flag type") {
|
||||
t.Fatalf("canonical type drift error = %v", err)
|
||||
}
|
||||
|
||||
_, err = ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON + ` {}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "trailing") {
|
||||
t.Fatalf("trailing JSON error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthorizeFlagMigrationsReturnsExactBaseOwnedApproval(t *testing.T) {
|
||||
pending := coverageManifest(FlagMigrationPending)
|
||||
consumed := coverageManifest(FlagMigrationConsumed)
|
||||
migration := pending.Migrations[0]
|
||||
before := coverageMigrationSnapshot(migration, false, false)
|
||||
after := coverageMigrationSnapshot(migration, true, false)
|
||||
|
||||
got, err := AuthorizeFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": before, "stable": before},
|
||||
pending,
|
||||
consumed,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("AuthorizeFlagMigrations() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, pending.Migrations) {
|
||||
t.Fatalf("AuthorizeFlagMigrations() = %#v, want exact base-owned approval %#v", got, pending.Migrations)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package interfacesnapshot
|
||||
|
||||
// flagTypeChange identifies one flag type migration exactly: which command,
|
||||
// which flag, and in which direction. Nothing is matched by wildcard — an entry
|
||||
// that differs in any of the four fields does not apply.
|
||||
//
|
||||
// CommandPath is the canonical Command.Path form, which always includes the root
|
||||
// command name ("dws minutes permission apply"). It is deliberately *not* the
|
||||
// alias-expanded accepted path: a command reachable through an alias enters
|
||||
// compareEffectiveFlags once per accepted spelling, and keying on the accepted
|
||||
// path would let every alias spelling bypass the exemption and re-report the
|
||||
// change.
|
||||
//
|
||||
// This table is duplicated in scripts/policy/interface-baseline/reviewed.go and
|
||||
// the two must stay identical; TestCrossPlatformCoverageReviewedFlagTypeTableMatchesInterfaceBaseline
|
||||
// fails if they drift. The duplication is forced rather than chosen:
|
||||
// check-authoritative-interface-baselines.sh copies the whole
|
||||
// scripts/policy/interface-baseline directory into a worktree checked out at a
|
||||
// historical revision and builds it there, so that copy cannot import a package
|
||||
// this branch adds.
|
||||
type flagTypeChange struct {
|
||||
CommandPath string
|
||||
Flag string
|
||||
From string
|
||||
To string
|
||||
}
|
||||
|
||||
// reviewedFlagTypeChanges enumerates the individually reviewed flag type
|
||||
// migrations this gate accepts. A flag type is part of the published CLI
|
||||
// contract, and a type swap cannot be proven safe from the type names alone —
|
||||
// whether old invocations still parse depends on the concrete value domain the
|
||||
// command enforces. So a migration is accepted only when this exact command,
|
||||
// flag, and old→new pair appear here. Adding an entry is a contract decision
|
||||
// and belongs in review, not in a feature change.
|
||||
//
|
||||
// Entries are direction-sensitive by construction: "string" → "int" is a
|
||||
// separate key from "int" → "string" and only the reviewed direction is
|
||||
// accepted. The reverse is not a candidate for this table — widening an int
|
||||
// flag back to string lets values the parser used to reject reach RunE, which
|
||||
// is a genuine loosening of validation rather than a type migration.
|
||||
//
|
||||
// The table alone does not admit a change: reviewedFlagTypeChange is consulted
|
||||
// only when nothing else about the flag moved. See compareEffectiveFlags.
|
||||
var reviewedFlagTypeChanges = map[flagTypeChange]struct{}{
|
||||
// "dws minutes permission apply --policy" moved from a String flag parsed
|
||||
// with strconv.ParseInt(v, 10, 64) in RunE to a native Int flag, keeping the
|
||||
// same [2,4] domain check in RunE.
|
||||
//
|
||||
// This is admissible because the historical set of *successful* invocations
|
||||
// is a subset of the new one, not merely similar to it:
|
||||
//
|
||||
// - Old: the value had to parse as base-10 and land in [2,4]. That is
|
||||
// exactly "2", "3", "4" plus sign/leading-zero spellings of them
|
||||
// ("+3", "03", "003", ...).
|
||||
// - New: pflag's intValue.Set parses with strconv.ParseInt(s, 0, 64) —
|
||||
// base 0 — and RunE still enforces [2,4]. Every base-10 spelling above
|
||||
// resolves to the same number under base 0, so no historical success
|
||||
// becomes a failure.
|
||||
// - Base 0 additionally accepts "0x3" and friends, which the old parser
|
||||
// rejected. That widens the accepted set; it cannot break a caller who
|
||||
// was already succeeding.
|
||||
// - Values outside [2,4] and non-numeric values still fail. Only the
|
||||
// failure *message and timing* move, from RunE to flag parsing.
|
||||
//
|
||||
// The flag's default value changes from "" to "0" as an unavoidable
|
||||
// consequence of the type. Neither gate compares defaults, and a default is
|
||||
// not reachable by a caller here because RunE requires the flag to be
|
||||
// explicitly set, so this is not a contract change.
|
||||
{CommandPath: "dws minutes permission apply", Flag: "policy", From: "string", To: "int"}: {},
|
||||
}
|
||||
|
||||
// reviewedFlagTypeChange reports whether this exact command, flag and direction
|
||||
// is a reviewed migration. Callers must additionally establish that the rest of
|
||||
// the flag's contract is unchanged before skipping the failure.
|
||||
func reviewedFlagTypeChange(commandPath, flag, from, to string) bool {
|
||||
_, reviewed := reviewedFlagTypeChanges[flagTypeChange{
|
||||
CommandPath: commandPath,
|
||||
Flag: flag,
|
||||
From: from,
|
||||
To: to,
|
||||
}]
|
||||
return reviewed
|
||||
}
|
||||
|
||||
// flagContractOtherwiseChanged reports whether anything this gate checks about a
|
||||
// flag, other than its type, differs between the two revisions. A reviewed type
|
||||
// migration is only accepted when this returns false, so the exemption cannot
|
||||
// carry an unrelated regression in behind it.
|
||||
//
|
||||
// Only the fields compareEffectiveFlags actually enforces are compared. Default
|
||||
// is deliberately excluded: a string → int migration always changes it ("" to
|
||||
// "0"), so including it would make every entry in the table dead. Deprecated is
|
||||
// excluded for the same reason it is not a blocking change on its own.
|
||||
func flagContractOtherwiseChanged(oldFlag, newFlag Flag) bool {
|
||||
if !oldFlag.Required && newFlag.Required {
|
||||
return true
|
||||
}
|
||||
if oldFlag.Shorthand != "" && newFlag.Shorthand != oldFlag.Shorthand {
|
||||
return true
|
||||
}
|
||||
if oldFlag.NoOpt != "" && newFlag.NoOpt != oldFlag.NoOpt {
|
||||
return true
|
||||
}
|
||||
if !oldFlag.Hidden && newFlag.Hidden {
|
||||
return true
|
||||
}
|
||||
if oldFlag.AliasOf != "" && newFlag.AliasOf != oldFlag.AliasOf {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -22,11 +22,16 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
const SchemaVersion = 1
|
||||
const SchemaVersion = 2
|
||||
|
||||
// FlagAliasOfAnnotation records a framework-originated reviewed relationship
|
||||
// between a retained compatibility flag and an exact canonical Cobra flag.
|
||||
const FlagAliasOfAnnotation = runtimeannotate.AnnotationFlagAliasOf
|
||||
|
||||
var (
|
||||
// Framework-owned command subtrees are deliberately excluded. They are
|
||||
@@ -80,6 +85,12 @@ type Flag struct {
|
||||
Required bool `json:"required"`
|
||||
Hidden bool `json:"hidden"`
|
||||
Deprecated string `json:"deprecated,omitempty"`
|
||||
AliasOf string `json:"alias_of,omitempty"`
|
||||
|
||||
// Capture cannot return an error, so it preserves malformed or non-framework
|
||||
// relation evidence here for Validate/Write to reject. Valid snapshots never
|
||||
// serialize this implementation detail.
|
||||
malformedAliasEvidence bool
|
||||
}
|
||||
|
||||
// Capture walks root without rendering help or executing any command. Both
|
||||
@@ -193,6 +204,9 @@ func (s Snapshot) Validate() error {
|
||||
if err := validateFlags(command.Path, "inherited", command.InheritedFlags); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateFlagAliases(command); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -206,21 +220,46 @@ func captureFlags(set *pflag.FlagSet) []Flag {
|
||||
if flag == nil || excludedFlags[flag.Name] {
|
||||
return
|
||||
}
|
||||
aliasOf, malformedAliasEvidence := captureFlagAliasOf(flag)
|
||||
flags = append(flags, Flag{
|
||||
Name: flag.Name,
|
||||
Shorthand: flag.Shorthand,
|
||||
Type: flag.Value.Type(),
|
||||
Default: flag.DefValue,
|
||||
NoOpt: flag.NoOptDefVal,
|
||||
Required: isRequired(flag),
|
||||
Hidden: flag.Hidden,
|
||||
Deprecated: strings.TrimSpace(flag.Deprecated),
|
||||
Name: flag.Name,
|
||||
Shorthand: flag.Shorthand,
|
||||
Type: flag.Value.Type(),
|
||||
Default: flag.DefValue,
|
||||
NoOpt: flag.NoOptDefVal,
|
||||
Required: isRequired(flag),
|
||||
Hidden: flag.Hidden,
|
||||
Deprecated: strings.TrimSpace(flag.Deprecated),
|
||||
AliasOf: aliasOf,
|
||||
malformedAliasEvidence: malformedAliasEvidence,
|
||||
})
|
||||
})
|
||||
sort.Slice(flags, func(i, j int) bool { return flags[i].Name < flags[j].Name })
|
||||
return flags
|
||||
}
|
||||
|
||||
func captureFlagAliasOf(flag *pflag.Flag) (string, bool) {
|
||||
aliasOf, aliasMalformed := exactFlagAnnotation(flag, FlagAliasOfAnnotation)
|
||||
origin, originMalformed := exactFlagAnnotation(flag, runtimeannotate.AnnotationFlagAliasOrigin)
|
||||
if aliasOf == "" && origin == "" && !aliasMalformed && !originMalformed {
|
||||
return "", false
|
||||
}
|
||||
malformed := aliasMalformed || originMalformed || aliasOf == "" ||
|
||||
origin != runtimeannotate.FlagAliasOriginCorecmdV1
|
||||
return aliasOf, malformed
|
||||
}
|
||||
|
||||
func exactFlagAnnotation(flag *pflag.Flag, key string) (string, bool) {
|
||||
values, exists := flag.Annotations[key]
|
||||
if !exists {
|
||||
return "", false
|
||||
}
|
||||
if len(values) != 1 || values[0] == "" || values[0] != strings.TrimSpace(values[0]) {
|
||||
return "", true
|
||||
}
|
||||
return values[0], false
|
||||
}
|
||||
|
||||
func isRequired(flag *pflag.Flag) bool {
|
||||
for _, value := range flag.Annotations[cobra.BashCompOneRequiredFlag] {
|
||||
if value == "true" {
|
||||
@@ -268,3 +307,73 @@ func validateFlags(path, scope string, flags []Flag) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFlagAliases(command Command) error {
|
||||
flags := make(map[string]Flag, len(command.LocalFlags)+len(command.InheritedFlags))
|
||||
ordered := make([]Flag, 0, len(command.LocalFlags)+len(command.InheritedFlags))
|
||||
for _, flag := range command.LocalFlags {
|
||||
flags[flag.Name] = flag
|
||||
ordered = append(ordered, flag)
|
||||
}
|
||||
for _, flag := range command.InheritedFlags {
|
||||
if _, exists := flags[flag.Name]; exists {
|
||||
return fmt.Errorf("command %q contains flag %q in both local and inherited scopes", command.Path, flag.Name)
|
||||
}
|
||||
flags[flag.Name] = flag
|
||||
ordered = append(ordered, flag)
|
||||
}
|
||||
|
||||
for _, flag := range ordered {
|
||||
if flag.malformedAliasEvidence {
|
||||
return fmt.Errorf(
|
||||
"command %q flag --%s must declare one exact %q value and framework origin %q",
|
||||
command.Path,
|
||||
flag.Name,
|
||||
FlagAliasOfAnnotation,
|
||||
runtimeannotate.FlagAliasOriginCorecmdV1,
|
||||
)
|
||||
}
|
||||
if flag.AliasOf == "" {
|
||||
continue
|
||||
}
|
||||
if !isExactFlagName(flag.AliasOf) {
|
||||
return fmt.Errorf(
|
||||
"command %q flag --%s has non-exact alias_of target %q",
|
||||
command.Path,
|
||||
flag.Name,
|
||||
flag.AliasOf,
|
||||
)
|
||||
}
|
||||
if flag.AliasOf == flag.Name {
|
||||
return fmt.Errorf("command %q flag --%s cannot alias itself", command.Path, flag.Name)
|
||||
}
|
||||
target, exists := flags[flag.AliasOf]
|
||||
if !exists {
|
||||
return fmt.Errorf(
|
||||
"command %q flag --%s aliases missing flag --%s",
|
||||
command.Path,
|
||||
flag.Name,
|
||||
flag.AliasOf,
|
||||
)
|
||||
}
|
||||
if target.AliasOf != "" {
|
||||
return fmt.Errorf(
|
||||
"command %q flag --%s aliases --%s, which is itself an alias",
|
||||
command.Path,
|
||||
flag.Name,
|
||||
flag.AliasOf,
|
||||
)
|
||||
}
|
||||
if flag.Type != target.Type {
|
||||
return fmt.Errorf(
|
||||
"command %q alias flags --%s and --%s have different types %q and %q",
|
||||
command.Path,
|
||||
flag.Name,
|
||||
flag.AliasOf,
|
||||
flag.Type,
|
||||
target.Type,
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -15,9 +15,12 @@ package interfacesnapshot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -95,7 +98,7 @@ func TestCrossPlatformCoverageCaptureUsesStableNoiseRulesAndFlagScopes(t *testin
|
||||
|
||||
func TestCrossPlatformCoverageReadRejectsUnknownSnapshotFields(t *testing.T) {
|
||||
input := bytes.NewBufferString(`{
|
||||
"schema_version": 1,
|
||||
"schema_version": 2,
|
||||
"rules": {"excluded_command_subtrees": [], "excluded_flags": []},
|
||||
"commands": [],
|
||||
"future_field": true
|
||||
@@ -105,6 +108,166 @@ func TestCrossPlatformCoverageReadRejectsUnknownSnapshotFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCaptureRoundTripsFrameworkFlagAlias(t *testing.T) {
|
||||
if FlagAliasOfAnnotation != runtimeannotate.AnnotationFlagAliasOf {
|
||||
t.Fatalf(
|
||||
"flag alias annotation key drifted: snapshot=%q runtime=%q",
|
||||
FlagAliasOfAnnotation,
|
||||
runtimeannotate.AnnotationFlagAliasOf,
|
||||
)
|
||||
}
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
leaf := &cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}}
|
||||
corecmd.RegisterFlags(leaf, []corecmd.FlagSpec{{
|
||||
Name: "message-id",
|
||||
Usage: "message ID",
|
||||
Aliases: []string{"open-conversation-id"},
|
||||
}})
|
||||
root.AddCommand(leaf)
|
||||
|
||||
snapshot := Capture(root)
|
||||
flag := flagIndex(commandIndex(snapshot)["dws send"].LocalFlags)["open-conversation-id"]
|
||||
if flag.AliasOf != "message-id" {
|
||||
t.Fatalf("captured alias_of = %q, want message-id", flag.AliasOf)
|
||||
}
|
||||
|
||||
var encoded bytes.Buffer
|
||||
if err := Write(&encoded, snapshot); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
decoded, err := Read(bytes.NewReader(encoded.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("Read: %v", err)
|
||||
}
|
||||
decodedFlag := flagIndex(commandIndex(decoded)["dws send"].LocalFlags)["open-conversation-id"]
|
||||
if decodedFlag.AliasOf != "message-id" {
|
||||
t.Fatalf("round-tripped alias_of = %q, want message-id", decodedFlag.AliasOf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCaptureRejectsUntrustedFlagAliasEvidence(t *testing.T) {
|
||||
for _, origin := range []string{"", "handwritten.alias.v1"} {
|
||||
t.Run(origin, func(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
leaf := &cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}}
|
||||
leaf.Flags().String("message-id", "", "canonical message ID")
|
||||
leaf.Flags().String("legacy-id", "", "legacy message ID")
|
||||
legacy := leaf.Flags().Lookup("legacy-id")
|
||||
legacy.Annotations = map[string][]string{
|
||||
FlagAliasOfAnnotation: {"message-id"},
|
||||
}
|
||||
if origin != "" {
|
||||
legacy.Annotations[runtimeannotate.AnnotationFlagAliasOrigin] = []string{origin}
|
||||
}
|
||||
root.AddCommand(leaf)
|
||||
|
||||
if err := Capture(root).Validate(); err == nil {
|
||||
t.Fatal("Validate accepted alias evidence outside corecmd FlagSpec.Aliases")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCaptureRejectsMultipleNonEmptyFlagAliases(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
leaf := &cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}}
|
||||
leaf.Flags().String("message-id", "", "canonical message ID")
|
||||
leaf.Flags().String("other-id", "", "other ID")
|
||||
leaf.Flags().String("legacy-id", "", "legacy ID")
|
||||
legacy := leaf.Flags().Lookup("legacy-id")
|
||||
legacy.Annotations = map[string][]string{
|
||||
FlagAliasOfAnnotation: {"message-id", "other-id"},
|
||||
}
|
||||
root.AddCommand(leaf)
|
||||
|
||||
snapshot := Capture(root)
|
||||
if err := snapshot.Validate(); err == nil {
|
||||
t.Fatal("Validate accepted multiple non-empty alias annotation values")
|
||||
}
|
||||
var encoded bytes.Buffer
|
||||
if err := Write(&encoded, snapshot); err == nil {
|
||||
t.Fatal("Write accepted multiple non-empty alias annotation values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateRejectsMalformedFlagAliasState(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
aliasOf string
|
||||
}{
|
||||
{name: "self", aliasOf: "legacy-id"},
|
||||
{name: "leading dashes", aliasOf: "--message-id"},
|
||||
{name: "non exact", aliasOf: "message id"},
|
||||
{name: "missing target", aliasOf: "missing-id"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
snapshot := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommandWithFlagScopes(
|
||||
"dws send",
|
||||
[]Flag{
|
||||
{Name: "legacy-id", Type: "string", AliasOf: test.aliasOf},
|
||||
{Name: "message-id", Type: "string"},
|
||||
},
|
||||
nil,
|
||||
),
|
||||
)
|
||||
if err := snapshot.Validate(); err == nil {
|
||||
t.Fatalf("Validate accepted alias_of %q", test.aliasOf)
|
||||
}
|
||||
encoded, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
if _, err := Read(bytes.NewReader(encoded)); err == nil {
|
||||
t.Fatalf("Read accepted alias_of %q", test.aliasOf)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateRejectsFlagAliasChainTypeAndScopeConflicts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
local []Flag
|
||||
inherited []Flag
|
||||
}{
|
||||
{
|
||||
name: "alias chain",
|
||||
local: []Flag{
|
||||
{Name: "legacy-id", Type: "string", AliasOf: "middle-id"},
|
||||
{Name: "middle-id", Type: "string", AliasOf: "message-id"},
|
||||
{Name: "message-id", Type: "string"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "different types",
|
||||
local: []Flag{
|
||||
{Name: "legacy-id", Type: "string", AliasOf: "message-id"},
|
||||
{Name: "message-id", Type: "int"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "same flag in local and inherited scopes",
|
||||
local: []Flag{{Name: "legacy-id", Type: "string"}},
|
||||
inherited: []Flag{{Name: "legacy-id", Type: "string"}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
snapshot := testSnapshot(
|
||||
testCommand("dws"),
|
||||
testCommandWithFlagScopes("dws send", test.local, test.inherited),
|
||||
)
|
||||
if err := snapshot.Validate(); err == nil {
|
||||
t.Fatalf("Validate accepted invalid alias contract: %#v", snapshot.Commands[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareBlocksCandidateSiblingAliasCollision(t *testing.T) {
|
||||
base := testSnapshot(testCommand("dws"))
|
||||
current := testSnapshot(
|
||||
|
||||
@@ -71,8 +71,8 @@ func TestCrossPlatformCoverageDocSemanticCatalogExactlyCoversRegisteredSurface(t
|
||||
}
|
||||
|
||||
wantPrimaries := map[string]string{
|
||||
"+find-doc": "+search", "+doc-append": "+update", "+version-save": "+history-save",
|
||||
"+version-list": "+history-list", "+version-revert": "+history-revert", "+share-doc": "+share",
|
||||
"+find-doc": "+search", "+doc-append": "+update", "+history-save": "+version-save",
|
||||
"+history-list": "+version-list", "+history-revert": "+version-revert", "+share-doc": "+share",
|
||||
}
|
||||
for command, primary := range wantPrimaries {
|
||||
item := registered[command]
|
||||
|
||||
@@ -21,11 +21,18 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
)
|
||||
|
||||
const (
|
||||
chatSearchHardPageLimit = 500
|
||||
chatSearchMaxWindowSize = 100
|
||||
chatListAllHardPageLimit = 500
|
||||
)
|
||||
|
||||
// ChatSearch searches groups by keyword (search_groups on the im server).
|
||||
var ChatSearch = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
@@ -33,8 +40,8 @@ var ChatSearch = shortcut.Shortcut{
|
||||
Aliases: []string{"+chat-group-search", "+search-group"},
|
||||
SinglePositionalAliasFor: "query",
|
||||
Product: "im",
|
||||
Description: "按关键词搜索群聊",
|
||||
Intent: "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。",
|
||||
Description: "按关键词分页搜索群聊,支持有界自动翻页和完整性检查",
|
||||
Intent: "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;默认读取一页,明确要求全部候选时加 --page-all,并用 --page-limit 保持有界。结果按 openConversationId 去重,并公开 complete、hasMore、nextCursor、stopReason 和 failures,避免把截断或失败结果误当完整候选集。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
@@ -48,15 +55,15 @@ var ChatSearch = shortcut.Shortcut{
|
||||
CLIPath: "chat +chat-search",
|
||||
PrimaryCLIPath: "chat +chat-search",
|
||||
},
|
||||
Description: "按关键词搜索群聊",
|
||||
Description: "按关键词分页搜索群聊,支持有界自动翻页和完整性检查",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按关键词搜索群聊",
|
||||
UseWhen: []string{"当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。"},
|
||||
AgentSummary: "按关键词分页搜索群聊,支持有界自动翻页和完整性检查",
|
||||
UseWhen: []string{"当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;默认读取一页,明确要求全部候选时加 --page-all,并用 --page-limit 保持有界。结果按 openConversationId 去重,并公开 complete、hasMore、nextCursor、stopReason 和 failures,避免把截断或失败结果误当完整候选集。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws chat +chat-search --query \"项目冲刺\""},
|
||||
},
|
||||
@@ -64,30 +71,327 @@ var ChatSearch = shortcut.Shortcut{
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "query", Type: shortcut.FlagString, Desc: "群名称关键词"},
|
||||
{Name: "keyword", Type: shortcut.FlagString, Desc: "--query 的别名", Hidden: true},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页返回数量"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页返回数量;显式页大小必须在 1-100 之间"},
|
||||
{Name: "page-size", Type: shortcut.FlagInt, Desc: "--limit 的 Lark 对齐别名;显式页大小必须在 1-100 之间"},
|
||||
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Default: "0", Desc: "分页游标,翻页传 nextCursor"},
|
||||
{Name: "page-token", Type: shortcut.FlagString, Desc: "--cursor 的 Lark 对齐别名"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部群搜索分页;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已设置免打扰的群聊"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"query", "keyword"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"limit", "page-size", "size"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"cursor", "page-token"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit", "page-size"}, Description: "显式页大小必须在 1-100 之间"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-search --query "项目冲刺"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
query := rt.Str("query")
|
||||
if query == "" {
|
||||
query = rt.Str("keyword")
|
||||
}
|
||||
params := map[string]any{
|
||||
"keyword": query,
|
||||
"limit": rt.IntFirst("limit", "size"),
|
||||
"cursor": rt.Str("cursor"),
|
||||
}
|
||||
if rt.Bool("exclude-muted") {
|
||||
params["excludeMuted"] = true
|
||||
}
|
||||
return rt.CallMCP("search_groups", params)
|
||||
Tips: []string{
|
||||
`dws chat +chat-search --query "项目冲刺"`,
|
||||
`dws chat +chat-search --query "项目" --page-size 100 --page-all --page-limit 20`,
|
||||
},
|
||||
Validate: validateChatSearch,
|
||||
Execute: executeChatSearch,
|
||||
}
|
||||
|
||||
func validateChatSearch(rt *shortcut.RuntimeContext) error {
|
||||
if size := chatSearchPageSize(rt); size < 1 || size > 100 {
|
||||
return apperrors.NewValidation("--limit/--page-size/--size 必须在 1-100 之间")
|
||||
}
|
||||
if !rt.Bool("page-all") && rt.Changed("page-limit") {
|
||||
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
|
||||
}
|
||||
if rt.Bool("page-all") {
|
||||
if limit := rt.Int("page-limit"); limit < 1 || limit > chatSearchHardPageLimit {
|
||||
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func chatSearchPageSize(rt *shortcut.RuntimeContext) int {
|
||||
if rt.Changed("page-size") {
|
||||
return rt.Int("page-size")
|
||||
}
|
||||
if rt.Changed("size") {
|
||||
return rt.Int("size")
|
||||
}
|
||||
return rt.Int("limit")
|
||||
}
|
||||
|
||||
func chatSearchStartCursor(rt *shortcut.RuntimeContext) string {
|
||||
if rt.Changed("page-token") {
|
||||
if value := strings.TrimSpace(rt.Str("page-token")); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
if value := strings.TrimSpace(rt.Str("cursor")); value != "" {
|
||||
return value
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
func chatSearchRequestParams(query string, pageSize int, cursor string, excludeMuted bool) map[string]any {
|
||||
params := map[string]any{
|
||||
"keyword": query,
|
||||
"limit": pageSize,
|
||||
"cursor": cursor,
|
||||
}
|
||||
if excludeMuted {
|
||||
params["excludeMuted"] = true
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func executeChatSearch(rt *shortcut.RuntimeContext) error {
|
||||
query := strings.TrimSpace(rt.StrFirst("query", "keyword"))
|
||||
pageSize := chatSearchPageSize(rt)
|
||||
requestPageSize := pageSize
|
||||
pageLimit := 1
|
||||
if rt.Bool("page-all") {
|
||||
pageLimit = rt.Int("page-limit")
|
||||
}
|
||||
cursor := chatSearchStartCursor(rt)
|
||||
if rt.DryRun() {
|
||||
return rt.CallMCP("search_groups", chatSearchRequestParams(query, pageSize, cursor, rt.Bool("exclude-muted")))
|
||||
}
|
||||
initialCursor := cursor
|
||||
seenCursors := map[string]bool{cursor: true}
|
||||
seenChats := map[string]bool{}
|
||||
chats := make([]map[string]any, 0)
|
||||
failures := make([]map[string]any, 0)
|
||||
pagesFetched := 0
|
||||
paginationKnown := true
|
||||
complete := false
|
||||
hasMore := false
|
||||
nextCursor := ""
|
||||
stopReason := "source_complete"
|
||||
truncatedByPageLimit := false
|
||||
maxWindowProbeUsed := false
|
||||
completionEvidence := ""
|
||||
|
||||
for pagesFetched < pageLimit {
|
||||
params := chatSearchRequestParams(query, requestPageSize, cursor, rt.Bool("exclude-muted"))
|
||||
data, err := rt.CallMCPData("im", "search_groups", params)
|
||||
if err != nil {
|
||||
if pagesFetched == 0 {
|
||||
return err
|
||||
}
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched + 1, "stage": "read", "cursor": cursor, "error": err.Error(),
|
||||
})
|
||||
stopReason = "read_failure"
|
||||
break
|
||||
}
|
||||
pagesFetched++
|
||||
pageItems := chatSearchItems(data)
|
||||
for _, chat := range pageItems {
|
||||
id := strings.TrimSpace(fmt.Sprint(chat["openConversationId"]))
|
||||
if id != "" && id != "<nil>" && seenChats[id] {
|
||||
continue
|
||||
}
|
||||
if id != "" && id != "<nil>" {
|
||||
seenChats[id] = true
|
||||
}
|
||||
chats = append(chats, chat)
|
||||
}
|
||||
|
||||
page := chatmsg.Pagination(data)
|
||||
pageHasMore, hasMoreKnown := page["hasMore"].(bool)
|
||||
nextCursor = chatSearchCursorString(page["nextCursor"])
|
||||
if !hasMoreKnown {
|
||||
switch {
|
||||
case nextCursor != "":
|
||||
pageHasMore = true
|
||||
case len(pageItems) < requestPageSize:
|
||||
paginationKnown = false
|
||||
complete = true
|
||||
hasMore = false
|
||||
stopReason = "legacy_short_page"
|
||||
default:
|
||||
paginationKnown = false
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "群搜索返回满页结果但缺少 hasMore/nextCursor,无法证明结果完整",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
}
|
||||
if complete || len(failures) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
hasMore = pageHasMore
|
||||
if !hasMore {
|
||||
// A full first page with hasMore=false/no cursor is the live legacy
|
||||
// shape that can hide additional rows. Once a prior page supplied a
|
||||
// valid continuation cursor, its terminal hasMore=false is trustworthy.
|
||||
fullPageWithoutCursor := len(pageItems) >= requestPageSize && nextCursor == "" &&
|
||||
(pagesFetched == 1 || (maxWindowProbeUsed && pagesFetched == 2))
|
||||
if fullPageWithoutCursor {
|
||||
paginationKnown = false
|
||||
if rt.Bool("page-all") && initialCursor == "0" && !maxWindowProbeUsed && requestPageSize < chatSearchMaxWindowSize && pagesFetched < pageLimit {
|
||||
maxWindowProbeUsed = true
|
||||
requestPageSize = chatSearchMaxWindowSize
|
||||
cursor = initialCursor
|
||||
stopReason = "max_window_probe"
|
||||
continue
|
||||
}
|
||||
if !rt.Bool("page-all") {
|
||||
complete = false
|
||||
stopReason = "single_page_full_untrusted"
|
||||
break
|
||||
}
|
||||
if pagesFetched >= pageLimit && requestPageSize < chatSearchMaxWindowSize {
|
||||
truncatedByPageLimit = true
|
||||
complete = false
|
||||
stopReason = "page_limit"
|
||||
break
|
||||
}
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "群搜索在最大窗口仍返回满页且没有可用 nextCursor,无法证明结果完整",
|
||||
})
|
||||
complete = false
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
complete = true
|
||||
nextCursor = ""
|
||||
if maxWindowProbeUsed {
|
||||
paginationKnown = false
|
||||
completionEvidence = "max_window_short_page"
|
||||
stopReason = "max_window_short_page"
|
||||
} else {
|
||||
completionEvidence = "backend_has_more_false_short_page"
|
||||
stopReason = "source_complete"
|
||||
}
|
||||
break
|
||||
}
|
||||
if nextCursor == "" || seenCursors[nextCursor] {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "群搜索返回 hasMore=true,但 nextCursor 缺失或未前进",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
if !rt.Bool("page-all") {
|
||||
stopReason = "single_page"
|
||||
break
|
||||
}
|
||||
seenCursors[nextCursor] = true
|
||||
cursor = nextCursor
|
||||
}
|
||||
|
||||
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
|
||||
truncatedByPageLimit = rt.Bool("page-all")
|
||||
if truncatedByPageLimit {
|
||||
stopReason = "page_limit"
|
||||
}
|
||||
}
|
||||
payload := map[string]any{
|
||||
"query": query,
|
||||
"count": len(chats),
|
||||
"chats": chats,
|
||||
"pagesFetched": pagesFetched,
|
||||
"paginationKnown": paginationKnown,
|
||||
"complete": complete && len(failures) == 0,
|
||||
"hasMore": hasMore,
|
||||
"nextCursor": nextCursor,
|
||||
"stopReason": stopReason,
|
||||
"truncatedByPageLimit": truncatedByPageLimit,
|
||||
"failedCount": len(failures),
|
||||
"failures": failures,
|
||||
"partial": len(failures) > 0 && len(chats) > 0,
|
||||
"requestedPageSize": pageSize,
|
||||
"effectivePageSize": requestPageSize,
|
||||
}
|
||||
if completionEvidence != "" {
|
||||
payload["completionEvidence"] = completionEvidence
|
||||
}
|
||||
if rt.Bool("exclude-muted") {
|
||||
payload["filter"] = map[string]any{"excludeMuted": true}
|
||||
}
|
||||
if err := rt.Output(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return apperrors.NewAPI(
|
||||
fmt.Sprintf("群搜索分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)),
|
||||
apperrors.WithOperation("im/search_groups"),
|
||||
apperrors.WithReason("chat_search_incomplete"),
|
||||
apperrors.WithOrigin("mcp_gateway"),
|
||||
apperrors.WithFailureStage("pagination"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(true),
|
||||
apperrors.WithHint("请根据 failures 和 nextCursor 重试"),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func chatSearchItems(data map[string]any) []map[string]any {
|
||||
if data == nil {
|
||||
return []map[string]any{}
|
||||
}
|
||||
scopes := []map[string]any{data}
|
||||
for _, key := range []string{"result", "data"} {
|
||||
if nested, ok := data[key].(map[string]any); ok {
|
||||
scopes = append(scopes, nested)
|
||||
}
|
||||
}
|
||||
for _, scope := range scopes {
|
||||
for _, key := range []string{"groups", "chats", "items", "list", "result"} {
|
||||
if raw, ok := scope[key].([]any); ok {
|
||||
return projectChatSearchItems(raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
return []map[string]any{}
|
||||
}
|
||||
|
||||
func projectChatSearchItems(raw []any) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
group, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
projected := make(map[string]any, len(group)+1)
|
||||
for key, value := range group {
|
||||
projected[key] = value
|
||||
}
|
||||
if _, exists := projected["openConversationId"]; !exists {
|
||||
for _, key := range []string{"conversationId", "chatId", "id"} {
|
||||
if value, ok := group[key]; ok {
|
||||
projected["openConversationId"] = value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, exists := projected["name"]; !exists {
|
||||
for _, key := range []string{"title", "conversationName"} {
|
||||
if value, ok := group[key]; ok {
|
||||
projected["name"] = value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, projected)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func chatSearchCursorString(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprint(value))
|
||||
if text == "" || text == "<nil>" || text == "0" {
|
||||
return ""
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// ChatMembersList lists members of a group (get_group_members, chat server).
|
||||
@@ -575,27 +879,190 @@ var ChatListAll = shortcut.Shortcut{
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页返回数量(最大 200)"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页返回数量;--limit 必须在 1-200 之间"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传 nextCursor"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部已加入群;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-list-all --limit 50`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{}
|
||||
if rt.Int("limit") > 0 {
|
||||
params["limit"] = rt.Int("limit")
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-200 之间"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws chat +chat-list-all --limit 50`,
|
||||
`dws chat +chat-list-all --limit 200 --page-all --page-limit 50`,
|
||||
},
|
||||
Validate: validateChatListAll,
|
||||
Execute: executeChatListAll,
|
||||
}
|
||||
|
||||
func validateChatListAll(rt *shortcut.RuntimeContext) error {
|
||||
if limit := rt.Int("limit"); limit < 1 || limit > 200 {
|
||||
return apperrors.NewValidation("--limit 必须在 1-200 之间")
|
||||
}
|
||||
if !rt.Bool("page-all") && rt.Changed("page-limit") {
|
||||
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
|
||||
}
|
||||
if rt.Bool("page-all") {
|
||||
if limit := rt.Int("page-limit"); limit < 1 || limit > chatListAllHardPageLimit {
|
||||
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
||||
}
|
||||
if c := rt.Str("cursor"); c != "" && c != "0" {
|
||||
params["cursor"] = c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeChatListAll(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{}
|
||||
if rt.Int("limit") > 0 {
|
||||
params["limit"] = rt.Int("limit")
|
||||
}
|
||||
if c := rt.Str("cursor"); c != "" && c != "0" {
|
||||
params["cursor"] = c
|
||||
}
|
||||
if rt.Bool("page-all") {
|
||||
payload, err := readAllChatListAll(rt, params)
|
||||
if outputErr := rt.Output(payload); outputErr != nil {
|
||||
return outputErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
data, err := rt.CallMCPData("im", "list_my_groups_pagination", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groups := chatListAllProject(data)
|
||||
payload := map[string]any{"count": len(groups), "groups": groups}
|
||||
chatmsg.ApplyPagination(payload, data)
|
||||
payload["pagesFetched"] = 1
|
||||
if payload["complete"] == true {
|
||||
payload["stopReason"] = "source_complete"
|
||||
} else {
|
||||
payload["stopReason"] = "single_page"
|
||||
}
|
||||
return rt.Output(payload)
|
||||
}
|
||||
|
||||
func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any) (map[string]any, error) {
|
||||
pageLimit := rt.Int("page-limit")
|
||||
cursorValue := baseParams["cursor"]
|
||||
cursorKey := chatListAllCursorString(cursorValue)
|
||||
if cursorKey == "" {
|
||||
cursorKey = "0"
|
||||
}
|
||||
seenCursors := map[string]bool{cursorKey: true}
|
||||
seenGroups := map[string]bool{}
|
||||
allGroups := make([]map[string]any, 0)
|
||||
failures := make([]map[string]any, 0)
|
||||
pagesFetched := 0
|
||||
complete := false
|
||||
hasMore := false
|
||||
stopReason := "source_complete"
|
||||
truncatedByPageLimit := false
|
||||
var nextCursor any
|
||||
|
||||
for pagesFetched < pageLimit {
|
||||
params := map[string]any{"limit": baseParams["limit"]}
|
||||
if cursorKey != "0" {
|
||||
params["cursor"] = cursorValue
|
||||
}
|
||||
data, err := rt.CallMCPData("im", "list_my_groups_pagination", params)
|
||||
if err != nil {
|
||||
return err
|
||||
if pagesFetched == 0 {
|
||||
return nil, err
|
||||
}
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched + 1, "stage": "read", "cursor": cursorKey, "error": err.Error(),
|
||||
})
|
||||
stopReason = "read_failure"
|
||||
break
|
||||
}
|
||||
groups := chatListAllProject(data)
|
||||
payload := map[string]any{"count": len(groups), "groups": groups}
|
||||
chatmsg.ApplyPagination(payload, data)
|
||||
return rt.Output(payload)
|
||||
},
|
||||
pagesFetched++
|
||||
pageGroups := chatListAllProject(data)
|
||||
for _, group := range pageGroups {
|
||||
id := strings.TrimSpace(fmt.Sprint(group["openConversationId"]))
|
||||
if id == "<nil>" {
|
||||
id = ""
|
||||
}
|
||||
if id != "" && seenGroups[id] {
|
||||
continue
|
||||
}
|
||||
if id != "" {
|
||||
seenGroups[id] = true
|
||||
}
|
||||
allGroups = append(allGroups, group)
|
||||
}
|
||||
|
||||
page := chatmsg.Pagination(data)
|
||||
pageHasMore, known := page["hasMore"].(bool)
|
||||
if !known {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "已加入群列表下层未返回可靠的 hasMore,无法证明结果完整",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
hasMore = pageHasMore
|
||||
if !hasMore {
|
||||
complete = true
|
||||
nextCursor = nil
|
||||
stopReason = "source_complete"
|
||||
break
|
||||
}
|
||||
nextCursor = page["nextCursor"]
|
||||
nextKey := chatListAllCursorString(nextCursor)
|
||||
if nextKey == "" || seenCursors[nextKey] {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "已加入群列表下层返回 hasMore=true,但 nextCursor 缺失、无效或未前进",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
seenCursors[nextKey] = true
|
||||
cursorKey = nextKey
|
||||
cursorValue = nextCursor
|
||||
}
|
||||
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
|
||||
truncatedByPageLimit = true
|
||||
stopReason = "page_limit"
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"count": len(allGroups), "groups": allGroups,
|
||||
"pagesFetched": pagesFetched, "paginationKnown": true,
|
||||
"complete": complete && len(failures) == 0, "hasMore": hasMore,
|
||||
"stopReason": stopReason, "truncatedByPageLimit": truncatedByPageLimit,
|
||||
"failedCount": len(failures), "failures": failures,
|
||||
"partial": len(failures) > 0 && len(allGroups) > 0,
|
||||
}
|
||||
if hasMore && nextCursor != nil {
|
||||
payload["nextCursor"] = nextCursor
|
||||
}
|
||||
if len(failures) == 0 {
|
||||
return payload, nil
|
||||
}
|
||||
return payload, apperrors.NewAPI(
|
||||
fmt.Sprintf("已加入群列表分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)),
|
||||
apperrors.WithOperation("im/list_my_groups_pagination"),
|
||||
apperrors.WithReason("chat_list_all_incomplete"),
|
||||
apperrors.WithOrigin("mcp_gateway"),
|
||||
apperrors.WithFailureStage("pagination"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(true),
|
||||
apperrors.WithHint("请根据 failures 和 nextCursor 重试"),
|
||||
)
|
||||
}
|
||||
|
||||
func chatListAllCursorString(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprint(value))
|
||||
if text == "" || text == "<nil>" || text == "0" {
|
||||
return ""
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// chatListAllProject reshapes list_my_groups_pagination into a clean group list
|
||||
|
||||
@@ -6,6 +6,8 @@ package chat
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
@@ -213,6 +215,19 @@ func TestCrossPlatformCoverageChatListProjectionHelpers(t *testing.T) {
|
||||
if _, err := normalizeChatListTypes([]string{"bad"}); err == nil {
|
||||
t.Fatal("invalid type accepted")
|
||||
}
|
||||
var tuple map[string]any
|
||||
if err := json.Unmarshal([]byte(`{"result":[[{"openConversationId":"tuple"}],2,true]}`), &tuple); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err := chatListPagination(tuple)
|
||||
if err != nil || page["nextCursor"] != float64(2) || page["hasMore"] != true || len(chatListProject(tuple)) != 1 {
|
||||
t.Fatalf("tuple normalization rows=%#v page=%#v err=%v", chatListProject(tuple), page, err)
|
||||
}
|
||||
ordinary := map[string]any{"result": []any{map[string]any{"openConversationId": "ordinary"}}}
|
||||
page, err = chatListPagination(ordinary)
|
||||
if err != nil || page != nil || len(chatListProject(ordinary)) != 1 {
|
||||
t.Fatalf("ordinary list rows=%#v page=%#v err=%v", chatListProject(ordinary), page, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatListExecuteAndHelperEdges(t *testing.T) {
|
||||
@@ -317,3 +332,310 @@ func TestCrossPlatformCoverageChatListCursorAndPageSizeDirect(t *testing.T) {
|
||||
t.Fatal("expected executeChatList cursor error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatListPageAllMergesBeforeTypeFilter(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
|
||||
"im/list_all_conversations": {
|
||||
`{"result":{"conversations":[{"openConversationId":"direct-1","title":"单聊","singleChat":true}],"hasMore":true,"nextCursor":2}}`,
|
||||
`{"result":{"conversations":[{"openConversationId":"direct-1","title":"重复单聊","singleChat":true},{"openConversationId":"group-1","title":"项目群","singleChat":false}],"hasMore":false}}`,
|
||||
},
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+chat-list", "--types", "group", "--page-size", "1", "--page-all", "--page-limit", "5"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 2 || fake.calls[1].args["cursor"] != 2 {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(1) || payload["pagesFetched"] != float64(2) || payload["complete"] != true || payload["hasMore"] != false {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
chat := payload["chats"].([]any)[0].(map[string]any)
|
||||
if chat["openConversationId"] != "group-1" || chat["conversationType"] != "group" {
|
||||
t.Fatalf("chat = %#v", chat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatListProbesMaximumWindowWhenBackendFalselyEndsFullFirstPage(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
|
||||
"im/list_all_conversations": {
|
||||
`{"result":{"conversations":[{"openConversationId":"group-1","title":"项目一群","singleChat":false}],"hasMore":false}}`,
|
||||
`{"result":{"conversations":[{"openConversationId":"group-1","title":"项目一群","singleChat":false},{"openConversationId":"group-2","title":"项目二群","singleChat":false}],"hasMore":false}}`,
|
||||
},
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+chat-list", "--page-size", "1", "--page-all", "--page-limit", "5"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 2 || fake.calls[0].args["limit"] != 1 || fake.calls[1].args["limit"] != 100 {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(2) || payload["complete"] != true || payload["paginationKnown"] != false ||
|
||||
payload["completionEvidence"] != "max_window_short_page" || payload["stopReason"] != "max_window_short_page" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatListPageLimitPublishesContinuation(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_all_conversations": `{"result":{"conversations":[{"openConversationId":"group-1","title":"项目群","singleChat":false}],"hasMore":true,"nextCursor":9}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+chat-list", "--page-all", "--page-limit", "1"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["complete"] != false || payload["hasMore"] != true || payload["nextCursor"] != float64(9) ||
|
||||
payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" || payload["failedCount"] != float64(0) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatListLaterPageFailureKeepsPartialLedger(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{
|
||||
sequenceResponses: map[string][]string{
|
||||
"im/list_all_conversations": {`{"result":{"conversations":[{"openConversationId":"group-1","title":"项目群","singleChat":false}],"hasMore":true,"nextCursor":2}}`},
|
||||
},
|
||||
failProductToolAt: map[string]int{"im/list_all_conversations": 2},
|
||||
}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+chat-list", "--page-all"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("expected later-page error")
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(1) || payload["complete"] != false || payload["partial"] != true ||
|
||||
payload["failedCount"] != float64(1) || payload["stopReason"] != "read_failure" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatListPaginationValidation(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"--page-limit", "2"},
|
||||
{"--page-all", "--page-limit", "0"},
|
||||
{"--page-all", "--page-limit", "501"},
|
||||
} {
|
||||
helpers.InitDeps(&larkAlignmentCaller{})
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs(append([]string{"chat", "+chat-list"}, args...))
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatalf("invalid args succeeded: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := chatListNextCursor(float64(1.5)); err == nil {
|
||||
t.Fatal("fractional cursor unexpectedly accepted")
|
||||
}
|
||||
if _, err := chatListNextCursor(struct{}{}); err == nil {
|
||||
t.Fatal("unsupported cursor unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatListAdditionalPaginationEdges(t *testing.T) {
|
||||
run := func(t *testing.T, fake *larkAlignmentCaller, args ...string) (map[string]any, error) {
|
||||
t.Helper()
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs(append([]string{"chat", "+chat-list"}, args...))
|
||||
err := root.Execute()
|
||||
if output.Len() == 0 {
|
||||
return nil, err
|
||||
}
|
||||
var payload map[string]any
|
||||
if decodeErr := json.Unmarshal(output.Bytes(), &payload); decodeErr != nil {
|
||||
t.Fatal(decodeErr)
|
||||
}
|
||||
return payload, err
|
||||
}
|
||||
|
||||
t.Run("first read failure", func(t *testing.T) {
|
||||
_, err := run(t, &larkAlignmentCaller{failProductToolAt: map[string]int{"im/list_all_conversations": 1}})
|
||||
if err == nil {
|
||||
t.Fatal("first read failure unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cursor without hasMore continues", func(t *testing.T) {
|
||||
payload, err := run(t, &larkAlignmentCaller{sequenceResponses: map[string][]string{
|
||||
"im/list_all_conversations": {
|
||||
`{"result":{"conversations":[{"openConversationId":"g1"}],"nextCursor":2}}`,
|
||||
`{"result":{"conversations":[],"hasMore":false}}`,
|
||||
},
|
||||
}}, "--page-all")
|
||||
if err != nil || payload["complete"] != true || payload["pagesFetched"] != float64(2) {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gateway tuple preserves continuation metadata", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
|
||||
"im/list_all_conversations": {
|
||||
`{"result":[[{"openConversationId":"g1","singleChat":false}],2,true]}`,
|
||||
`{"result":[[{"openConversationId":"g2","singleChat":false}],0,false]}`,
|
||||
},
|
||||
}}
|
||||
payload, err := run(t, fake, "--page-size", "1", "--page-all")
|
||||
if err != nil {
|
||||
t.Fatalf("tuple pagination payload=%#v calls=%#v err=%v", payload, fake.calls, err)
|
||||
}
|
||||
if len(fake.calls) != 2 || fake.calls[1].args["cursor"] != 2 {
|
||||
t.Fatalf("tuple pagination calls = %#v", fake.calls)
|
||||
}
|
||||
if payload["count"] != float64(2) || payload["pagesFetched"] != float64(2) ||
|
||||
payload["complete"] != true || payload["hasMore"] != false || payload["stopReason"] != "source_complete" {
|
||||
t.Fatalf("tuple pagination payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
for name, response := range map[string]string{
|
||||
"missing metadata": `{"result":[[{"openConversationId":"g1"}]]}`,
|
||||
"invalid hasMore": `{"result":[[{"openConversationId":"g1"}],2,"true"]}`,
|
||||
} {
|
||||
t.Run("malformed gateway tuple "+name, func(t *testing.T) {
|
||||
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_all_conversations": response,
|
||||
}}, "--page-all")
|
||||
if err == nil || payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
|
||||
t.Fatalf("malformed tuple payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("full legacy page without pagination fails closed", func(t *testing.T) {
|
||||
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_all_conversations": `{"result":{"conversations":[{"openConversationId":"g1"}]}}`,
|
||||
}}, "--page-size", "1")
|
||||
if err == nil || payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single full page is untrusted", func(t *testing.T) {
|
||||
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_all_conversations": `{"result":{"conversations":[{"openConversationId":"g1"}],"hasMore":false}}`,
|
||||
}}, "--page-size", "1")
|
||||
if err != nil || payload["complete"] != false || payload["stopReason"] != "single_page_full_untrusted" {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bounded probe reports page limit", func(t *testing.T) {
|
||||
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_all_conversations": `{"result":{"conversations":[{"openConversationId":"g1"}],"hasMore":false}}`,
|
||||
}}, "--page-size", "1", "--page-all", "--page-limit", "1")
|
||||
if err != nil || payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("full maximum probe fails closed", func(t *testing.T) {
|
||||
chats := make([]map[string]any, chatListMaxWindowSize)
|
||||
for i := range chats {
|
||||
chats[i] = map[string]any{"openConversationId": fmt.Sprintf("probe-%d", i)}
|
||||
}
|
||||
second, marshalErr := json.Marshal(map[string]any{"result": map[string]any{"conversations": chats, "hasMore": false}})
|
||||
if marshalErr != nil {
|
||||
t.Fatal(marshalErr)
|
||||
}
|
||||
payload, err := run(t, &larkAlignmentCaller{sequenceResponses: map[string][]string{
|
||||
"im/list_all_conversations": {
|
||||
`{"result":{"conversations":[{"openConversationId":"first"}],"hasMore":false}}`,
|
||||
string(second),
|
||||
},
|
||||
}}, "--page-size", "1", "--page-all")
|
||||
if err == nil || payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
response string
|
||||
wantErr bool
|
||||
stop string
|
||||
}{
|
||||
{name: "missing continuation", response: `{"result":{"conversations":[{"openConversationId":"g1"}],"hasMore":true}}`, wantErr: true, stop: "pagination_error"},
|
||||
{name: "single page continuation", response: `{"result":{"conversations":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":2}}`, stop: "single_page"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{"im/list_all_conversations": tc.response}})
|
||||
if (err != nil) != tc.wantErr || payload["stopReason"] != tc.stop {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("exclude muted and output errors", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_all_conversations": `{"result":{"conversations":[],"hasMore":false}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(chatOutputErrorWriter{err: errors.New("fixture output")})
|
||||
root.SetArgs([]string{"chat", "+chat-list", "--exclude-muted"})
|
||||
if err := root.Execute(); err == nil || fake.calls[0].args["excludeMuted"] != true {
|
||||
t.Fatalf("calls=%#v err=%v", fake.calls, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatListCursorTypeEdges(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
value any
|
||||
ok bool
|
||||
}{
|
||||
{name: "nil", value: nil, ok: true},
|
||||
{name: "int", value: int(1), ok: true},
|
||||
{name: "negative int", value: int(-1)},
|
||||
{name: "int64", value: int64(2), ok: true},
|
||||
{name: "negative int64", value: int64(-1)},
|
||||
{name: "float64", value: float64(3), ok: true},
|
||||
{name: "negative float64", value: float64(-1)},
|
||||
{name: "json number", value: json.Number("4"), ok: true},
|
||||
{name: "invalid json number", value: json.Number("bad")},
|
||||
{name: "empty string", value: "", ok: true},
|
||||
{name: "string", value: "5", ok: true},
|
||||
{name: "invalid string", value: "bad"},
|
||||
{name: "unsupported", value: struct{}{}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := chatListNextCursor(tc.value)
|
||||
if (err == nil) != tc.ok {
|
||||
t.Fatalf("value=%#v err=%v", tc.value, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,12 @@ package chat
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
@@ -27,6 +30,8 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
)
|
||||
|
||||
const directMessagesHardPageLimit = 500
|
||||
|
||||
// MessagesSend sends a text/markdown message as the current user
|
||||
// (send_personal_message, chat server). Media/file variants are not covered.
|
||||
// MessagesReply quote-replies a message (send_personal_message, chat server).
|
||||
@@ -283,8 +288,8 @@ var MessagesList = shortcut.Shortcut{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
|
||||
{Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\"", Required: true},
|
||||
{Name: "forward", Type: shortcut.FlagBool, Default: "true", Desc: "true=从给定时间往现在拉,false=往以前拉"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量"},
|
||||
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量;显式页大小必须大于 0"},
|
||||
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名;显式页大小必须大于 0", Hidden: true},
|
||||
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
@@ -407,16 +412,6 @@ func listMessagesResolveMaps(data map[string]any) []map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
// listMessagesFirst returns the first present candidate key's value.
|
||||
func listMessagesFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// MessagesListDirect pulls messages of a single chat (list_individual_chat_message, chat server).
|
||||
var MessagesListDirect = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
@@ -454,40 +449,246 @@ var MessagesListDirect = shortcut.Shortcut{
|
||||
{Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "对方 openDingTalkId(与 --user 二选一)"},
|
||||
{Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\"", Required: true},
|
||||
{Name: "forward", Type: shortcut.FlagBool, Default: "true", Desc: "true=往现在拉,false=往以前拉"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量"},
|
||||
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量;显式页大小必须大于 0"},
|
||||
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名;显式页大小必须大于 0", Hidden: true},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿毫秒级 nextCursor 自动读取全部单聊消息;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
|
||||
},
|
||||
Tips: []string{`dws chat +messages-list-direct --user <userId> --time "2025-03-01 00:00:00"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"time": rt.Str("time"),
|
||||
"forward": rt.Bool("forward"),
|
||||
Tips: []string{
|
||||
`dws chat +messages-list-direct --user <userId> --time "2025-03-01 00:00:00"`,
|
||||
`dws chat +messages-list-direct --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --forward=false --page-all`,
|
||||
},
|
||||
Execute: executeMessagesListDirect,
|
||||
}
|
||||
|
||||
func validateMessagesListDirectPagination(rt *shortcut.RuntimeContext) error {
|
||||
// This existing command shipped without formal Schema constraints. Keep its
|
||||
// published constraint set stable and validate the new pagination options at
|
||||
// runtime so adding --page-all remains backwards compatible for consumers.
|
||||
for _, name := range []string{"limit", "size"} {
|
||||
if rt.Changed(name) && rt.Int(name) <= 0 {
|
||||
return apperrors.NewValidation("--" + name + " 必须大于 0")
|
||||
}
|
||||
switch {
|
||||
case rt.Str("open-dingtalk-id") != "":
|
||||
params["openDingTalkId"] = rt.Str("open-dingtalk-id")
|
||||
case rt.Str("user") != "":
|
||||
params["userId"] = rt.Str("user")
|
||||
default:
|
||||
return fmt.Errorf("--user 或 --open-dingtalk-id 必填其一")
|
||||
}
|
||||
if !rt.Bool("page-all") && rt.Changed("page-limit") {
|
||||
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
|
||||
}
|
||||
if rt.Bool("page-all") {
|
||||
if limit := rt.Int("page-limit"); limit < 1 || limit > directMessagesHardPageLimit {
|
||||
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
||||
}
|
||||
if limit := rt.IntFirst("limit", "size"); limit > 0 {
|
||||
params["limit"] = limit
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeMessagesListDirect(rt *shortcut.RuntimeContext) error {
|
||||
if err := validateMessagesListDirectPagination(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"time": rt.Str("time"),
|
||||
"forward": rt.Bool("forward"),
|
||||
}
|
||||
switch {
|
||||
case rt.Str("open-dingtalk-id") != "":
|
||||
params["openDingTalkId"] = rt.Str("open-dingtalk-id")
|
||||
case rt.Str("user") != "":
|
||||
params["userId"] = rt.Str("user")
|
||||
default:
|
||||
return fmt.Errorf("--user 或 --open-dingtalk-id 必填其一")
|
||||
}
|
||||
if limit := rt.IntFirst("limit", "size"); limit > 0 {
|
||||
params["limit"] = limit
|
||||
}
|
||||
if rt.Bool("page-all") {
|
||||
payload, err := readAllDirectMessages(rt, params)
|
||||
if outputErr := rt.Output(payload); outputErr != nil {
|
||||
return outputErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
data, err := rt.CallMCPData("chat", "list_individual_chat_message", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
messages := listMessagesProjectWithReactions(data, !rt.Bool("no-reactions"))
|
||||
payload := map[string]any{"count": len(messages), "messages": messages}
|
||||
direction := "older"
|
||||
if rt.Bool("forward") {
|
||||
direction = "newer"
|
||||
}
|
||||
chatmsg.ApplyMessagePagination(payload, data, listMessagesResolveMaps(data), direction)
|
||||
if payload["complete"] == true {
|
||||
payload["stopReason"] = "source_complete"
|
||||
} else {
|
||||
payload["stopReason"] = "single_page"
|
||||
}
|
||||
return rt.Output(payload)
|
||||
}
|
||||
|
||||
func readAllDirectMessages(rt *shortcut.RuntimeContext, params map[string]any) (map[string]any, error) {
|
||||
pageLimit := rt.Int("page-limit")
|
||||
direction := "older"
|
||||
if rt.Bool("forward") {
|
||||
direction = "newer"
|
||||
}
|
||||
seenCursors := map[string]bool{}
|
||||
seenMessages := map[string]bool{}
|
||||
allItems := make([]map[string]any, 0)
|
||||
failures := make([]map[string]any, 0)
|
||||
pagesFetched := 0
|
||||
complete := false
|
||||
hasMore := false
|
||||
stopReason := "source_complete"
|
||||
truncatedByPageLimit := false
|
||||
var nextPage map[string]any
|
||||
|
||||
for pagesFetched < pageLimit {
|
||||
data, err := rt.CallMCPData("chat", "list_individual_chat_message", params)
|
||||
if err != nil {
|
||||
return err
|
||||
if pagesFetched == 0 {
|
||||
return nil, err
|
||||
}
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched + 1, "stage": "read", "error": err.Error(),
|
||||
})
|
||||
stopReason = "read_failure"
|
||||
break
|
||||
}
|
||||
messages := listMessagesProjectWithReactions(data, !rt.Bool("no-reactions"))
|
||||
payload := map[string]any{"count": len(messages), "messages": messages}
|
||||
direction := "older"
|
||||
if rt.Bool("forward") {
|
||||
direction = "newer"
|
||||
pagesFetched++
|
||||
pageItems := listMessagesResolveMaps(data)
|
||||
for _, item := range pageItems {
|
||||
id := chatmsg.StableMessageID(item)
|
||||
if id != "" && seenMessages[id] {
|
||||
continue
|
||||
}
|
||||
if id != "" {
|
||||
seenMessages[id] = true
|
||||
}
|
||||
allItems = append(allItems, item)
|
||||
}
|
||||
chatmsg.ApplyMessagePagination(payload, data, listMessagesResolveMaps(data), direction)
|
||||
return rt.Output(payload)
|
||||
},
|
||||
|
||||
page := chatmsg.Pagination(data)
|
||||
pageHasMore, known := page["hasMore"].(bool)
|
||||
if !known {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "单聊消息下层未返回可靠的 hasMore,无法证明结果完整",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
hasMore = pageHasMore
|
||||
if !hasMore {
|
||||
complete = true
|
||||
nextPage = nil
|
||||
stopReason = "source_complete"
|
||||
break
|
||||
}
|
||||
if len(pageItems) == 0 {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "单聊消息下层返回 hasMore=true 但当前页没有消息",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
cursorKey, boundary, cursorErr := directMessageCursorBoundary(page["nextCursor"])
|
||||
if cursorErr != nil {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "单聊消息下层返回 hasMore=true,但 nextCursor 无效: " + cursorErr.Error(),
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
if seenCursors[cursorKey] {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "单聊消息毫秒 nextCursor 停滞",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
seenCursors[cursorKey] = true
|
||||
nextPage = map[string]any{
|
||||
"direction": direction, "time": boundary, "nextCursor": page["nextCursor"],
|
||||
}
|
||||
params["time"] = boundary
|
||||
}
|
||||
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
|
||||
truncatedByPageLimit = true
|
||||
stopReason = "page_limit"
|
||||
}
|
||||
|
||||
messages := make([]map[string]any, 0, len(allItems))
|
||||
for _, item := range allItems {
|
||||
messages = append(messages, listMessageProjectOneWithReactions(item, !rt.Bool("no-reactions")))
|
||||
}
|
||||
payload := chatmsg.NewMessageListPayload(messages)
|
||||
payload["pagesFetched"] = pagesFetched
|
||||
payload["paginationKnown"] = true
|
||||
payload["complete"] = complete && len(failures) == 0
|
||||
payload["hasMore"] = hasMore
|
||||
payload["stopReason"] = stopReason
|
||||
payload["truncatedByPageLimit"] = truncatedByPageLimit
|
||||
payload["failedCount"] = len(failures)
|
||||
payload["failures"] = failures
|
||||
payload["partial"] = len(failures) > 0 && len(messages) > 0
|
||||
if hasMore && nextPage != nil {
|
||||
payload["nextPage"] = nextPage
|
||||
}
|
||||
if len(failures) == 0 {
|
||||
return payload, nil
|
||||
}
|
||||
return payload, apperrors.NewAPI(
|
||||
fmt.Sprintf("单聊消息分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)),
|
||||
apperrors.WithOperation("chat/list_individual_chat_message"),
|
||||
apperrors.WithReason("messages_list_direct_incomplete"),
|
||||
apperrors.WithOrigin("mcp_gateway"),
|
||||
apperrors.WithFailureStage("pagination"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(true),
|
||||
apperrors.WithHint("请根据 failures 和 nextPage 重试"),
|
||||
)
|
||||
}
|
||||
|
||||
func directMessageCursorBoundary(value any) (string, string, error) {
|
||||
var millis int64
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
millis = int64(typed)
|
||||
case int32:
|
||||
millis = int64(typed)
|
||||
case int64:
|
||||
millis = typed
|
||||
case float32:
|
||||
asFloat := float64(typed)
|
||||
if math.IsNaN(asFloat) || math.IsInf(asFloat, 0) || asFloat <= 0 || math.Trunc(asFloat) != asFloat || asFloat > math.MaxInt64 {
|
||||
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
|
||||
}
|
||||
millis = int64(asFloat)
|
||||
case float64:
|
||||
if math.IsNaN(typed) || math.IsInf(typed, 0) || typed <= 0 || math.Trunc(typed) != typed || typed > math.MaxInt64 {
|
||||
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
|
||||
}
|
||||
millis = int64(typed)
|
||||
case string:
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
|
||||
}
|
||||
millis = parsed
|
||||
default:
|
||||
return "", "", fmt.Errorf("缺少毫秒级分页游标")
|
||||
}
|
||||
if millis <= 0 {
|
||||
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
|
||||
}
|
||||
key := strconv.FormatInt(millis, 10)
|
||||
boundary := time.UnixMilli(millis).UTC().Format(time.RFC3339Nano)
|
||||
return key, boundary, nil
|
||||
}
|
||||
|
||||
// MessagesListTopicReplies pulls topic replies (list_topic_replies, chat server).
|
||||
@@ -1238,8 +1439,8 @@ var MessagesSendCard = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+messages-send-card",
|
||||
Product: "im",
|
||||
Description: "创建流式卡片,可在同一次调用中写入内容并结束",
|
||||
Intent: "当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。",
|
||||
Description: "创建流式卡片,可在同一次调用中写入内容并结束;群聊创建时可 @成员或 @所有人",
|
||||
Intent: "当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。群聊可用 --at-open-dingtalk-ids 或 --at-all 在创建卡片时设置 @对象;同时传 --content 时,Runtime 会把创建响应中的 atTag 自动加在正文前,后续更新不重复传递 @参数。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
@@ -1253,30 +1454,34 @@ var MessagesSendCard = shortcut.Shortcut{
|
||||
CLIPath: "chat +messages-send-card",
|
||||
PrimaryCLIPath: "chat +messages-send-card",
|
||||
},
|
||||
Description: "创建流式卡片,可在同一次调用中写入内容并结束",
|
||||
Description: "创建流式卡片,可在同一次调用中写入内容并结束;群聊创建时可 @成员或 @所有人",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed card lifecycle adapter: it can resolve a userId through contact search with exact matching, call create_and_send_card alone, or compose creation with update_streaming_card after extracting the returned bizId.",
|
||||
Reason: "Reviewed card lifecycle adapter: it can resolve a userId through contact search with exact matching, call create_and_send_card alone, or compose creation with update_streaming_card after extracting the returned bizId and atTag.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "创建流式卡片,可在同一次调用中写入内容并结束",
|
||||
UseWhen: []string{"当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。"},
|
||||
AgentSummary: "创建流式卡片,可在同一次调用中写入内容并结束;群聊创建时可 @成员或 @所有人",
|
||||
UseWhen: []string{"当你要发送一张流式文本卡片时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。群聊可用 --at-open-dingtalk-ids 或 --at-all 在创建卡片时设置 @对象;同时传 --content 时,Runtime 会把创建响应中的 atTag 自动加在正文前,后续更新不重复传递 @参数。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成。当前只支持 streaming text,不支持 Card JSON 组件或 action callback。"},
|
||||
AvoidWhen: []string{"已有 bizId、只需要追加或更新现有卡片内容时使用 +messages-update-card"},
|
||||
Examples: []string{
|
||||
"dws chat +messages-send-card --group <openConversationId> --content \"任务已完成\"",
|
||||
"dws chat +messages-send-card --group <openConversationId> --at-open-dingtalk-ids <openDingTalkId> --content \"任务已完成\"",
|
||||
"dws chat +messages-send-card --receiver <userId>",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "receiver-open-dingtalk-id", Property: "receiverOpenDingTalkId"},
|
||||
{Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds"},
|
||||
{Name: "at-all", Property: "atAll"},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(与两个单聊接收者参数互斥)"},
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(与两个单聊接收者参数互斥);艾特参数仅支持群聊 --group"},
|
||||
{Name: "receiver", Type: shortcut.FlagString, Desc: "单聊接收者 userId(与 --group/--receiver-open-dingtalk-id 互斥);始终通过通讯录搜索精确匹配 openDingTalkId,包括 --dry-run 和 D/d 开头的 userId"},
|
||||
{Name: "receiver-open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊接收者 openDingTalkId(与 --group/--receiver 互斥);显式直传且不做通讯录解析"},
|
||||
{Name: "content", Type: shortcut.FlagString, Desc: "创建后立即写入的卡片内容;省略时仅创建并返回 bizId"},
|
||||
{Name: "at-open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "群聊创建卡片时 @ 的 openDingTalkId 列表;仅随 create_and_send_card 发送;艾特参数仅支持群聊 --group"},
|
||||
{Name: "at-all", Type: shortcut.FlagBool, Desc: "群聊创建卡片时 @ 所有人;仅随 create_and_send_card 发送;艾特参数仅支持群聊 --group"},
|
||||
{Name: "content", Type: shortcut.FlagString, Desc: "创建后立即写入的卡片正文;群聊 @ 时 Runtime 自动前置 create 返回的 atTag;省略时仅创建并返回 bizId"},
|
||||
{Name: "flow-status", Type: shortcut.FlagInt, Default: "3", Desc: "自动更新状态:1处理中/2输入中/3完成/4执行中/5错误;--flow-status 必须在 1-5 之间,且显式指定时必须同时提供 --content"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
@@ -1286,10 +1491,15 @@ var MessagesSendCard = shortcut.Shortcut{
|
||||
Flags: []string{"flow-status"},
|
||||
Description: "--flow-status 必须在 1-5 之间,且显式指定时必须同时提供 --content",
|
||||
},
|
||||
{
|
||||
Kind: shortcut.ConstraintCustom,
|
||||
Flags: []string{"group", "at-open-dingtalk-ids", "at-all"},
|
||||
Description: "艾特参数仅支持群聊 --group",
|
||||
},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws chat +messages-send-card --group <openConversationId>`,
|
||||
`dws chat +messages-send-card --group <openConversationId> --content "任务已完成"`,
|
||||
`dws chat +messages-send-card --group <openConversationId> --at-open-dingtalk-ids <openDingTalkId> --content "任务已完成"`,
|
||||
},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if status := rt.Int("flow-status"); !validCardFlowStatus(status) {
|
||||
@@ -1298,6 +1508,9 @@ var MessagesSendCard = shortcut.Shortcut{
|
||||
if rt.Changed("flow-status") && rt.Str("content") == "" {
|
||||
return fmt.Errorf("--flow-status 只有与 --content 一起使用才有意义")
|
||||
}
|
||||
if rt.Str("group") == "" && (len(uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids"))) > 0 || rt.Bool("at-all")) {
|
||||
return fmt.Errorf("--at-open-dingtalk-ids 和 --at-all 仅支持群聊 --group")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
@@ -1305,9 +1518,18 @@ var MessagesSendCard = shortcut.Shortcut{
|
||||
receiver := rt.Str("receiver")
|
||||
receiverOpenID := rt.Str("receiver-open-dingtalk-id")
|
||||
params := map[string]any{}
|
||||
mentionsRequested := false
|
||||
switch {
|
||||
case group != "":
|
||||
params["openConversationId"] = group
|
||||
if atOpenIDs := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")); len(atOpenIDs) > 0 {
|
||||
params["atOpenDingTalkIds"] = atOpenIDs
|
||||
mentionsRequested = true
|
||||
}
|
||||
if rt.Bool("at-all") {
|
||||
params["atAll"] = true
|
||||
mentionsRequested = true
|
||||
}
|
||||
case receiver != "":
|
||||
openID, err := resolveUserOpenDingTalkID(rt, receiver)
|
||||
if err != nil {
|
||||
@@ -1323,6 +1545,10 @@ var MessagesSendCard = shortcut.Shortcut{
|
||||
}
|
||||
status := rt.Int("flow-status")
|
||||
if rt.DryRun() {
|
||||
plannedContent := content
|
||||
if mentionsRequested {
|
||||
plannedContent = "<atTag from create_and_send_card>" + content
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
"contractVersion": currentCardWorkflowContract.Version,
|
||||
"dry_run": true,
|
||||
@@ -1339,7 +1565,7 @@ var MessagesSendCard = shortcut.Shortcut{
|
||||
"tool": "update_streaming_card",
|
||||
"arguments": map[string]any{
|
||||
"bizId": "<from create_and_send_card>",
|
||||
"msgContent": content,
|
||||
"msgContent": plannedContent,
|
||||
"flowStatus": status,
|
||||
},
|
||||
},
|
||||
@@ -1354,9 +1580,13 @@ var MessagesSendCard = shortcut.Shortcut{
|
||||
if bizID == "" {
|
||||
return fmt.Errorf("卡片已创建但下层未返回 bizId,无法自动更新;请检查 create_and_send_card 响应")
|
||||
}
|
||||
atTag := findCardAtTag(created)
|
||||
if mentionsRequested && atTag == "" {
|
||||
return fmt.Errorf("卡片已创建(bizId=%s),但下层未返回 atTag,无法保证请求的 @ 生效;未执行自动更新", bizID)
|
||||
}
|
||||
updated, err := rt.CallMCPWriteData("im", "update_streaming_card", map[string]any{
|
||||
"bizId": bizID,
|
||||
"msgContent": content,
|
||||
"msgContent": atTag + content,
|
||||
"flowStatus": status,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1374,12 +1604,18 @@ var MessagesSendCard = shortcut.Shortcut{
|
||||
}
|
||||
|
||||
func findCardBizID(value any) string {
|
||||
return strings.TrimSpace(findCardResponseString(value, []string{"bizId", "bizID", "biz_id"}))
|
||||
}
|
||||
|
||||
func findCardAtTag(value any) string {
|
||||
return findCardResponseString(value, []string{"atTag"})
|
||||
}
|
||||
|
||||
func findCardResponseString(value any, directKeys []string) string {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
directKeys := []string{"bizId", "bizID", "biz_id"}
|
||||
for _, key := range directKeys {
|
||||
if candidate, ok := typed[key].(string); ok && strings.TrimSpace(candidate) != "" {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
@@ -1394,7 +1630,7 @@ func findCardBizID(value any) string {
|
||||
}
|
||||
for _, key := range envelopeKeys {
|
||||
visited[key] = struct{}{}
|
||||
if candidate := findCardBizID(typed[key]); candidate != "" {
|
||||
if candidate := findCardResponseString(typed[key], directKeys); candidate != "" {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
@@ -1407,13 +1643,13 @@ func findCardBizID(value any) string {
|
||||
}
|
||||
sort.Strings(remainingKeys)
|
||||
for _, key := range remainingKeys {
|
||||
if candidate := findCardBizID(typed[key]); candidate != "" {
|
||||
if candidate := findCardResponseString(typed[key], directKeys); candidate != "" {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
if candidate := findCardBizID(child); candidate != "" {
|
||||
if candidate := findCardResponseString(child, directKeys); candidate != "" {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
@@ -1422,7 +1658,7 @@ func findCardBizID(value any) string {
|
||||
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
|
||||
var nested any
|
||||
if json.Unmarshal([]byte(trimmed), &nested) == nil {
|
||||
return findCardBizID(nested)
|
||||
return findCardResponseString(nested, directKeys)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageChatSearchDryRunStopsBeforeRead(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{dryRun: true}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-search", "--query", "项目", "--page-size", "20",
|
||||
"--cursor", "cursor-1", "--exclude-muted", "--dry-run",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("chat-search dry-run made lower calls: %#v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatSearchPageAllUsesOpaqueCursorAndDeduplicates(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
|
||||
"im/search_groups": {
|
||||
`{"result":{"groups":[{"openConversationId":"cid-1","title":"项目一群"}],"hasMore":true,"nextCursor":"cursor-2"}}`,
|
||||
`{"result":{"groups":[{"openConversationId":"cid-1","title":"重复项目群"},{"openConversationId":"cid-2","title":"项目二群"}],"hasMore":false}}`,
|
||||
},
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--page-size", "1", "--page-all", "--page-limit", "5"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 2 || fake.calls[0].args["cursor"] != "0" || fake.calls[1].args["cursor"] != "cursor-2" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(2) || payload["pagesFetched"] != float64(2) || payload["complete"] != true || payload["hasMore"] != false {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
chats := payload["chats"].([]any)
|
||||
if chats[0].(map[string]any)["name"] != "项目一群" || chats[1].(map[string]any)["openConversationId"] != "cid-2" {
|
||||
t.Fatalf("chats = %#v", chats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatSearchProbesMaximumWindowWhenBackendFalselyEndsFullFirstPage(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
|
||||
"im/search_groups": {
|
||||
`{"result":{"groups":[{"openConversationId":"cid-1","title":"项目一群"}],"hasMore":false}}`,
|
||||
`{"result":{"groups":[{"openConversationId":"cid-1","title":"项目一群"},{"openConversationId":"cid-2","title":"项目二群"}],"hasMore":false}}`,
|
||||
},
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--page-size", "1", "--page-all", "--page-limit", "5"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 2 || fake.calls[0].args["limit"] != 1 || fake.calls[1].args["limit"] != 100 {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(2) || payload["complete"] != true || payload["paginationKnown"] != false ||
|
||||
payload["completionEvidence"] != "max_window_short_page" || payload["stopReason"] != "max_window_short_page" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatSearchPageTokenAndPageLimit(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/search_groups": `{"result":{"groups":[{"openConversationId":"cid-2","title":"项目二群"}],"hasMore":true,"nextCursor":"cursor-3"}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--page-token", "cursor-2", "--page-all", "--page-limit", "1"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].args["cursor"] != "cursor-2" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["complete"] != false || payload["hasMore"] != true || payload["nextCursor"] != "cursor-3" ||
|
||||
payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatSearchFailureModes(t *testing.T) {
|
||||
t.Run("later read failure keeps partial result", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{
|
||||
sequenceResponses: map[string][]string{
|
||||
"im/search_groups": {`{"result":{"groups":[{"openConversationId":"cid-1","title":"项目群"}],"hasMore":true,"nextCursor":"cursor-2"}}`},
|
||||
},
|
||||
failProductToolAt: map[string]int{"im/search_groups": 2},
|
||||
}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--page-all"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("expected later-page error")
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(1) || payload["partial"] != true || payload["stopReason"] != "read_failure" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("full legacy page without pagination fails closed", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/search_groups": `{"result":{"groups":[{"openConversationId":"cid-1","title":"项目群"}]}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--page-size", "1", "--page-all"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("missing pagination unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("short legacy page is bounded complete", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{"im/search_groups": `{"result":[]}`}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+chat-search", "--query", "不存在"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["complete"] != true || payload["paginationKnown"] != false || payload["stopReason"] != "legacy_short_page" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatSearchPaginationValidation(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"--page-size", "0"},
|
||||
{"--page-limit", "2"},
|
||||
{"--page-all", "--page-limit", "0"},
|
||||
{"--page-all", "--page-limit", "501"},
|
||||
{"--cursor", "c1", "--page-token", "c2"},
|
||||
} {
|
||||
helpers.InitDeps(&larkAlignmentCaller{})
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs(append([]string{"chat", "+chat-search", "--query", "项目"}, args...))
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatalf("invalid args succeeded: %v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatSearchAdditionalPaginationEdges(t *testing.T) {
|
||||
run := func(t *testing.T, fake *larkAlignmentCaller, args ...string) (map[string]any, error) {
|
||||
t.Helper()
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs(append([]string{"chat", "+chat-search", "--query", "项目"}, args...))
|
||||
err := root.Execute()
|
||||
if output.Len() == 0 {
|
||||
return nil, err
|
||||
}
|
||||
var payload map[string]any
|
||||
if decodeErr := json.Unmarshal(output.Bytes(), &payload); decodeErr != nil {
|
||||
t.Fatal(decodeErr)
|
||||
}
|
||||
return payload, err
|
||||
}
|
||||
|
||||
t.Run("first read failure", func(t *testing.T) {
|
||||
_, err := run(t, &larkAlignmentCaller{failProductToolAt: map[string]int{"im/search_groups": 1}})
|
||||
if err == nil {
|
||||
t.Fatal("first read failure unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cursor without hasMore continues", func(t *testing.T) {
|
||||
payload, err := run(t, &larkAlignmentCaller{sequenceResponses: map[string][]string{
|
||||
"im/search_groups": {
|
||||
`{"result":{"groups":[{"openConversationId":"g1"}],"nextCursor":"next"}}`,
|
||||
`{"result":{"groups":[],"hasMore":false}}`,
|
||||
},
|
||||
}}, "--page-all")
|
||||
if err != nil || payload["complete"] != true || payload["pagesFetched"] != float64(2) {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single full page is untrusted", func(t *testing.T) {
|
||||
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/search_groups": `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":false}}`,
|
||||
}}, "--page-size", "1")
|
||||
if err != nil || payload["complete"] != false || payload["stopReason"] != "single_page_full_untrusted" {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bounded probe reports page limit", func(t *testing.T) {
|
||||
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/search_groups": `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":false}}`,
|
||||
}}, "--page-size", "1", "--page-all", "--page-limit", "1")
|
||||
if err != nil || payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("full maximum probe fails closed", func(t *testing.T) {
|
||||
groups := make([]map[string]any, chatSearchMaxWindowSize)
|
||||
for i := range groups {
|
||||
groups[i] = map[string]any{"openConversationId": fmt.Sprintf("probe-%d", i)}
|
||||
}
|
||||
second, marshalErr := json.Marshal(map[string]any{"result": map[string]any{"groups": groups, "hasMore": false}})
|
||||
if marshalErr != nil {
|
||||
t.Fatal(marshalErr)
|
||||
}
|
||||
payload, err := run(t, &larkAlignmentCaller{sequenceResponses: map[string][]string{
|
||||
"im/search_groups": {
|
||||
`{"result":{"groups":[{"openConversationId":"first"}],"hasMore":false}}`,
|
||||
string(second),
|
||||
},
|
||||
}}, "--page-size", "1", "--page-all")
|
||||
if err == nil || payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
response string
|
||||
args []string
|
||||
wantErr bool
|
||||
stop string
|
||||
}{
|
||||
{name: "missing continuation", response: `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true}}`, wantErr: true, stop: "pagination_error"},
|
||||
{name: "single page continuation", response: `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":"next"}}`, stop: "single_page"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{"im/search_groups": tc.response}, failProductToolAt: map[string]int{}}, tc.args...)
|
||||
if (err != nil) != tc.wantErr || payload["stopReason"] != tc.stop {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("exclude muted reaches lower call and output errors propagate", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/search_groups": `{"result":{"groups":[],"hasMore":false}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(chatOutputErrorWriter{err: errors.New("fixture output")})
|
||||
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--exclude-muted"})
|
||||
if err := root.Execute(); err == nil || fake.calls[0].args["excludeMuted"] != true {
|
||||
t.Fatalf("calls=%#v err=%v", fake.calls, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatSearchProjectionEdges(t *testing.T) {
|
||||
if got := chatSearchItems(nil); len(got) != 0 {
|
||||
t.Fatalf("nil items = %#v", got)
|
||||
}
|
||||
if got := chatSearchItems(map[string]any{"result": map[string]any{"unknown": true}}); len(got) != 0 {
|
||||
t.Fatalf("unknown items = %#v", got)
|
||||
}
|
||||
got := projectChatSearchItems([]any{
|
||||
"invalid",
|
||||
map[string]any{"conversationId": "g1", "conversationName": "项目群"},
|
||||
})
|
||||
if len(got) != 1 || got[0]["openConversationId"] != "g1" || got[0]["name"] != "项目群" {
|
||||
t.Fatalf("projected = %#v", got)
|
||||
}
|
||||
for _, value := range []any{nil, "", "0", "<nil>"} {
|
||||
if cursor := chatSearchCursorString(value); cursor != "" {
|
||||
t.Fatalf("cursor %v = %q", value, cursor)
|
||||
}
|
||||
}
|
||||
|
||||
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
|
||||
"im/search_groups": `{"result":{"groups":[],"hasMore":false}}`,
|
||||
}})
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--cursor="})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -316,7 +316,7 @@ func TestCrossPlatformCoverageFlagAndMgetValidation(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"chat", "+flag-create", "--message-ids", strings.Join(tooMany, ","), "--conversation-id", "cid", "--yes"},
|
||||
{"chat", "+flag-list", "--cursor", "-1"},
|
||||
{"chat", "+flag-list", "--size", "101"},
|
||||
{"chat", "+flag-list", "--size", "31"},
|
||||
{"chat", "+messages-mget", "--msg-ids", strings.Join(makeIDs(51), ",")},
|
||||
{"chat", "+messages-mget", "--msg-ids", "msg", "--download-resources", "--output-dir", "../escape"},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageFlagListDryRunStopsBeforeRead(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{dryRun: true}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+flag-list", "--page-size", "20", "--cursor", "0", "--dry-run",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("flag-list dry-run made lower calls: %#v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagListPageAllUsesNumericCursorAndDeduplicates(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
|
||||
"im/list_message_favorites": {
|
||||
`{"result":{"items":[{"openMessageId":"msg-1","openConversationId":"cid-1","summary":"一"}],"hasMore":true,"nextCursor":7}}`,
|
||||
`{"result":{"items":[{"openMessageId":"msg-1","openConversationId":"cid-1","summary":"重复"},{"openMessageId":"msg-2","openConversationId":"cid-2","summary":"二"}],"hasMore":false}}`,
|
||||
},
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+flag-list", "--page-size", "1", "--page-all", "--page-limit", "5"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 2 || fake.calls[0].args["cursor"] != 0 || fake.calls[0].args["size"] != "1" || fake.calls[1].args["cursor"] != 7 {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(2) || payload["pagesFetched"] != float64(2) || payload["complete"] != true || payload["hasMore"] != false {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagListPageTokenAndPageLimit(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_message_favorites": `{"result":{"items":[{"openMessageId":"msg-2"}],"hasMore":true,"nextCursor":9}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+flag-list", "--page-token", "7", "--page-all", "--page-limit", "1"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].args["cursor"] != 7 {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["complete"] != false || payload["nextCursor"] != float64(9) || payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagListFailureModes(t *testing.T) {
|
||||
t.Run("later read failure keeps partial result", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{
|
||||
sequenceResponses: map[string][]string{
|
||||
"im/list_message_favorites": {`{"result":{"items":[{"openMessageId":"msg-1"}],"hasMore":true,"nextCursor":7}}`},
|
||||
},
|
||||
failProductToolAt: map[string]int{"im/list_message_favorites": 2},
|
||||
}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+flag-list", "--page-all"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("expected later-page error")
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(1) || payload["partial"] != true || payload["failedCount"] != float64(1) || payload["stopReason"] != "read_failure" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stalled numeric cursor fails closed", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_message_favorites": `{"result":{"items":[{"openMessageId":"msg-1"}],"hasMore":true,"nextCursor":7}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetArgs([]string{"chat", "+flag-list", "--cursor", "7", "--page-all"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("stalled cursor unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("full legacy page without pagination fails closed", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_message_favorites": `{"result":{"items":[{"openMessageId":"msg-1"}]}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetArgs([]string{"chat", "+flag-list", "--page-size", "1"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("full legacy page unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagListPaginationValidation(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"--page-size", "0"},
|
||||
{"--page-size", "31"},
|
||||
{"--size", "31"},
|
||||
{"--page-token", "not-a-number"},
|
||||
{"--page-limit", "2"},
|
||||
{"--page-all", "--page-limit", "0"},
|
||||
{"--page-all", "--page-limit", "501"},
|
||||
{"--cursor", "1", "--page-token", "2"},
|
||||
} {
|
||||
helpers.InitDeps(&larkAlignmentCaller{})
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs(append([]string{"chat", "+flag-list"}, args...))
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatalf("invalid args succeeded: %v", args)
|
||||
}
|
||||
}
|
||||
if _, err := flagListNextCursor(float64(1.5)); err == nil {
|
||||
t.Fatal("fractional cursor unexpectedly accepted")
|
||||
}
|
||||
if _, err := flagListNextCursor(struct{}{}); err == nil {
|
||||
t.Fatal("unsupported cursor unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagListAdditionalEdges(t *testing.T) {
|
||||
run := func(t *testing.T, fake *larkAlignmentCaller, args ...string) (map[string]any, error) {
|
||||
t.Helper()
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs(append([]string{"chat", "+flag-list"}, args...))
|
||||
err := root.Execute()
|
||||
if output.Len() == 0 {
|
||||
return nil, err
|
||||
}
|
||||
var payload map[string]any
|
||||
if decodeErr := json.Unmarshal(output.Bytes(), &payload); decodeErr != nil {
|
||||
t.Fatal(decodeErr)
|
||||
}
|
||||
return payload, err
|
||||
}
|
||||
|
||||
for _, token := range []string{"", "0"} {
|
||||
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_message_favorites": `{"result":{"items":[],"hasMore":false}}`,
|
||||
}}, "--page-token="+token)
|
||||
if err != nil || payload["complete"] != true {
|
||||
t.Fatalf("token=%q payload=%#v err=%v", token, payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("first read failure", func(t *testing.T) {
|
||||
_, err := run(t, &larkAlignmentCaller{failProductToolAt: map[string]int{"im/list_message_favorites": 1}})
|
||||
if err == nil {
|
||||
t.Fatal("first read failure unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cursor without hasMore continues", func(t *testing.T) {
|
||||
payload, err := run(t, &larkAlignmentCaller{sequenceResponses: map[string][]string{
|
||||
"im/list_message_favorites": {
|
||||
`{"result":{"items":[{"openMessageId":"m1"}],"nextCursor":2}}`,
|
||||
`{"result":{"items":[],"hasMore":false}}`,
|
||||
},
|
||||
}}, "--page-all")
|
||||
if err != nil || payload["pagesFetched"] != float64(2) || payload["complete"] != true {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
response string
|
||||
wantErr bool
|
||||
stop string
|
||||
}{
|
||||
{name: "single page continuation", response: `{"result":{"items":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":2}}`, stop: "single_page"},
|
||||
{name: "missing continuation", response: `{"result":{"items":[{"openMessageId":"m1"}],"hasMore":true}}`, wantErr: true, stop: "pagination_error"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{"im/list_message_favorites": tc.response}})
|
||||
if (err != nil) != tc.wantErr || payload["stopReason"] != tc.stop {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("output errors propagate", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_message_favorites": `{"result":{"items":[],"hasMore":false}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(chatOutputErrorWriter{err: errors.New("fixture output")})
|
||||
root.SetArgs([]string{"chat", "+flag-list"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("output error was swallowed")
|
||||
}
|
||||
})
|
||||
|
||||
if got := flagListItems(nil); len(got) != 0 {
|
||||
t.Fatalf("nil items = %#v", got)
|
||||
}
|
||||
if got := flagListItems(map[string]any{"result": map[string]any{"items": []any{"invalid", map[string]any{"id": "m1"}}}}); len(got) != 1 {
|
||||
t.Fatalf("projected items = %#v", got)
|
||||
}
|
||||
if got := firstNonEmptyMapString(map[string]any{}, "missing"); got != "" {
|
||||
t.Fatalf("missing identity = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagListCursorTypeEdges(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
value any
|
||||
ok bool
|
||||
}{
|
||||
{name: "nil", value: nil, ok: true},
|
||||
{name: "int", value: int(1), ok: true},
|
||||
{name: "negative int", value: int(-1)},
|
||||
{name: "int64", value: int64(2), ok: true},
|
||||
{name: "negative int64", value: int64(-1)},
|
||||
{name: "float64", value: float64(3), ok: true},
|
||||
{name: "negative float64", value: float64(-1)},
|
||||
{name: "json number", value: json.Number("4"), ok: true},
|
||||
{name: "invalid json number", value: json.Number("bad")},
|
||||
{name: "empty string", value: "", ok: true},
|
||||
{name: "string", value: "5", ok: true},
|
||||
{name: "invalid string", value: "bad"},
|
||||
{name: "unsupported", value: struct{}{}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := flagListNextCursor(tc.value)
|
||||
if (err == nil) != tc.ok {
|
||||
t.Fatalf("value=%#v err=%v", tc.value, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -609,7 +609,7 @@ func TestCrossPlatformCoverageMessagesSendTextModesAndExecuteGuard(t *testing.T)
|
||||
|
||||
func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/create_and_send_card": `{"result":{"card":{"biz_id":"biz-1"}}}`,
|
||||
"im/create_and_send_card": `{"result":{"card":{"biz_id":"biz-1","atTag":"<a atId=D-one>甲</a> <a atId=D-two>乙</a> "}}}`,
|
||||
"im/update_streaming_card": `{"result":{"updated":true}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
@@ -619,6 +619,8 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send-card",
|
||||
"--group", "cid",
|
||||
"--at-open-dingtalk-ids", "D-one,D-two,D-one",
|
||||
"--at-all",
|
||||
"--content", "完成",
|
||||
"--flow-status", "3",
|
||||
"--yes",
|
||||
@@ -630,7 +632,20 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
|
||||
fake.calls[1].tool != "update_streaming_card" {
|
||||
t.Fatalf("card calls = %#v", fake.calls)
|
||||
}
|
||||
if fake.calls[1].args["bizId"] != "biz-1" || fake.calls[1].args["msgContent"] != "完成" ||
|
||||
if got, want := fake.calls[0].args["atOpenDingTalkIds"], []string{"D-one", "D-two"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("card create atOpenDingTalkIds = %#v, want %#v", got, want)
|
||||
}
|
||||
if fake.calls[0].args["atAll"] != true {
|
||||
t.Fatalf("card create atAll = %#v", fake.calls[0].args["atAll"])
|
||||
}
|
||||
if _, exists := fake.calls[1].args["atOpenDingTalkIds"]; exists {
|
||||
t.Fatalf("card update leaked atOpenDingTalkIds: %#v", fake.calls[1].args)
|
||||
}
|
||||
if _, exists := fake.calls[1].args["atAll"]; exists {
|
||||
t.Fatalf("card update leaked atAll: %#v", fake.calls[1].args)
|
||||
}
|
||||
if fake.calls[1].args["bizId"] != "biz-1" ||
|
||||
fake.calls[1].args["msgContent"] != "<a atId=D-one>甲</a> <a atId=D-two>乙</a> 完成" ||
|
||||
fake.calls[1].args["flowStatus"] != 3 {
|
||||
t.Fatalf("card update args = %#v", fake.calls[1].args)
|
||||
}
|
||||
@@ -643,6 +658,49 @@ func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesSendCardKeepsContentWithoutAtTag(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/create_and_send_card": `{"result":{"bizId":"biz-no-mention"}}`,
|
||||
"im/update_streaming_card": `{"result":{"updated":true}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send-card",
|
||||
"--group", "cid",
|
||||
"--content", "正文",
|
||||
"--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 2 || fake.calls[1].args["msgContent"] != "正文" {
|
||||
t.Fatalf("card calls = %#v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesSendCardRejectsMissingRequestedAtTag(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/create_and_send_card": `{"result":{"bizId":"biz-missing-at-tag"}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send-card",
|
||||
"--group", "cid",
|
||||
"--at-open-dingtalk-ids", "D-mentioned",
|
||||
"--content", "正文",
|
||||
"--yes",
|
||||
})
|
||||
err := root.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "biz-missing-at-tag") || !strings.Contains(err.Error(), "atTag") {
|
||||
t.Fatalf("error = %v, want recoverable missing-atTag error containing bizId", err)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != "create_and_send_card" {
|
||||
t.Fatalf("card calls = %#v, want create only", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesSendCardResolvesReceiverForLowerTool(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
@@ -748,6 +806,50 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry run mentions only in create", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send-card",
|
||||
"--group", "cid",
|
||||
"--at-open-dingtalk-ids", "D-mentioned",
|
||||
"--at-all",
|
||||
"--content", "处理中",
|
||||
"--dry-run",
|
||||
"--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("card group dry-run calls = %#v", fake.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actions, _ := payload["actions"].([]any)
|
||||
create, _ := actions[0].(map[string]any)
|
||||
createArguments, _ := create["arguments"].(map[string]any)
|
||||
update, _ := actions[1].(map[string]any)
|
||||
updateArguments, _ := update["arguments"].(map[string]any)
|
||||
if !reflect.DeepEqual(createArguments["atOpenDingTalkIds"], []any{"D-mentioned"}) || createArguments["atAll"] != true {
|
||||
t.Fatalf("card create dry-run mentions = %#v", createArguments)
|
||||
}
|
||||
if _, exists := updateArguments["atOpenDingTalkIds"]; exists {
|
||||
t.Fatalf("card update dry-run leaked atOpenDingTalkIds: %#v", updateArguments)
|
||||
}
|
||||
if _, exists := updateArguments["atAll"]; exists {
|
||||
t.Fatalf("card update dry-run leaked atAll: %#v", updateArguments)
|
||||
}
|
||||
if updateArguments["msgContent"] != "<atTag from create_and_send_card>处理中" {
|
||||
t.Fatalf("card update dry-run content = %#v", updateArguments["msgContent"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("receiver resolution error", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{failProductTool: "contact/search_contact_by_key_word"}
|
||||
helpers.InitDeps(fake)
|
||||
@@ -815,6 +917,8 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
|
||||
{"--group", "cid", "--flow-status", "2"},
|
||||
{"--group", "cid", "--receiver-open-dingtalk-id", "D-direct"},
|
||||
{"--receiver", "user-id", "--receiver-open-dingtalk-id", "D-direct"},
|
||||
{"--receiver", "user-id", "--at-open-dingtalk-ids", "D-mentioned"},
|
||||
{"--receiver-open-dingtalk-id", "D-direct", "--at-all"},
|
||||
} {
|
||||
helpers.InitDeps(&larkAlignmentCaller{})
|
||||
root := newPlatformCoverageRoot()
|
||||
@@ -858,6 +962,24 @@ func TestCrossPlatformCoverageFindCardBizIDResponseShapes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFindCardAtTagResponseShapes(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
value any
|
||||
want string
|
||||
}{
|
||||
{map[string]any{"atTag": "<a atId=D-direct>甲</a> "}, "<a atId=D-direct>甲</a> "},
|
||||
{map[string]any{"result": map[string]any{"atTag": "<a atId=D-nested>乙</a> "}}, "<a atId=D-nested>乙</a> "},
|
||||
{`{"result":{"card":{"atTag":"<a atId=D-json>丙</a> "}}}`, "<a atId=D-json>丙</a> "},
|
||||
{map[string]any{"atTag": " "}, ""},
|
||||
{map[string]any{"atTag": 42}, ""},
|
||||
{map[string]any{"result": map[string]any{"created": true}}, ""},
|
||||
} {
|
||||
if got := findCardAtTag(tc.value); got != tc.want {
|
||||
t.Errorf("findCardAtTag(%#v) = %q, want %q", tc.value, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessageResourceDownloadKeepsNestedMessageContext(t *testing.T) {
|
||||
resetResourceDownloadHooks(t)
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
@@ -7,6 +7,7 @@ package chat
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -16,6 +17,13 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
)
|
||||
|
||||
const (
|
||||
flagListHardPageLimit = 500
|
||||
flagListMaxPageSize = 30
|
||||
chatListHardPageLimit = 500
|
||||
chatListMaxWindowSize = 100
|
||||
)
|
||||
|
||||
// ChatCreate creates a DingTalk group after resolving every natural member and
|
||||
// the optional owner to stable DingTalk identities. Description, initial-bot,
|
||||
// idempotency, and Lark visibility semantics remain deliberately unsupported.
|
||||
@@ -499,34 +507,291 @@ var FlagList = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+flag-list",
|
||||
Product: "im",
|
||||
Description: "分页查询当前用户收藏的消息",
|
||||
Intent: "当你要查看当前用户的 DingTalk message favorite 列表时使用;返回下层分页结果,不把 message favorite 与 Pin、会话置顶或 Lark feed-layer thread flag 混为一谈。",
|
||||
Description: "分页查询当前用户收藏的消息,支持有界自动翻页",
|
||||
Intent: "当你要查看当前用户的 DingTalk message favorite 列表时使用;默认读取一页,明确要求全部收藏时加 --page-all,并用 --page-limit 保持有界。底层实际使用数字 cursor,结果按 openMessageId 去重并公开 complete、hasMore、nextCursor、stopReason 和 failures;它不把 message favorite 与 Pin、会话置顶或 Lark feed-layer thread flag 混为一谈。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "数字分页游标;--cursor 必须大于等于 0,--size 必须在 1-100 之间"},
|
||||
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量;--cursor 必须大于等于 0,--size 必须在 1-100 之间"},
|
||||
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量;下游真实上限为 30,显式页大小必须在 1-30 之间"},
|
||||
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "--page-size 的兼容别名;下游真实上限为 30,显式页大小必须在 1-30 之间"},
|
||||
{Name: "page-token", Type: shortcut.FlagString, Desc: "Lark 对齐的起始分页参数;起始 cursor 必须是非负整数"},
|
||||
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "钉钉数字分页游标;起始 cursor 必须是非负整数"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部收藏分页;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "20", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{
|
||||
Kind: shortcut.ConstraintCustom,
|
||||
Flags: []string{"cursor", "size"},
|
||||
Description: "--cursor 必须大于等于 0,--size 必须在 1-100 之间",
|
||||
}},
|
||||
Tips: []string{`dws chat +flag-list --cursor 0 --size 20`},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("cursor") < 0 {
|
||||
return apperrors.NewValidation("--cursor 必须大于等于 0")
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"page-size", "size"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"page-token", "cursor"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-size", "size"}, Description: "显式页大小必须在 1-30 之间"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-token", "cursor"}, Description: "起始 cursor 必须是非负整数"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws chat +flag-list --cursor 0 --page-size 20`,
|
||||
`dws chat +flag-list --page-size 30 --page-all --page-limit 20`,
|
||||
},
|
||||
Validate: validateFlagList,
|
||||
Execute: executeFlagList,
|
||||
}
|
||||
|
||||
func validateFlagList(rt *shortcut.RuntimeContext) error {
|
||||
if size := flagListPageSize(rt); size < 1 || size > flagListMaxPageSize {
|
||||
return apperrors.NewValidation("--page-size/--size 必须在 1-30 之间")
|
||||
}
|
||||
if _, err := flagListStartCursor(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
if !rt.Bool("page-all") && rt.Changed("page-limit") {
|
||||
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
|
||||
}
|
||||
if rt.Bool("page-all") {
|
||||
if limit := rt.Int("page-limit"); limit < 1 || limit > flagListHardPageLimit {
|
||||
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
||||
}
|
||||
if size := rt.Int("size"); size < 1 || size > 100 {
|
||||
return apperrors.NewValidation("--size 必须在 1-100 之间")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func flagListPageSize(rt *shortcut.RuntimeContext) int {
|
||||
if rt.Changed("size") {
|
||||
return rt.Int("size")
|
||||
}
|
||||
return rt.Int("page-size")
|
||||
}
|
||||
|
||||
func flagListStartCursor(rt *shortcut.RuntimeContext) (int, error) {
|
||||
if rt.Changed("page-token") {
|
||||
token := strings.TrimSpace(rt.Str("page-token"))
|
||||
if token == "" || token == "0" {
|
||||
return 0, nil
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("list_message_favorites", map[string]any{
|
||||
"cursor": rt.Int("cursor"),
|
||||
"size": strconv.Itoa(rt.Int("size")),
|
||||
})
|
||||
},
|
||||
value, err := strconv.Atoi(token)
|
||||
if err != nil || value < 0 {
|
||||
return 0, apperrors.NewValidation("--page-token 必须是非负整数")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
if value := rt.Int("cursor"); value < 0 {
|
||||
return 0, apperrors.NewValidation("--cursor 必须大于等于 0")
|
||||
}
|
||||
return rt.Int("cursor"), nil
|
||||
}
|
||||
|
||||
func executeFlagList(rt *shortcut.RuntimeContext) error {
|
||||
pageSize := flagListPageSize(rt)
|
||||
cursor, _ := flagListStartCursor(rt) // Validate has already accepted the cursor.
|
||||
if rt.DryRun() {
|
||||
return rt.CallMCP("list_message_favorites", flagListRequestParams(cursor, pageSize))
|
||||
}
|
||||
pageLimit := 1
|
||||
if rt.Bool("page-all") {
|
||||
pageLimit = rt.Int("page-limit")
|
||||
}
|
||||
seenCursors := map[int]bool{cursor: true}
|
||||
seenMessages := map[string]bool{}
|
||||
items := make([]map[string]any, 0)
|
||||
failures := make([]map[string]any, 0)
|
||||
pagesFetched := 0
|
||||
paginationKnown := true
|
||||
complete := false
|
||||
hasMore := false
|
||||
nextCursor := 0
|
||||
var cursorErr error
|
||||
stopReason := "source_complete"
|
||||
truncatedByPageLimit := false
|
||||
|
||||
for pagesFetched < pageLimit {
|
||||
data, callErr := rt.CallMCPData("im", "list_message_favorites", flagListRequestParams(cursor, pageSize))
|
||||
if callErr != nil {
|
||||
if pagesFetched == 0 {
|
||||
return callErr
|
||||
}
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched + 1, "stage": "read", "cursor": cursor, "error": callErr.Error(),
|
||||
})
|
||||
stopReason = "read_failure"
|
||||
break
|
||||
}
|
||||
pagesFetched++
|
||||
pageItems := flagListItems(data)
|
||||
for _, item := range pageItems {
|
||||
messageID := firstNonEmptyMapString(item, "openMessageId", "messageId", "itemId", "id")
|
||||
if messageID != "" && seenMessages[messageID] {
|
||||
continue
|
||||
}
|
||||
if messageID != "" {
|
||||
seenMessages[messageID] = true
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
page := chatmsg.Pagination(data)
|
||||
pageHasMore, hasMoreKnown := page["hasMore"].(bool)
|
||||
nextCursor, cursorErr = flagListNextCursor(page["nextCursor"])
|
||||
if !hasMoreKnown {
|
||||
switch {
|
||||
case nextCursor > 0:
|
||||
pageHasMore = true
|
||||
case len(pageItems) < pageSize:
|
||||
paginationKnown = false
|
||||
complete = true
|
||||
hasMore = false
|
||||
stopReason = "legacy_short_page"
|
||||
default:
|
||||
paginationKnown = false
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "收藏列表返回满页结果但缺少 hasMore/nextCursor,无法证明结果完整",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
}
|
||||
if complete || len(failures) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
hasMore = pageHasMore
|
||||
if !hasMore {
|
||||
complete = true
|
||||
nextCursor = 0
|
||||
stopReason = "source_complete"
|
||||
break
|
||||
}
|
||||
if cursorErr != nil || nextCursor <= 0 || seenCursors[nextCursor] {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "收藏列表返回 hasMore=true,但数字 nextCursor 缺失、无效或未前进",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
if !rt.Bool("page-all") {
|
||||
stopReason = "single_page"
|
||||
break
|
||||
}
|
||||
seenCursors[nextCursor] = true
|
||||
cursor = nextCursor
|
||||
}
|
||||
|
||||
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
|
||||
truncatedByPageLimit = rt.Bool("page-all")
|
||||
if truncatedByPageLimit {
|
||||
stopReason = "page_limit"
|
||||
}
|
||||
}
|
||||
payload := map[string]any{
|
||||
"count": len(items),
|
||||
"items": items,
|
||||
"pagesFetched": pagesFetched,
|
||||
"paginationKnown": paginationKnown,
|
||||
"complete": complete && len(failures) == 0,
|
||||
"hasMore": hasMore,
|
||||
"nextCursor": nextCursor,
|
||||
"stopReason": stopReason,
|
||||
"truncatedByPageLimit": truncatedByPageLimit,
|
||||
"failedCount": len(failures),
|
||||
"failures": failures,
|
||||
"partial": len(failures) > 0 && len(items) > 0,
|
||||
}
|
||||
if outputErr := rt.Output(payload); outputErr != nil {
|
||||
return outputErr
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return apperrors.NewAPI(
|
||||
fmt.Sprintf("收藏消息分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)),
|
||||
apperrors.WithOperation("im/list_message_favorites"),
|
||||
apperrors.WithReason("flag_list_incomplete"),
|
||||
apperrors.WithOrigin("mcp_gateway"),
|
||||
apperrors.WithFailureStage("pagination"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(true),
|
||||
apperrors.WithHint("请根据 failures 和 nextCursor 重试"),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func flagListRequestParams(cursor, pageSize int) map[string]any {
|
||||
return map[string]any{
|
||||
"cursor": cursor,
|
||||
"size": strconv.Itoa(pageSize),
|
||||
}
|
||||
}
|
||||
|
||||
func flagListItems(data map[string]any) []map[string]any {
|
||||
if data == nil {
|
||||
return []map[string]any{}
|
||||
}
|
||||
scopes := []map[string]any{data}
|
||||
for _, key := range []string{"result", "data"} {
|
||||
if nested, ok := data[key].(map[string]any); ok {
|
||||
scopes = append(scopes, nested)
|
||||
}
|
||||
}
|
||||
for _, scope := range scopes {
|
||||
for _, key := range []string{"items", "favorites", "messageFavorites", "list"} {
|
||||
raw, ok := scope[key].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
if value, ok := item.(map[string]any); ok {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
return []map[string]any{}
|
||||
}
|
||||
|
||||
func flagListNextCursor(value any) (int, error) {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return 0, nil
|
||||
case int:
|
||||
if typed < 0 {
|
||||
return 0, fmt.Errorf("negative cursor")
|
||||
}
|
||||
return typed, nil
|
||||
case int64:
|
||||
if typed < 0 || int64(int(typed)) != typed {
|
||||
return 0, fmt.Errorf("cursor out of range")
|
||||
}
|
||||
return int(typed), nil
|
||||
case float64:
|
||||
if typed < 0 || math.Trunc(typed) != typed || typed > float64(int(^uint(0)>>1)) {
|
||||
return 0, fmt.Errorf("invalid numeric cursor")
|
||||
}
|
||||
return int(typed), nil
|
||||
case json.Number:
|
||||
parsed, err := strconv.ParseInt(typed.String(), 10, 64)
|
||||
if err != nil || parsed < 0 || int64(int(parsed)) != parsed {
|
||||
return 0, fmt.Errorf("invalid json cursor")
|
||||
}
|
||||
return int(parsed), nil
|
||||
case string:
|
||||
text := strings.TrimSpace(typed)
|
||||
if text == "" || text == "0" {
|
||||
return 0, nil
|
||||
}
|
||||
parsed, err := strconv.Atoi(text)
|
||||
if err != nil || parsed < 0 {
|
||||
return 0, fmt.Errorf("invalid string cursor")
|
||||
}
|
||||
return parsed, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported cursor type %T", value)
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmptyMapString(item map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
value := strings.TrimSpace(fmt.Sprint(item[key]))
|
||||
if value != "" && value != "<nil>" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// FeedGroupQueryItem is a client-side exact filter over a DingTalk conversation
|
||||
@@ -615,18 +880,18 @@ func feedGroupQueryProject(conversations []map[string]any, requestedIDs []string
|
||||
|
||||
// ChatList is the lark-cli +chat-list alignment for DingTalk. It lists the
|
||||
// current user's conversations through list_all_conversations, defaults to
|
||||
// groups only (lark omit-types behavior), and applies --types / --exclude-muted
|
||||
// as an honest client-side subset over the current page. Sort order and bot
|
||||
// groups only (lark omit-types behavior), and applies --types after all fetched
|
||||
// pages have been merged. Sort order and bot
|
||||
// identity p2p stripping are intentionally omitted: DingTalk has no matching
|
||||
// sort_type parameter here, and DWS shortcuts run as the signed-in user.
|
||||
var ChatList = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-list",
|
||||
Product: "im",
|
||||
Description: "列出当前用户加入的会话(默认群聊;可选包含单聊)",
|
||||
Description: "分页列出当前用户加入的会话(默认群聊;可选包含单聊)",
|
||||
Intent: "当你要像 lark-cli +chat-list 一样列出当前用户加入的会话时使用;默认只返回群聊," +
|
||||
"--types 可追加 p2p(钉钉投影为 conversationType=direct)。" +
|
||||
"--exclude-muted 交给下层 excludeMuted;--types 过滤发生在当前页本地,不会伪造跨页完整性。" +
|
||||
"--exclude-muted 交给下层 excludeMuted;默认读取一页,明确要求全部时加 --page-all,合并并去重所有已读取页面后再执行 --types 过滤,结果公开完整性 ledger。" +
|
||||
"不支持 lark 的 sort/sort-type,也不模拟 bot 身份剥离 p2p。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
@@ -635,6 +900,8 @@ var ChatList = shortcut.Shortcut{
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "--page-size 的别名,必须在 1-100 之间"},
|
||||
{Name: "page-token", Type: shortcut.FlagString, Desc: "分页游标;若提供则必须是非负整数"},
|
||||
{Name: "cursor", Type: shortcut.FlagInt, Desc: "--page-token 的整数别名"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部会话分页;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
@@ -655,10 +922,12 @@ var ChatList = shortcut.Shortcut{
|
||||
Flags: []string{"page-token"},
|
||||
Description: "若提供则必须是非负整数",
|
||||
},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws chat +chat-list`,
|
||||
`dws chat +chat-list --types group,p2p --exclude-muted --page-size 50`,
|
||||
`dws chat +chat-list --types group --page-size 100 --page-all --page-limit 20`,
|
||||
},
|
||||
Validate: validateChatList,
|
||||
Execute: executeChatList,
|
||||
@@ -675,6 +944,14 @@ func validateChatList(rt *shortcut.RuntimeContext) error {
|
||||
if _, err := chatListCursor(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
if !rt.Bool("page-all") && rt.Changed("page-limit") {
|
||||
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
|
||||
}
|
||||
if rt.Bool("page-all") {
|
||||
if limit := rt.Int("page-limit"); limit < 1 || limit > chatListHardPageLimit {
|
||||
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -690,28 +967,269 @@ func executeChatList(rt *shortcut.RuntimeContext) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{"limit": chatListPageSize(rt)}
|
||||
if cursor > 0 {
|
||||
params["cursor"] = cursor
|
||||
pageSize := chatListPageSize(rt)
|
||||
requestPageSize := pageSize
|
||||
initialCursor := cursor
|
||||
pageLimit := 1
|
||||
if rt.Bool("page-all") {
|
||||
pageLimit = rt.Int("page-limit")
|
||||
}
|
||||
if rt.Bool("exclude-muted") {
|
||||
params["excludeMuted"] = true
|
||||
seenCursors := map[int]bool{cursor: true}
|
||||
seenChats := map[string]bool{}
|
||||
allChats := make([]map[string]any, 0)
|
||||
failures := make([]map[string]any, 0)
|
||||
pagesFetched := 0
|
||||
paginationKnown := true
|
||||
complete := false
|
||||
hasMore := false
|
||||
nextCursor := 0
|
||||
stopReason := "source_complete"
|
||||
truncatedByPageLimit := false
|
||||
maxWindowProbeUsed := false
|
||||
completionEvidence := ""
|
||||
|
||||
for pagesFetched < pageLimit {
|
||||
params := map[string]any{"limit": requestPageSize}
|
||||
if cursor > 0 {
|
||||
params["cursor"] = cursor
|
||||
}
|
||||
if rt.Bool("exclude-muted") {
|
||||
params["excludeMuted"] = true
|
||||
}
|
||||
data, callErr := rt.CallMCPData("im", "list_all_conversations", params)
|
||||
if callErr != nil {
|
||||
if pagesFetched == 0 {
|
||||
return callErr
|
||||
}
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched + 1, "stage": "read", "cursor": cursor, "error": callErr.Error(),
|
||||
})
|
||||
stopReason = "read_failure"
|
||||
break
|
||||
}
|
||||
pagesFetched++
|
||||
pageItems := chatListProject(data)
|
||||
for _, chat := range pageItems {
|
||||
id := firstNonEmptyMapString(chat, "openConversationId")
|
||||
if id != "" && seenChats[id] {
|
||||
continue
|
||||
}
|
||||
if id != "" {
|
||||
seenChats[id] = true
|
||||
}
|
||||
allChats = append(allChats, chat)
|
||||
}
|
||||
|
||||
page, paginationErr := chatListPagination(data)
|
||||
if paginationErr != nil {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination", "error": paginationErr.Error(),
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
pageHasMore, hasMoreKnown := page["hasMore"].(bool)
|
||||
nextCursor, err = chatListNextCursor(page["nextCursor"])
|
||||
if !hasMoreKnown {
|
||||
switch {
|
||||
case nextCursor > 0:
|
||||
pageHasMore = true
|
||||
case len(pageItems) < requestPageSize:
|
||||
paginationKnown = false
|
||||
complete = true
|
||||
hasMore = false
|
||||
stopReason = "legacy_short_page"
|
||||
default:
|
||||
paginationKnown = false
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "会话列表返回满页结果但缺少 hasMore/nextCursor,无法证明结果完整",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
}
|
||||
if complete || len(failures) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
hasMore = pageHasMore
|
||||
if !hasMore {
|
||||
// A full first page with hasMore=false/no cursor is the live legacy
|
||||
// shape that can hide additional rows. Once a prior page supplied a
|
||||
// valid continuation cursor, its terminal hasMore=false is trustworthy.
|
||||
fullPageWithoutCursor := len(pageItems) >= requestPageSize && nextCursor <= 0 &&
|
||||
(pagesFetched == 1 || (maxWindowProbeUsed && pagesFetched == 2))
|
||||
if fullPageWithoutCursor {
|
||||
paginationKnown = false
|
||||
if rt.Bool("page-all") && initialCursor == 0 && !maxWindowProbeUsed && requestPageSize < chatListMaxWindowSize && pagesFetched < pageLimit {
|
||||
maxWindowProbeUsed = true
|
||||
requestPageSize = chatListMaxWindowSize
|
||||
cursor = initialCursor
|
||||
stopReason = "max_window_probe"
|
||||
continue
|
||||
}
|
||||
if !rt.Bool("page-all") {
|
||||
complete = false
|
||||
stopReason = "single_page_full_untrusted"
|
||||
break
|
||||
}
|
||||
if pagesFetched >= pageLimit && requestPageSize < chatListMaxWindowSize {
|
||||
truncatedByPageLimit = true
|
||||
complete = false
|
||||
stopReason = "page_limit"
|
||||
break
|
||||
}
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "会话列表在最大窗口仍返回满页且没有可用 nextCursor,无法证明结果完整",
|
||||
})
|
||||
complete = false
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
complete = true
|
||||
nextCursor = 0
|
||||
if maxWindowProbeUsed {
|
||||
paginationKnown = false
|
||||
completionEvidence = "max_window_short_page"
|
||||
stopReason = "max_window_short_page"
|
||||
} else {
|
||||
completionEvidence = "backend_has_more_false_short_page"
|
||||
stopReason = "source_complete"
|
||||
}
|
||||
break
|
||||
}
|
||||
if err != nil || nextCursor <= 0 || seenCursors[nextCursor] {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "会话列表返回 hasMore=true,但数字 nextCursor 缺失、无效或未前进",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
if !rt.Bool("page-all") {
|
||||
stopReason = "single_page"
|
||||
break
|
||||
}
|
||||
seenCursors[nextCursor] = true
|
||||
cursor = nextCursor
|
||||
}
|
||||
data, err := rt.CallMCPData("im", "list_all_conversations", params)
|
||||
if err != nil {
|
||||
return err
|
||||
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
|
||||
truncatedByPageLimit = rt.Bool("page-all")
|
||||
if truncatedByPageLimit {
|
||||
stopReason = "page_limit"
|
||||
}
|
||||
}
|
||||
chats := chatListFilterTypes(chatListProject(data), types)
|
||||
chats := chatListFilterTypes(allChats, types)
|
||||
payload := map[string]any{
|
||||
"count": len(chats),
|
||||
"chats": chats,
|
||||
"requestedTypes": types,
|
||||
"count": len(chats),
|
||||
"chats": chats,
|
||||
"requestedTypes": types,
|
||||
"pagesFetched": pagesFetched,
|
||||
"paginationKnown": paginationKnown,
|
||||
"complete": complete && len(failures) == 0,
|
||||
"hasMore": hasMore,
|
||||
"nextCursor": nextCursor,
|
||||
"stopReason": stopReason,
|
||||
"truncatedByPageLimit": truncatedByPageLimit,
|
||||
"failedCount": len(failures),
|
||||
"failures": failures,
|
||||
"partial": len(failures) > 0 && len(chats) > 0,
|
||||
"requestedPageSize": pageSize,
|
||||
"effectivePageSize": requestPageSize,
|
||||
}
|
||||
if completionEvidence != "" {
|
||||
payload["completionEvidence"] = completionEvidence
|
||||
}
|
||||
if rt.Bool("exclude-muted") {
|
||||
payload["filter"] = map[string]any{"excludeMuted": true}
|
||||
}
|
||||
chatmsg.ApplyPagination(payload, data)
|
||||
return rt.Output(payload)
|
||||
if outputErr := rt.Output(payload); outputErr != nil {
|
||||
return outputErr
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return apperrors.NewAPI(
|
||||
fmt.Sprintf("会话列表分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)),
|
||||
apperrors.WithOperation("im/list_all_conversations"),
|
||||
apperrors.WithReason("chat_list_incomplete"),
|
||||
apperrors.WithOrigin("mcp_gateway"),
|
||||
apperrors.WithFailureStage("pagination"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(true),
|
||||
apperrors.WithHint("请根据 failures 和 nextCursor 重试"),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// chatListPagination preserves the shared map/envelope pagination formats and
|
||||
// supplements them with the list_all_conversations gateway tuple:
|
||||
// result:[conversationList,nextCursor,hasMore]. Tuple positions are specific to
|
||||
// this RPC, so they are normalized here instead of broadening chatmsg.Pagination
|
||||
// for unrelated Chat shortcuts.
|
||||
func chatListPagination(data map[string]any) (map[string]any, error) {
|
||||
page := chatmsg.Pagination(data)
|
||||
for _, key := range []string{"result", "data"} {
|
||||
values, ok := data[key].([]any)
|
||||
if !ok || len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, tuple := values[0].([]any); !tuple {
|
||||
continue
|
||||
}
|
||||
if len(values) < 3 {
|
||||
return nil, fmt.Errorf("会话列表 tuple 分页元数据不完整:期望 [conversationList,nextCursor,hasMore]")
|
||||
}
|
||||
hasMore, ok := values[2].(bool)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("会话列表 tuple 的 hasMore 必须是布尔值")
|
||||
}
|
||||
return map[string]any{
|
||||
"nextCursor": values[1],
|
||||
"hasMore": hasMore,
|
||||
"complete": !hasMore,
|
||||
}, nil
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func chatListNextCursor(value any) (int, error) {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return 0, nil
|
||||
case int:
|
||||
if typed < 0 {
|
||||
return 0, fmt.Errorf("negative cursor")
|
||||
}
|
||||
return typed, nil
|
||||
case int64:
|
||||
if typed < 0 || int64(int(typed)) != typed {
|
||||
return 0, fmt.Errorf("cursor out of range")
|
||||
}
|
||||
return int(typed), nil
|
||||
case float64:
|
||||
if typed < 0 || math.Trunc(typed) != typed || typed > float64(int(^uint(0)>>1)) {
|
||||
return 0, fmt.Errorf("invalid numeric cursor")
|
||||
}
|
||||
return int(typed), nil
|
||||
case json.Number:
|
||||
parsed, err := strconv.ParseInt(typed.String(), 10, 64)
|
||||
if err != nil || parsed < 0 || int64(int(parsed)) != parsed {
|
||||
return 0, fmt.Errorf("invalid json cursor")
|
||||
}
|
||||
return int(parsed), nil
|
||||
case string:
|
||||
text := strings.TrimSpace(typed)
|
||||
if text == "" || text == "0" {
|
||||
return 0, nil
|
||||
}
|
||||
parsed, err := strconv.Atoi(text)
|
||||
if err != nil || parsed < 0 {
|
||||
return 0, fmt.Errorf("invalid string cursor")
|
||||
}
|
||||
return parsed, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported cursor type %T", value)
|
||||
}
|
||||
}
|
||||
|
||||
func chatListPageSize(rt *shortcut.RuntimeContext) int {
|
||||
|
||||
@@ -24,6 +24,7 @@ type larkAlignmentCall struct {
|
||||
|
||||
type larkAlignmentCaller struct {
|
||||
calls []larkAlignmentCall
|
||||
dryRun bool
|
||||
failTarget string
|
||||
failProductTool string
|
||||
failProductToolAt map[string]int
|
||||
@@ -97,7 +98,7 @@ func (f *larkAlignmentCaller) CallReadTool(ctx context.Context, product, tool st
|
||||
}
|
||||
|
||||
func (f *larkAlignmentCaller) Format() string { return "json" }
|
||||
func (f *larkAlignmentCaller) DryRun() bool { return false }
|
||||
func (f *larkAlignmentCaller) DryRun() bool { return f.dryRun }
|
||||
func (f *larkAlignmentCaller) Fields() string { return "" }
|
||||
func (f *larkAlignmentCaller) JQ() string { return "" }
|
||||
|
||||
@@ -409,12 +410,12 @@ func TestCrossPlatformCoverageLarkAlignmentWriteMappings(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "flag-list",
|
||||
args: []string{"chat", "+flag-list", "--cursor", "3", "--size", "50"},
|
||||
args: []string{"chat", "+flag-list", "--cursor", "3", "--size", "30"},
|
||||
product: "im",
|
||||
tool: "list_message_favorites",
|
||||
wantArgs: map[string]any{
|
||||
"cursor": 3,
|
||||
"size": "50",
|
||||
"size": "30",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -705,7 +706,7 @@ func TestCrossPlatformCoverageFindMessageSenderOpenDingTalkIDIgnoresUnrelatedNes
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatListDefaultsToGroupsAndSupportsLarkAliases(t *testing.T) {
|
||||
func TestCrossPlatformCoverageChatListDefaultsToGroupsAndSupportsLarkAliases(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{
|
||||
responses: map[string]string{
|
||||
"im/list_all_conversations": `{
|
||||
@@ -755,7 +756,7 @@ func TestChatListDefaultsToGroupsAndSupportsLarkAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatListIncludesP2PAndRejectsInvalidTypes(t *testing.T) {
|
||||
func TestCrossPlatformCoverageChatListIncludesP2PAndRejectsInvalidTypes(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{
|
||||
responses: map[string]string{
|
||||
"im/list_all_conversations": `{
|
||||
@@ -798,7 +799,7 @@ func TestChatListIncludesP2PAndRejectsInvalidTypes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatListP2POnlyDropsGroups(t *testing.T) {
|
||||
func TestCrossPlatformCoverageChatListP2POnlyDropsGroups(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{
|
||||
responses: map[string]string{
|
||||
"im/list_all_conversations": `{
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageChatListAllPageAllUsesNumericCursorAndDeduplicates(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
|
||||
"im/list_my_groups_pagination": {
|
||||
`{"result":{"groups":[{"openConversationId":"g1","title":"群一"}],"hasMore":true,"nextCursor":88}}`,
|
||||
`{"result":{"groups":[{"openConversationId":"g1","title":"重复群"},{"openConversationId":"g2","title":"群二"}],"hasMore":false}}`,
|
||||
},
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+chat-list-all", "--limit", "1", "--page-all", "--page-limit", "5"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 2 || fake.calls[1].args["cursor"] != float64(88) {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(2) || payload["pagesFetched"] != float64(2) ||
|
||||
payload["complete"] != true || payload["hasMore"] != false || payload["failedCount"] != float64(0) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDirectMessagesPageAllUsesMillisecondCursor(t *testing.T) {
|
||||
const cursorMillis int64 = 1785919699136
|
||||
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
|
||||
"chat/list_individual_chat_message": {
|
||||
`{"result":{"messages":[{"openMessageId":"m2","createTime":"2026-08-05 16:48:19"}],"hasMore":true,"nextCursor":1785919699136}}`,
|
||||
`{"result":{"messages":[{"openMessageId":"m1","createTime":"2026-08-05 16:48:19"}],"hasMore":false}}`,
|
||||
},
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-list-direct", "--open-dingtalk-id", "D-user",
|
||||
"--time", "2026-08-05 16:49:00", "--forward=false", "--limit", "1",
|
||||
"--page-all", "--page-limit", "5",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantBoundary := time.UnixMilli(cursorMillis).UTC().Format(time.RFC3339Nano)
|
||||
if len(fake.calls) != 2 || fake.calls[1].args["time"] != wantBoundary {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(2) || payload["pagesFetched"] != float64(2) ||
|
||||
payload["complete"] != true || payload["failedCount"] != float64(0) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDirectMessagesPageLimitPublishesExecutableContinuation(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"chat/list_individual_chat_message": `{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":1785919699136}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-08-05 16:49:00",
|
||||
"--page-all", "--page-limit", "1",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["complete"] != false || payload["hasMore"] != true ||
|
||||
payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" || payload["nextPage"] == nil {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDirectMessagesPageAllFailsClosedWithoutMillisecondCursor(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"chat/list_individual_chat_message": `{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-08-05 16:49:00", "--page-all",
|
||||
})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("missing direct-message cursor unexpectedly succeeded")
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["complete"] != false || payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatListAllLaterReadFailureKeepsPartialLedger(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{
|
||||
sequenceResponses: map[string][]string{
|
||||
"im/list_my_groups_pagination": {`{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":88}}`},
|
||||
},
|
||||
failProductToolAt: map[string]int{"im/list_my_groups_pagination": 2},
|
||||
}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+chat-list-all", "--page-all"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("later group-list failure unexpectedly succeeded")
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(1) || payload["complete"] != false || payload["partial"] != true ||
|
||||
payload["failedCount"] != float64(1) || payload["stopReason"] != "read_failure" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatRemainingPaginationValidation(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"chat", "+chat-list-all", "--limit", "201"},
|
||||
{"chat", "+chat-list-all", "--page-limit", "2"},
|
||||
{"chat", "+chat-list-all", "--page-all", "--page-limit", "501"},
|
||||
{"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-01-01", "--page-limit", "2"},
|
||||
{"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-01-01", "--page-all", "--page-limit", "501"},
|
||||
} {
|
||||
helpers.InitDeps(&larkAlignmentCaller{})
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatalf("invalid args succeeded: %v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatListAllAdditionalEdges(t *testing.T) {
|
||||
run := func(t *testing.T, fake *larkAlignmentCaller, args ...string) (map[string]any, error) {
|
||||
t.Helper()
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs(append([]string{"chat", "+chat-list-all"}, args...))
|
||||
err := root.Execute()
|
||||
if output.Len() == 0 {
|
||||
return nil, err
|
||||
}
|
||||
var payload map[string]any
|
||||
if decodeErr := json.Unmarshal(output.Bytes(), &payload); decodeErr != nil {
|
||||
t.Fatal(decodeErr)
|
||||
}
|
||||
return payload, err
|
||||
}
|
||||
|
||||
t.Run("single page terminal and continuing", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
response string
|
||||
stop string
|
||||
}{
|
||||
{name: "terminal", response: `{"result":{"groups":[],"hasMore":false}}`, stop: "source_complete"},
|
||||
{name: "continuing", response: `{"result":{"groups":[],"hasMore":true,"nextCursor":2}}`, stop: "single_page"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{"im/list_my_groups_pagination": tc.response}})
|
||||
if err != nil || payload["stopReason"] != tc.stop || payload["pagesFetched"] != float64(1) {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("first read failure", func(t *testing.T) {
|
||||
for _, args := range [][]string{nil, {"--page-all"}} {
|
||||
_, err := run(t, &larkAlignmentCaller{failProductToolAt: map[string]int{"im/list_my_groups_pagination": 1}}, args...)
|
||||
if err == nil {
|
||||
t.Fatalf("first read failure unexpectedly succeeded for %v", args)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
response string
|
||||
args []string
|
||||
wantErr bool
|
||||
stop string
|
||||
}{
|
||||
{name: "unknown pagination", response: `{"result":{"groups":[]}}`, args: []string{"--page-all"}, wantErr: true, stop: "pagination_error"},
|
||||
{name: "missing continuation", response: `{"result":{"groups":[],"hasMore":true}}`, args: []string{"--page-all"}, wantErr: true, stop: "pagination_error"},
|
||||
{name: "page limit", response: `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":2}}`, args: []string{"--page-all", "--page-limit", "1"}, stop: "page_limit"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{"im/list_my_groups_pagination": tc.response}}, tc.args...)
|
||||
if (err != nil) != tc.wantErr || payload["stopReason"] != tc.stop {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("output errors propagate", func(t *testing.T) {
|
||||
for _, args := range [][]string{nil, {"--page-all"}} {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_my_groups_pagination": `{"result":{"groups":[],"hasMore":false}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(chatOutputErrorWriter{err: errors.New("fixture output")})
|
||||
root.SetArgs(append([]string{"chat", "+chat-list-all"}, args...))
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatalf("output error swallowed for args %v", args)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for _, value := range []any{nil, "", "0", "<nil>"} {
|
||||
if got := chatListAllCursorString(value); got != "" {
|
||||
t.Fatalf("cursor %v = %q", value, got)
|
||||
}
|
||||
}
|
||||
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/list_my_groups_pagination": `{"result":{"groups":[{"title":"无 ID 群"}],"hasMore":false}}`,
|
||||
}}, "--page-all")
|
||||
if err != nil || payload["count"] != float64(1) {
|
||||
t.Fatalf("missing-id payload=%#v err=%v", payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDirectMessagesAdditionalEdges(t *testing.T) {
|
||||
run := func(t *testing.T, fake *larkAlignmentCaller, args ...string) (map[string]any, error) {
|
||||
t.Helper()
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs(append([]string{
|
||||
"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-08-05 16:49:00",
|
||||
}, args...))
|
||||
err := root.Execute()
|
||||
if output.Len() == 0 {
|
||||
return nil, err
|
||||
}
|
||||
var payload map[string]any
|
||||
if decodeErr := json.Unmarshal(output.Bytes(), &payload); decodeErr != nil {
|
||||
t.Fatal(decodeErr)
|
||||
}
|
||||
return payload, err
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"chat", "+messages-list-direct", "--time", "2026-01-01"},
|
||||
{"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-01-01", "--limit", "0"},
|
||||
{"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-01-01", "--size", "0"},
|
||||
} {
|
||||
helpers.InitDeps(&larkAlignmentCaller{})
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatalf("invalid args succeeded: %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("single page terminal and continuing", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
response string
|
||||
stop string
|
||||
}{
|
||||
{name: "terminal", response: `{"result":{"messages":[],"hasMore":false}}`, stop: "source_complete"},
|
||||
{name: "continuing", response: `{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":1785919699136}}`, stop: "single_page"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{"chat/list_individual_chat_message": tc.response}})
|
||||
if err != nil || payload["stopReason"] != tc.stop {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("read failures", func(t *testing.T) {
|
||||
_, err := run(t, &larkAlignmentCaller{failProductToolAt: map[string]int{"chat/list_individual_chat_message": 1}})
|
||||
if err == nil {
|
||||
t.Fatal("single-page read failure unexpectedly succeeded")
|
||||
}
|
||||
_, err = run(t, &larkAlignmentCaller{failProductToolAt: map[string]int{"chat/list_individual_chat_message": 1}}, "--page-all")
|
||||
if err == nil {
|
||||
t.Fatal("first all-page read failure unexpectedly succeeded")
|
||||
}
|
||||
payload, err := run(t, &larkAlignmentCaller{
|
||||
sequenceResponses: map[string][]string{
|
||||
"chat/list_individual_chat_message": {`{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":1785919699136}}`},
|
||||
},
|
||||
failProductToolAt: map[string]int{"chat/list_individual_chat_message": 2},
|
||||
}, "--page-all")
|
||||
if err == nil || payload["partial"] != true || payload["stopReason"] != "read_failure" {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
responses []string
|
||||
}{
|
||||
{name: "unknown pagination", responses: []string{`{"result":{"messages":[{"openMessageId":"m1"}]}}`}},
|
||||
{name: "empty continuing page", responses: []string{`{"result":{"messages":[],"hasMore":true,"nextCursor":1785919699136}}`}},
|
||||
{name: "stalled cursor and duplicate", responses: []string{
|
||||
`{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":1785919699136}}`,
|
||||
`{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":1785919699136}}`,
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload, err := run(t, &larkAlignmentCaller{sequenceResponses: map[string][]string{
|
||||
"chat/list_individual_chat_message": tc.responses,
|
||||
}}, "--page-all")
|
||||
if err == nil || payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("all-page output errors propagate", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"chat/list_individual_chat_message": `{"result":{"messages":[],"hasMore":false}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(chatOutputErrorWriter{err: errors.New("fixture output")})
|
||||
root.SetArgs([]string{"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-01-01", "--page-all"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("output error was swallowed")
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
value any
|
||||
ok bool
|
||||
}{
|
||||
{name: "int", value: int(1), ok: true},
|
||||
{name: "int32", value: int32(2), ok: true},
|
||||
{name: "float32", value: float32(3), ok: true},
|
||||
{name: "float64", value: float64(4), ok: true},
|
||||
{name: "string", value: "5", ok: true},
|
||||
{name: "fractional float32", value: float32(1.5)},
|
||||
{name: "negative float64", value: float64(-1)},
|
||||
{name: "invalid string", value: "invalid"},
|
||||
{name: "unsupported", value: true},
|
||||
{name: "zero", value: int64(0)},
|
||||
} {
|
||||
t.Run("cursor "+tc.name, func(t *testing.T) {
|
||||
key, boundary, err := directMessageCursorBoundary(tc.value)
|
||||
if tc.ok {
|
||||
if err != nil || key == "" || boundary == "" {
|
||||
t.Fatalf("cursor=(%q,%q,%v)", key, boundary, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("invalid cursor succeeded: (%q,%q)", key, boundary)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -32,10 +32,13 @@ package chatmsg
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MessageListContractVersion identifies the additive, compatibility-preserving
|
||||
@@ -75,6 +78,8 @@ var messageResultContractV1 = MessageResultContract{
|
||||
"contractVersion",
|
||||
"messages",
|
||||
"count",
|
||||
"resolvedFilters",
|
||||
"queryRange",
|
||||
"pagesFetched",
|
||||
"paginationKnown",
|
||||
"complete",
|
||||
@@ -774,10 +779,10 @@ func ApplyPagination(payload, data map[string]any) {
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyMessagePagination publishes message-list completeness without claiming
|
||||
// the lower response's nextCursor is a valid CLI input. DingTalk's executable
|
||||
// message-list contract paginates with the boundary message createTime, so the
|
||||
// resume object uses exactly that accepted parameter.
|
||||
// ApplyMessagePagination publishes message-list completeness and converts the
|
||||
// authoritative millisecond nextCursor into the RFC3339Nano time boundary
|
||||
// accepted by the executable message-list command. Projected createTime is
|
||||
// deliberately not used because it is only second precision.
|
||||
func ApplyMessagePagination(payload, data map[string]any, messages []map[string]any, direction string) {
|
||||
payload["contractVersion"] = MessageListContractVersion
|
||||
payload["pagesFetched"] = 1
|
||||
@@ -812,25 +817,70 @@ func ApplyMessagePagination(payload, data map[string]any, messages []map[string]
|
||||
payload["complete"] = value
|
||||
}
|
||||
hasMore, _ := page["hasMore"].(bool)
|
||||
if !hasMore || len(messages) == 0 {
|
||||
if !hasMore {
|
||||
return
|
||||
}
|
||||
boundary := CreateTime(messages[len(messages)-1])
|
||||
if boundary == nil {
|
||||
if len(messages) == 0 {
|
||||
payload["failedCount"] = 1
|
||||
payload["failures"] = []map[string]any{{
|
||||
"stage": "pagination",
|
||||
"error": "下层返回 hasMore=true,但末条消息缺少可继续读取的 createTime",
|
||||
"error": "下层返回 hasMore=true 但当前页没有消息",
|
||||
}}
|
||||
return
|
||||
}
|
||||
next := map[string]any{"time": boundary}
|
||||
_, boundary, err := messagePaginationCursorBoundary(page["nextCursor"])
|
||||
if err != nil {
|
||||
payload["failedCount"] = 1
|
||||
payload["failures"] = []map[string]any{{
|
||||
"stage": "pagination",
|
||||
"error": "下层返回 hasMore=true,但 nextCursor 无效: " + err.Error(),
|
||||
}}
|
||||
return
|
||||
}
|
||||
next := map[string]any{"time": boundary, "nextCursor": page["nextCursor"]}
|
||||
if strings.TrimSpace(direction) != "" {
|
||||
next["direction"] = direction
|
||||
}
|
||||
payload["nextPage"] = next
|
||||
}
|
||||
|
||||
func messagePaginationCursorBoundary(value any) (string, string, error) {
|
||||
var millis int64
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
millis = int64(typed)
|
||||
case int32:
|
||||
millis = int64(typed)
|
||||
case int64:
|
||||
millis = typed
|
||||
case float32:
|
||||
asFloat := float64(typed)
|
||||
if math.IsNaN(asFloat) || math.IsInf(asFloat, 0) || asFloat <= 0 || math.Trunc(asFloat) != asFloat || asFloat > math.MaxInt64 {
|
||||
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
|
||||
}
|
||||
millis = int64(asFloat)
|
||||
case float64:
|
||||
if math.IsNaN(typed) || math.IsInf(typed, 0) || typed <= 0 || math.Trunc(typed) != typed || typed > math.MaxInt64 {
|
||||
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
|
||||
}
|
||||
millis = int64(typed)
|
||||
case string:
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
|
||||
}
|
||||
millis = parsed
|
||||
default:
|
||||
return "", "", fmt.Errorf("缺少毫秒级分页游标")
|
||||
}
|
||||
if millis <= 0 {
|
||||
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
|
||||
}
|
||||
key := strconv.FormatInt(millis, 10)
|
||||
boundary := time.UnixMilli(millis).UTC().Format(time.RFC3339Nano)
|
||||
return key, boundary, nil
|
||||
}
|
||||
|
||||
// Pagination extracts hasMore/nextCursor from the response root or a common
|
||||
// result/data envelope. When hasMore is present it also emits the explicit
|
||||
// inverse "complete", making truncation hard for an Agent to overlook.
|
||||
|
||||
@@ -16,6 +16,7 @@ package chatmsg
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSender(t *testing.T) {
|
||||
@@ -220,6 +221,46 @@ func TestCrossPlatformCoverageMessageLedgerNilAndCursorOnlyBoundaries(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagePaginationCursorTypeEdges(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
value any
|
||||
ok bool
|
||||
}{
|
||||
{name: "int", value: int(1), ok: true},
|
||||
{name: "int32", value: int32(2), ok: true},
|
||||
{name: "float32", value: float32(3), ok: true},
|
||||
{name: "float64", value: float64(4), ok: true},
|
||||
{name: "string", value: "5", ok: true},
|
||||
{name: "fractional float32", value: float32(1.5)},
|
||||
{name: "negative float64", value: float64(-1)},
|
||||
{name: "invalid string", value: "not-a-cursor"},
|
||||
{name: "unsupported", value: true},
|
||||
{name: "zero", value: int64(0)},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
key, boundary, err := messagePaginationCursorBoundary(tc.value)
|
||||
if tc.ok {
|
||||
if err != nil || key == "" || boundary == "" {
|
||||
t.Fatalf("cursor = (%q, %q, %v)", key, boundary, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("invalid cursor unexpectedly succeeded: (%q, %q)", key, boundary)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
payload := NewMessageListPayload(nil)
|
||||
ApplyMessagePagination(payload, map[string]any{
|
||||
"result": map[string]any{"hasMore": true, "nextCursor": int64(1)},
|
||||
}, nil, "older")
|
||||
if payload["failedCount"] != 1 || payload["complete"] != false {
|
||||
t.Fatalf("empty continuing page = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageQuotedMessageIsBoundedAndSemantic(t *testing.T) {
|
||||
got := QuotedMessage(map[string]any{
|
||||
"quotedMessage": map[string]any{
|
||||
@@ -423,26 +464,41 @@ func TestCrossPlatformCoverageApplyPaginationReadsNestedEnvelope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageApplyMessagePaginationUsesExecutableTimeBoundary(t *testing.T) {
|
||||
func TestCrossPlatformCoverageApplyMessagePaginationUsesAuthoritativeMillisecondCursor(t *testing.T) {
|
||||
const cursorMillis int64 = 1785919699136
|
||||
payload := map[string]any{}
|
||||
ApplyMessagePagination(payload, map[string]any{
|
||||
"result": map[string]any{
|
||||
"hasMore": true,
|
||||
"nextCursor": "not-a-message-list-cli-flag",
|
||||
"nextCursor": cursorMillis,
|
||||
},
|
||||
}, []map[string]any{
|
||||
{"createTime": "2026-07-28 10:00:00"},
|
||||
{"createTime": "2026-07-28 09:00:00"},
|
||||
}, "older")
|
||||
if _, leaked := payload["nextCursor"]; leaked {
|
||||
t.Fatalf("message pagination exposed unusable cursor: %#v", payload)
|
||||
t.Fatalf("message pagination exposed cursor outside nextPage: %#v", payload)
|
||||
}
|
||||
wantBoundary := time.UnixMilli(cursorMillis).UTC().Format(time.RFC3339Nano)
|
||||
next, ok := payload["nextPage"].(map[string]any)
|
||||
if !ok || next["time"] != "2026-07-28 09:00:00" || next["direction"] != "older" {
|
||||
if !ok || next["time"] != wantBoundary || next["nextCursor"] != cursorMillis || next["direction"] != "older" {
|
||||
t.Fatalf("message nextPage = %#v", payload["nextPage"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageApplyMessagePaginationStopsWhenComplete(t *testing.T) {
|
||||
payload := map[string]any{}
|
||||
ApplyMessagePagination(payload, map[string]any{
|
||||
"result": map[string]any{"hasMore": false},
|
||||
}, nil, "older")
|
||||
if payload["paginationKnown"] != true || payload["hasMore"] != false || payload["complete"] != true {
|
||||
t.Fatalf("completed message pagination = %#v", payload)
|
||||
}
|
||||
if _, ok := payload["nextPage"]; ok {
|
||||
t.Fatalf("completed message pagination unexpectedly exposed nextPage: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageApplyMessagePaginationFailsClosedWhenCompletenessIsUnknown(t *testing.T) {
|
||||
payload := map[string]any{}
|
||||
ApplyMessagePagination(payload, map[string]any{"result": map[string]any{"messages": []any{}}}, nil, "older")
|
||||
|
||||
+189
-15
@@ -5,6 +5,7 @@ package doc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
@@ -16,7 +17,11 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
const compositeInterfaceReason = "Reviewed Doc Shortcut composite: the executable CLI owns validation, multi-step orchestration, local I/O, output projection, and confirmation; no single MCP interface represents the complete command contract."
|
||||
const (
|
||||
compositeInterfaceReason = "Reviewed Doc Shortcut composite: the executable CLI owns validation, multi-step orchestration, local I/O, output projection, and confirmation; no single MCP interface represents the complete command contract."
|
||||
docContentInputDescription = "内容字面量、@工作目录相对文件或 - 表示 stdin"
|
||||
docRequiredContentInputDescription = docContentInputDescription + ";相关动作要求时不能为空"
|
||||
)
|
||||
|
||||
func docContract(command, description, intent string, examples []string, params ...contract.ParamDecl) corecmd.ContractDecl {
|
||||
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
|
||||
@@ -66,7 +71,7 @@ func readShortcutContent(rt *shortcut.RuntimeContext, flag string) (string, erro
|
||||
}
|
||||
path := strings.TrimSpace(strings.TrimPrefix(raw, "@"))
|
||||
if path == "" || filepath.IsAbs(path) {
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 只接受工作目录内的相对路径", flag))
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 只接受工作目录内的相对路径;请先暂存到工作目录后传 @相对路径,或改用 --%s - 从 stdin 读取", flag, flag))
|
||||
}
|
||||
cwd, err := docGetwd()
|
||||
if err != nil {
|
||||
@@ -82,7 +87,7 @@ func readShortcutContent(rt *shortcut.RuntimeContext, flag string) (string, erro
|
||||
}
|
||||
rel, err := docRel(realBase, realPath)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(filepath.ToSlash(rel), "../") {
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 不能逃逸工作目录", flag))
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 不能逃逸工作目录;请先暂存到工作目录后传 @相对路径,或改用 --%s - 从 stdin 读取", flag, flag))
|
||||
}
|
||||
data, err := docReadFile(realPath)
|
||||
if err != nil {
|
||||
@@ -91,30 +96,198 @@ func readShortcutContent(rt *shortcut.RuntimeContext, flag string) (string, erro
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func validateWorkspaceInputPath(flag, raw string) error {
|
||||
path := strings.TrimSpace(raw)
|
||||
if path == "" || filepath.IsAbs(path) {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--%s 只接受工作目录内已暂存文件的相对路径", flag))
|
||||
}
|
||||
cwd, err := docGetwd()
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("读取工作目录失败: %v", err))
|
||||
}
|
||||
realBase, err := docEvalSymlinks(cwd)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err))
|
||||
}
|
||||
realPath, err := docEvalSymlinks(filepath.Join(realBase, filepath.Clean(path)))
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--%s: 读取文件 %q 失败: %v", flag, path, err))
|
||||
}
|
||||
rel, err := docRel(realBase, realPath)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(filepath.ToSlash(rel), "../") {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--%s 不能逃逸工作目录;请先把文件暂存到工作目录", flag))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateJSONML(raw string) (string, error) {
|
||||
var value any
|
||||
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("JSONML 解析失败: %v", err))
|
||||
}
|
||||
if _, ok := value.([]any); !ok {
|
||||
root, ok := value.([]any)
|
||||
if !ok {
|
||||
return "", apperrors.NewValidation("JSONML 顶层必须是数组")
|
||||
}
|
||||
if len(root) == 0 {
|
||||
return "", apperrors.NewValidation("JSONML 顶层数组不能为空")
|
||||
}
|
||||
if _, nestedRoot := root[0].([]any); nestedRoot {
|
||||
return "", apperrors.NewValidation("JSONML 顶层必须是单个元素,不能使用元素数组包裹;请传 [\"root\", {...}, ...] 或单个 block 元素")
|
||||
}
|
||||
if tag, ok := root[0].(string); !ok || strings.TrimSpace(tag) == "" {
|
||||
return "", apperrors.NewValidation("JSONML 第一个元素必须是非空标签名")
|
||||
}
|
||||
normalized, _ := json.Marshal(value) // decoded JSON trees are always marshalable
|
||||
return string(normalized), nil
|
||||
}
|
||||
|
||||
func docEnvelope(operation string, data any, steps ...map[string]any) map[string]any {
|
||||
return map[string]any{
|
||||
"ok": true,
|
||||
"status": "success",
|
||||
"operation": operation,
|
||||
"steps": steps,
|
||||
"data": data,
|
||||
"warnings": []string{},
|
||||
"compensation": map[string]any{"available": false, "reason": ""},
|
||||
"contractVersion": "doc.operation.v1",
|
||||
"ok": true,
|
||||
"status": "success",
|
||||
"complete": true,
|
||||
"operation": operation,
|
||||
"steps": steps,
|
||||
"data": data,
|
||||
"warnings": []string{},
|
||||
"compensation": map[string]any{"available": false, "reason": ""},
|
||||
}
|
||||
}
|
||||
|
||||
type docFailureState string
|
||||
|
||||
const (
|
||||
docFailureFailed docFailureState = "failed"
|
||||
docFailureRetryable docFailureState = "retryable"
|
||||
docFailureUnknown docFailureState = "unknown"
|
||||
)
|
||||
|
||||
type docFailureTransition struct {
|
||||
state docFailureState
|
||||
reason string
|
||||
retryable bool
|
||||
executionStarted bool
|
||||
message string
|
||||
actions []string
|
||||
}
|
||||
|
||||
func classifyDocWriteFailure(cause error) docFailureTransition {
|
||||
transition := docFailureTransition{
|
||||
state: docFailureUnknown,
|
||||
reason: "doc_write_commit_unknown",
|
||||
retryable: false,
|
||||
executionStarted: true,
|
||||
message: "文档写入结果未知;为避免重复创建或重复追加,已停止自动重试",
|
||||
actions: []string{"先读取目标文档确认实际写入状态", "仅在确认服务端未提交后重新执行"},
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(cause, &typed) {
|
||||
return transition
|
||||
}
|
||||
reason := strings.ToLower(strings.TrimSpace(typed.Reason))
|
||||
message := strings.ToLower(typed.Message)
|
||||
if typed.Category == apperrors.CategoryValidation {
|
||||
if reason == "" {
|
||||
reason = "invalid_input"
|
||||
}
|
||||
return docFailureTransition{
|
||||
state: docFailureFailed,
|
||||
reason: reason,
|
||||
retryable: false,
|
||||
executionStarted: false,
|
||||
message: typed.Message,
|
||||
actions: []string{"根据错误中的参数约束修正输入后重新执行"},
|
||||
}
|
||||
}
|
||||
permissionDenied := strings.Contains(reason, "403") || strings.Contains(reason, "permission") ||
|
||||
strings.Contains(message, "permission") || strings.Contains(message, "forbidden") || strings.Contains(message, "权限")
|
||||
if permissionDenied {
|
||||
return docFailureTransition{
|
||||
state: docFailureFailed,
|
||||
reason: "permission_denied",
|
||||
retryable: false,
|
||||
executionStarted: false,
|
||||
message: "当前身份没有执行该文档操作的权限;已终止,不会重试或改走同义命令",
|
||||
actions: []string{"检查当前 profile 与文档权限", "获得权限后重新发起一次原操作"},
|
||||
}
|
||||
}
|
||||
if typed.Category == apperrors.CategoryAuth {
|
||||
return docFailureTransition{
|
||||
state: docFailureFailed,
|
||||
reason: "authentication_required",
|
||||
retryable: false,
|
||||
executionStarted: false,
|
||||
message: "当前身份认证不可用;已终止文档写入",
|
||||
actions: []string{"检查当前 profile 与登录状态", "认证恢复后重新发起一次原操作"},
|
||||
}
|
||||
}
|
||||
if typed.ExecutionStarted != nil && !*typed.ExecutionStarted {
|
||||
transition.state = docFailureFailed
|
||||
transition.executionStarted = false
|
||||
transition.retryable = typed.RetryableSet && typed.Retryable
|
||||
if transition.retryable {
|
||||
transition.state = docFailureRetryable
|
||||
}
|
||||
if reason != "" {
|
||||
transition.reason = reason
|
||||
} else {
|
||||
transition.reason = "doc_write_not_started"
|
||||
}
|
||||
transition.message = "文档写入已确认尚未开始"
|
||||
transition.actions = []string{"仅在 retryable=true 时按服务端 retry-after 有界重试一次"}
|
||||
}
|
||||
return transition
|
||||
}
|
||||
|
||||
func docUnknownWriteError(operation, stage, nodeID string, cause error) error {
|
||||
var existing *apperrors.Error
|
||||
if errors.As(cause, &existing) {
|
||||
if existing.Category == apperrors.CategoryValidation {
|
||||
return cause
|
||||
}
|
||||
if status, ok := existing.Details["status"].(string); ok && (status == "unknown" || status == "partial_success") {
|
||||
return cause
|
||||
}
|
||||
}
|
||||
transition := classifyDocWriteFailure(cause)
|
||||
details := map[string]any{
|
||||
"contractVersion": "doc.operation.v1",
|
||||
"status": string(transition.state),
|
||||
"state": string(transition.state),
|
||||
"nodeId": nodeID,
|
||||
"stage": stage,
|
||||
}
|
||||
options := []apperrors.Option{
|
||||
apperrors.WithOperation(operation),
|
||||
apperrors.WithReason(transition.reason),
|
||||
apperrors.WithFailureStage(stage),
|
||||
apperrors.WithExecutionStarted(transition.executionStarted),
|
||||
apperrors.WithRetryable(transition.retryable),
|
||||
apperrors.WithActions(transition.actions...),
|
||||
apperrors.WithDetails(details),
|
||||
apperrors.WithCause(cause),
|
||||
}
|
||||
if transition.reason == "permission_denied" || transition.reason == "authentication_required" {
|
||||
return apperrors.NewAuth(transition.message, options...)
|
||||
}
|
||||
return apperrors.NewAPI(transition.message, options...)
|
||||
}
|
||||
|
||||
func docVerificationError(operation, stage, nodeID string, cause error, steps []map[string]any) error {
|
||||
return docPartialWriteError(
|
||||
operation,
|
||||
"doc_write_verification_failed",
|
||||
stage,
|
||||
"文档写入已经执行,但回读验证失败;请先检查当前内容,不要直接重试写入",
|
||||
cause,
|
||||
map[string]any{"nodeId": nodeID, "verified": false},
|
||||
steps,
|
||||
map[string]any{"available": false, "reason": "inspect the current document before choosing recovery"},
|
||||
)
|
||||
}
|
||||
|
||||
func docPartialWriteError(operation, reason, stage, message string, cause error, data map[string]any, steps []map[string]any, compensation map[string]any) error {
|
||||
return apperrors.NewAPI(
|
||||
message,
|
||||
@@ -125,10 +298,11 @@ func docPartialWriteError(operation, reason, stage, message string, cause error,
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithActions("inspect the completed steps before retrying", "use the compensation details to clean up or restore the document"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"status": "partial_success",
|
||||
"data": data,
|
||||
"steps": steps,
|
||||
"compensation": compensation,
|
||||
"contractVersion": "doc.operation.v1",
|
||||
"status": "partial_success",
|
||||
"data": data,
|
||||
"steps": steps,
|
||||
"compensation": compensation,
|
||||
}),
|
||||
apperrors.WithCause(cause),
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,339 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageDocFinalCommonAndCanonicalBranches(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
if err := os.WriteFile("staged.md", []byte("body"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateWorkspaceInputPath("file", "staged.md"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
stub func(*testing.T)
|
||||
}{
|
||||
{"getwd", func(t *testing.T) {
|
||||
testseam.Swap(t, &docGetwd, func() (string, error) { return "", errors.New("getwd") })
|
||||
}},
|
||||
{"base symlink", func(t *testing.T) {
|
||||
testseam.Swap(t, &docEvalSymlinks, func(string) (string, error) { return "", errors.New("base") })
|
||||
}},
|
||||
{"file symlink", func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &docEvalSymlinks, func(path string) (string, error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return "", errors.New("file")
|
||||
}
|
||||
return path, nil
|
||||
})
|
||||
}},
|
||||
{"relative escape", func(t *testing.T) {
|
||||
testseam.Swap(t, &docRel, func(string, string) (string, error) { return "../escape", nil })
|
||||
}},
|
||||
{"relative error", func(t *testing.T) {
|
||||
testseam.Swap(t, &docRel, func(string, string) (string, error) { return "", errors.New("rel") })
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tc.stub(t)
|
||||
if err := validateWorkspaceInputPath("file", "staged.md"); err == nil {
|
||||
t.Fatal("expected validation failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := validateJSONML(`["",{}]`); err == nil {
|
||||
t.Fatal("empty JSONML tag succeeded")
|
||||
}
|
||||
|
||||
validation := classifyDocWriteFailure(apperrors.NewValidation("bad"))
|
||||
if validation.reason != "invalid_input" || validation.executionStarted {
|
||||
t.Fatalf("validation transition = %#v", validation)
|
||||
}
|
||||
permission := classifyDocWriteFailure(apperrors.NewAPI("forbidden"))
|
||||
if permission.reason != "permission_denied" {
|
||||
t.Fatalf("permission transition = %#v", permission)
|
||||
}
|
||||
auth := classifyDocWriteFailure(apperrors.NewAuth("login"))
|
||||
if auth.reason != "authentication_required" {
|
||||
t.Fatalf("auth transition = %#v", auth)
|
||||
}
|
||||
notStarted := classifyDocWriteFailure(apperrors.NewAPI("later", apperrors.WithExecutionStarted(false), apperrors.WithRetryable(true)))
|
||||
if notStarted.state != docFailureRetryable || notStarted.reason != "doc_write_not_started" {
|
||||
t.Fatalf("retryable transition = %#v", notStarted)
|
||||
}
|
||||
for _, cause := range []error{apperrors.NewAPI("permission denied"), apperrors.NewAuth("login")} {
|
||||
var typed *apperrors.Error
|
||||
if err := docUnknownWriteError("doc.test", "write", "n", cause); !errors.As(err, &typed) || typed.Category != apperrors.CategoryAuth {
|
||||
t.Fatalf("auth-classified error = %#v", err)
|
||||
}
|
||||
}
|
||||
for _, existing := range []error{
|
||||
apperrors.NewValidation("existing"),
|
||||
apperrors.NewAPI("existing", apperrors.WithDetails(map[string]any{"status": "partial_success"})),
|
||||
} {
|
||||
if got := docUnknownWriteError("doc.test", "write", "n", existing); got != existing {
|
||||
t.Fatalf("existing classified error was wrapped: %p != %p", got, existing)
|
||||
}
|
||||
}
|
||||
|
||||
if !containsText(nil, "") || !containsText("alpha", "ph") || containsText("alpha", "z") ||
|
||||
!containsText(map[string]any{"nested": "alpha"}, "alpha") || containsText(map[string]any{"nested": "beta"}, "alpha") ||
|
||||
!containsText([]any{"alpha"}, "alpha") || containsText([]any{"beta"}, "alpha") || containsText(3, "alpha") {
|
||||
t.Fatal("containsText branch contract failed")
|
||||
}
|
||||
|
||||
jsonElement := []any{"p", map[string]any{"uuid": "b"}, "text"}
|
||||
jsonPayload := map[string]any{"jsonml": `["p",{"uuid":"b"},"text"]`}
|
||||
for _, value := range []any{jsonPayload, `["p",{"uuid":"b"},"text"]`, jsonElement} {
|
||||
if canonicalBlockContent(value, "jsonml") == "" {
|
||||
t.Fatalf("empty canonical JSONML for %#v", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []any{
|
||||
map[string]any{"text": "alpha"},
|
||||
map[string]any{"id": "ignored", "nested": map[string]any{"text": "beta"}},
|
||||
[]any{"p", map[string]any{"text": "gamma"}},
|
||||
[]any{map[string]any{"text": "delta"}},
|
||||
"epsilon",
|
||||
} {
|
||||
if canonicalBlockContent(value, "markdown") == "" {
|
||||
t.Fatalf("empty canonical markdown for %#v", value)
|
||||
}
|
||||
}
|
||||
|
||||
jsonTree := map[string]any{
|
||||
"jsonml": `["root",{},["p",{"uuid":"ref"},"before"],["p",{"uuid":"new"},"after"]]`,
|
||||
"content": "not-json",
|
||||
"nested": []any{"p", map[string]any{"uuid": "direct"}, "direct"},
|
||||
}
|
||||
if len(orderedCanonicalBlocks(jsonTree, "jsonml")) < 3 || findJSONMLBlock(jsonTree, "new") == nil {
|
||||
t.Fatalf("JSONML traversal failed: %#v", orderedJSONMLBlocks(jsonTree))
|
||||
}
|
||||
if findJSONMLBlock(jsonTree, "missing") != nil || jsonMLBlockIdentity([]any{"p"}) != "" || jsonMLBlockIdentity([]any{"p", "attrs"}) != "" {
|
||||
t.Fatal("JSONML identity negative contract failed")
|
||||
}
|
||||
if canonicalBlockIdentity(jsonElement, "jsonml") != "b" || canonicalBlockIdentity("bad", "jsonml") != "" {
|
||||
t.Fatal("JSONML canonical identity failed")
|
||||
}
|
||||
|
||||
elementTree := map[string]any{"wrapper": []any{map[string]any{"id": "ref", "text": "before"}, map[string]any{"id": "new", "text": "after"}}}
|
||||
if len(orderedCanonicalBlocks(elementTree, "element")) != 2 || canonicalBlockIdentity(elementTree, "element") != "" || canonicalBlockIdentity("bad", "element") != "" {
|
||||
t.Fatal("element canonical traversal failed")
|
||||
}
|
||||
if !verifyInsertedCanonicalBlock(map[string]any{"blockId": "new"}, elementTree, "ref", "after", "element") ||
|
||||
!verifyInsertedCanonicalBlock(map[string]any{}, elementTree, "ref", "after", "element") ||
|
||||
verifyInsertedCanonicalBlock(map[string]any{}, elementTree, "new", "after", "element") {
|
||||
t.Fatal("inserted block verification branch contract failed")
|
||||
}
|
||||
if blockContentEquals(map[string]any{}, "missing", "after", "element") {
|
||||
t.Fatal("missing block matched")
|
||||
}
|
||||
if blockContentEquals(nil, "missing", "after", "element") {
|
||||
t.Fatal("nil block tree matched")
|
||||
}
|
||||
if blockContentEquals(map[string]any{}, "missing", "after", "jsonml") {
|
||||
t.Fatal("missing JSONML block matched")
|
||||
}
|
||||
if got := documentContentCandidates("root", "markdown"); len(got) != 1 || got[0] != "root" {
|
||||
t.Fatalf("root content candidates = %#v", got)
|
||||
}
|
||||
if got := documentContentCandidates([]any{map[string]any{"content": "nested"}}, "markdown"); len(got) != 1 || got[0] != "nested" {
|
||||
t.Fatalf("nested content candidates = %#v", got)
|
||||
}
|
||||
if got := splitDocMarkdown("a\nb", 0); len(got) != 1 {
|
||||
t.Fatalf("disabled split = %#v", got)
|
||||
}
|
||||
if got := splitDocMarkdown("ab\ncd", 4); len(got) != 2 || got[0] != "ab\n" {
|
||||
t.Fatalf("newline split = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocFinalExecutionFailureBranches(t *testing.T) {
|
||||
longContent := strings.Repeat("x", 10001)
|
||||
if err := runDocCoverage(t, Create, &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}, "--name", "n", "--content", longContent); err == nil {
|
||||
t.Fatal("partial chunk create succeeded")
|
||||
}
|
||||
for _, failAt := range []int{1, 2} {
|
||||
err := runDocCoverage(t, Update, &docCoverageCaller{failAt: failAt, responses: map[string][]map[string]any{}},
|
||||
"--node", "n", "--command", "block_insert_after", "--after-block-id", "block-1", "--content", "x", "--yes")
|
||||
if err == nil {
|
||||
t.Fatalf("block mutation failAt=%d succeeded", failAt)
|
||||
}
|
||||
}
|
||||
mismatch := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"insert_document_block": {{"blockId": "new"}},
|
||||
"list_document_blocks": {{"blocks": []any{map[string]any{"id": "new", "text": "wrong"}}}},
|
||||
}}
|
||||
if err := runDocCoverage(t, Update, mismatch, "--node", "n", "--command", "block_insert_after", "--after-block-id", "block-1", "--content", "x", "--yes"); err == nil {
|
||||
t.Fatal("mismatched block verification succeeded")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
responses map[string][]map[string]any
|
||||
failAt int
|
||||
args []string
|
||||
}{
|
||||
{"query error", map[string][]map[string]any{}, 1, []string{"--job-id", "j"}},
|
||||
{"unknown", map[string][]map[string]any{"query_export_job": {{}}}, 0, []string{"--job-id", "j"}},
|
||||
{"failed", map[string][]map[string]any{"query_export_job": {{"status": "CANCELLED"}}}, 0, []string{"--job-id", "j"}},
|
||||
{"missing url", map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS"}}}, 0, []string{"--job-id", "j", "--output", "out"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_ = runDocCoverage(t, ExportGet, &docCoverageCaller{failAt: tc.failAt, responses: tc.responses}, tc.args...)
|
||||
})
|
||||
}
|
||||
t.Run("export getwd", func(t *testing.T) {
|
||||
testseam.Swap(t, &docGetwd, func() (string, error) { return "", errors.New("getwd") })
|
||||
_ = runDocCoverage(t, ExportGet, &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS", "downloadUrl": "https://example.com/x"}}}}, "--job-id", "j", "--output", "out")
|
||||
})
|
||||
t.Run("export download", func(t *testing.T) {
|
||||
testseam.Swap(t, &docDownload, func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) {
|
||||
return localio.DownloadResult{}, errors.New("download")
|
||||
})
|
||||
_ = runDocCoverage(t, ExportGet, &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS", "downloadUrl": "https://example.com/x"}}}}, "--job-id", "j", "--output", "out")
|
||||
})
|
||||
|
||||
searchCaller := &docCoverageCaller{responses: map[string][]map[string]any{"search_documents": {{"documents": []any{}, "hasMore": false}}}}
|
||||
if err := runDocCoverage(t, Search, searchCaller, "--query", "q", "--creator-uids", "u", "--editor-uids", "e", "--mentioned-uids", "m", "--workspace-ids", "w"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := searchCaller.history[0].params["pageSize"]; got != 10 {
|
||||
t.Fatalf("default search page size = %#v", got)
|
||||
}
|
||||
listCaller := &docCoverageCaller{responses: map[string][]map[string]any{"list_nodes": {{"nodes": []any{}, "hasMore": false}}}}
|
||||
if err := runDocCoverage(t, List, listCaller, "--folder", "f", "--workspace", "w"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := listCaller.history[0].params["pageSize"]; got != 50 {
|
||||
t.Fatalf("default list page size = %#v", got)
|
||||
}
|
||||
|
||||
_ = runDocCoverage(t, CommentReply, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--comment-key", "c", "--content", "x", "--emoji", "--mention", "[bad", "--yes")
|
||||
if err := runDocCoverage(t, CommentReply, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--comment-key", "c", "--content", "x", "--mention", "u", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = runDocCoverage(t, CommentUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--comment-key", "c", "--content", "x", "--mention", "[bad")
|
||||
_ = runDocCoverage(t, ExportSubmit, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--export-format", "docx")
|
||||
if err := runDocCoverage(t, ExportSubmit, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--export-format", "docx"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
responses map[string][]map[string]any
|
||||
failAt int
|
||||
args []string
|
||||
}{
|
||||
{"template search error", map[string][]map[string]any{}, 1, []string{"--query", "q"}},
|
||||
{"template not found", map[string][]map[string]any{"search_doc_templates": {{"templates": []any{}, "hasMore": false}}}, 0, []string{"--query", "q"}},
|
||||
{"template resolved", map[string][]map[string]any{"search_doc_templates": {{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": false}}}, 0, []string{"--query", "q", "--source", "PUBLIC"}},
|
||||
{"template continue", map[string][]map[string]any{"search_doc_templates": {{"templates": []any{map[string]any{"templateId": "t"}}, "nextCursor": "p2"}}}, 0, []string{"--query", "q", "--cursor", "p1"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_ = runDocCoverage(t, TemplateSearch, &docCoverageCaller{responses: tc.responses, failAt: tc.failAt}, tc.args...)
|
||||
})
|
||||
}
|
||||
|
||||
for _, raw := range [][]string{{"[bad"}, {"", " "}} {
|
||||
if _, err := normalizeMentionUserIDs(raw); err == nil {
|
||||
t.Fatalf("invalid mentions %#v succeeded", raw)
|
||||
}
|
||||
}
|
||||
if _, ok := sliceUTF16Range("A😀B", 1, 2); ok {
|
||||
t.Fatal("selection ending inside surrogate pair succeeded")
|
||||
}
|
||||
if _, ok := sliceUTF16Range("abc", -1, 1); ok {
|
||||
t.Fatal("negative selection range succeeded")
|
||||
}
|
||||
|
||||
commentCases := []struct {
|
||||
name string
|
||||
caller *docCoverageCaller
|
||||
args []string
|
||||
wantError bool
|
||||
}{
|
||||
{"block read", &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, []string{"--node", "n", "--content", "c", "--block-id", "b", "--start", "0", "--end", "1", "--yes"}, true},
|
||||
{"range", &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"id": "b", "text": ""}}}}, []string{"--node", "n", "--content", "c", "--block-id", "b", "--start", "0", "--end", "1", "--yes"}, true},
|
||||
{"selected mismatch", &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"id": "b", "text": "alpha"}}}}, []string{"--node", "n", "--content", "c", "--block-id", "b", "--start", "0", "--end", "1", "--selected-text", "z", "--yes"}, true},
|
||||
{"write error", &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, []string{"--node", "n", "--content", "c", "--yes"}, true},
|
||||
{"mention", &docCoverageCaller{responses: map[string][]map[string]any{}}, []string{"--node", "n", "--content", "c", "--mention", "[bad", "--yes"}, true},
|
||||
}
|
||||
for _, tc := range commentCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := runDocCoverage(t, CommentCreate, tc.caller, tc.args...)
|
||||
if (err != nil) != tc.wantError {
|
||||
t.Fatalf("err=%v wantError=%v", err, tc.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if !versionEvidenceMatches([]any{map[string]any{"target_version": 3}}, 3, map[string]bool{"targetversion": true}) {
|
||||
t.Fatal("array version evidence did not match")
|
||||
}
|
||||
if !versionEvidenceMatches(map[string]any{"wrapper": map[string]any{"target_version": 3}}, 3, map[string]bool{"targetversion": true}) {
|
||||
t.Fatal("nested version evidence did not match")
|
||||
}
|
||||
withDescription := collectTemplateCandidates(map[string]any{"templateId": "t", "description": "d"})
|
||||
if len(withDescription) != 1 || withDescription[0]["description"] != "d" {
|
||||
t.Fatalf("template description = %#v", withDescription)
|
||||
}
|
||||
|
||||
duplicateTemplates := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {
|
||||
{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": true, "nextCursor": "p2"},
|
||||
{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": false},
|
||||
}}}
|
||||
_ = runDocCoverage(t, CreateFromTemplate, duplicateTemplates, "--query", "q")
|
||||
stalledTemplates := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {
|
||||
{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": true, "nextCursor": "p2"},
|
||||
{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": true, "nextCursor": "p2"},
|
||||
}}}
|
||||
if err := runDocCoverage(t, CreateFromTemplate, stalledTemplates, "--query", "q"); err == nil {
|
||||
t.Fatal("stalled template cursor succeeded")
|
||||
}
|
||||
maxPages := make([]map[string]any, 20)
|
||||
for index := range maxPages {
|
||||
maxPages[index] = map[string]any{"templates": []any{map[string]any{"templateId": "t"}}, "hasMore": true, "nextCursor": "p" + strings.Repeat("x", index+1)}
|
||||
}
|
||||
if err := runDocCoverage(t, CreateFromTemplate, &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": maxPages}}, "--query", "q"); err == nil {
|
||||
t.Fatal("template max-pages succeeded")
|
||||
}
|
||||
|
||||
t.Chdir(t.TempDir())
|
||||
if err := os.WriteFile("media.bin", []byte("media"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = runDocCoverage(t, ResourceUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--file", "media.bin", "--dry-run", "--yes")
|
||||
if err := runDocCoverage(t, Import, &docCoverageCaller{dryRun: true, responses: map[string][]map[string]any{}}, "--file", "media.bin", "--folder", "f", "--dry-run"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("empty preview", func(t *testing.T) {
|
||||
testseam.Swap(t, &docDownload, func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) {
|
||||
return localio.DownloadResult{}, nil
|
||||
})
|
||||
_ = runDocCoverage(t, MediaPreview, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--resource-id", "r")
|
||||
})
|
||||
t.Run("empty download", func(t *testing.T) {
|
||||
testseam.Swap(t, &docDownload, func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) {
|
||||
return localio.DownloadResult{}, nil
|
||||
})
|
||||
_ = runDocCoverage(t, MediaDownload, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--resource-id", "r", "--output", "out")
|
||||
})
|
||||
}
|
||||
+142
-108
@@ -17,8 +17,11 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"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/localio"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
@@ -77,6 +80,9 @@ var Search = shortcut.Shortcut{
|
||||
{Name: "workspace-ids", Type: shortcut.FlagStringSlice, Desc: "按知识库 ID 过滤"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 10,最大 30)"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上次结果的 nextPageToken)"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "有界读取全部后续页"},
|
||||
{Name: "max-pages", Type: shortcut.FlagInt, Default: "20", Desc: "--page-all 最大页数"},
|
||||
{Name: "max-items", Type: shortcut.FlagInt, Default: "500", Desc: "最多返回文档数"},
|
||||
},
|
||||
Tips: []string{`dws doc +search --query "会议纪要"`, `dws doc +search --extensions pdf,docx`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
@@ -111,18 +117,17 @@ var Search = shortcut.Shortcut{
|
||||
if rt.Changed("workspace-ids") {
|
||||
params["workspaceIds"] = rt.StrSlice("workspace-ids")
|
||||
}
|
||||
if rt.Changed("limit") {
|
||||
params["pageSize"] = rt.Int("limit")
|
||||
pageSize := rt.Int("limit")
|
||||
if pageSize == 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
if rt.Changed("cursor") {
|
||||
params["pageToken"] = rt.Str("cursor")
|
||||
}
|
||||
data, err := rt.CallMCPData(productDoc, "search_documents", params)
|
||||
result, err := collectDocPages(rt, "search_documents", "documents", params, searchDocsProject, docPageOptions{
|
||||
PageAll: rt.Bool("page-all"), PageSize: pageSize, MaxPages: rt.Int("max-pages"), MaxItems: rt.Int("max-items"), Cursor: rt.Str("cursor"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
docs := searchDocsProject(data)
|
||||
return rt.Output(map[string]any{"count": len(docs), "documents": docs})
|
||||
return rt.Output(result)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -241,6 +246,9 @@ var List = shortcut.Shortcut{
|
||||
{Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上次结果的 nextPageToken)"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "有界读取全部后续页"},
|
||||
{Name: "max-pages", Type: shortcut.FlagInt, Default: "20", Desc: "--page-all 最大页数"},
|
||||
{Name: "max-items", Type: shortcut.FlagInt, Default: "500", Desc: "最多返回节点数"},
|
||||
},
|
||||
Tips: []string{`dws doc +list --folder DOC_FOLDER_NODE_ID`, `dws doc +list --workspace WS_ID --limit 20`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
@@ -251,18 +259,17 @@ var List = shortcut.Shortcut{
|
||||
if rt.Changed("workspace") {
|
||||
params["workspaceId"] = rt.Str("workspace")
|
||||
}
|
||||
if rt.Changed("limit") {
|
||||
params["pageSize"] = rt.Int("limit")
|
||||
pageSize := rt.Int("limit")
|
||||
if pageSize == 0 {
|
||||
pageSize = 50
|
||||
}
|
||||
if rt.Changed("cursor") {
|
||||
params["pageToken"] = rt.Str("cursor")
|
||||
}
|
||||
data, err := rt.CallMCPData(productDoc, "list_nodes", params)
|
||||
result, err := collectDocPages(rt, "list_nodes", "nodes", params, listNodesProject, docPageOptions{
|
||||
PageAll: rt.Bool("page-all"), PageSize: pageSize, MaxPages: rt.Int("max-pages"), MaxItems: rt.Int("max-items"), Cursor: rt.Str("cursor"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nodes := listNodesProject(data)
|
||||
return rt.Output(map[string]any{"count": len(nodes), "nodes": nodes})
|
||||
return rt.Output(result)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -414,7 +421,7 @@ var CommentList = shortcut.Shortcut{
|
||||
Command: "+comment-list",
|
||||
Product: productComment,
|
||||
Description: "查询文档评论列表",
|
||||
Intent: "当你想查看某篇文档上已有的评论、了解有哪些反馈或待处理意见(可按全文/划词、已解决/未解决过滤)时使用;输入 node,返回评论列表及其 commentKey 以便后续回复。",
|
||||
Intent: "当你想查看某篇文档上已有的评论、了解有哪些反馈或待处理意见(可按全文/划词、已解决/未解决过滤)时使用;输入 node,可用 --limit/--cursor 分页,返回评论列表及其 commentKey 以便后续回复。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
@@ -436,26 +443,26 @@ var CommentList = shortcut.Shortcut{
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询文档评论列表",
|
||||
UseWhen: []string{"当你想查看某篇文档上已有的评论、了解有哪些反馈或待处理意见(可按全文/划词、已解决/未解决过滤)时使用;输入 node,返回评论列表及其 commentKey 以便后续回复。"},
|
||||
UseWhen: []string{"当你想查看某篇文档上已有的评论、了解有哪些反馈或待处理意见(可按全文/划词、已解决/未解决过滤)时使用;输入 node,可用 --limit/--cursor 分页,返回评论列表及其 commentKey 以便后续回复。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{
|
||||
"dws doc +comment-list --node DOC_ID",
|
||||
"dws doc +comment-list --node DOC_ID --type inline --resolve-status unresolved",
|
||||
"dws doc +comment-list --node DOC_ID --limit 20",
|
||||
"dws doc +comment-list --node DOC_ID --limit 20 --cursor NEXT_TOKEN",
|
||||
},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)", Aliases: []string{"page-size"}},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上一页返回的 nextToken)"},
|
||||
{Name: "type", Type: shortcut.FlagString, Desc: "评论类型: global (全文) / inline (划词)", Enum: []string{"global", "inline"}},
|
||||
{Name: "resolve-status", Type: shortcut.FlagString, Desc: "解决状态: resolved / unresolved", Enum: []string{"resolved", "unresolved"}},
|
||||
},
|
||||
Tips: []string{`dws doc +comment-list --node DOC_ID`, `dws doc +comment-list --node DOC_ID --type inline --resolve-status unresolved`},
|
||||
Tips: []string{`dws doc +comment-list --node DOC_ID --limit 20`, `dws doc +comment-list --node DOC_ID --limit 20 --cursor NEXT_TOKEN`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"nodeId": rt.Str("node")}
|
||||
if rt.Changed("limit") {
|
||||
params["pageSize"] = rt.Int("limit")
|
||||
if rt.Changed("limit") || rt.Changed("page-size") {
|
||||
params["pageSize"] = rt.IntFirst("limit", "page-size")
|
||||
}
|
||||
if v := rt.Str("cursor"); v != "" {
|
||||
params["nextToken"] = v
|
||||
@@ -470,54 +477,44 @@ var CommentList = shortcut.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
const commentCreateTargetConstraint = "selection 与 block-id/start/end 高级通道互斥;block-id/start/end 必须一起提供;selection 必须唯一匹配"
|
||||
|
||||
var CommentCreate = shortcut.Shortcut{
|
||||
Service: "doc",
|
||||
Command: "+comment-create",
|
||||
Product: productComment,
|
||||
Description: "在文档上创建一条评论",
|
||||
Intent: "当你想对整篇文档留一条全文评论、给出反馈或 @ 相关同事时使用;输入 node 与评论 content(可带 mention),会实际在文档上发布一条新评论。",
|
||||
Description: "创建全文评论,或按 selection 创建划词评论",
|
||||
Intent: "当用户要对整篇文档留言,或针对文档中唯一匹配的一段文字创建精确划词评论时使用;已知 block/start/end 时也可直接走高级通道。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "doc",
|
||||
Name: "shortcut_comment_create",
|
||||
CanonicalPath: "doc.shortcut_comment_create",
|
||||
CLIPath: "doc +comment-create",
|
||||
PrimaryCLIPath: "doc +comment-create",
|
||||
},
|
||||
Description: "在文档上创建一条评论",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "在文档上创建一条评论",
|
||||
UseWhen: []string{"当你想对整篇文档留一条全文评论、给出反馈或 @ 相关同事时使用;输入 node 与评论 content(可带 mention),会实际在文档上发布一条新评论。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws doc +comment-create --node DOC_ID --content \"这里需要修改\""},
|
||||
},
|
||||
},
|
||||
Contract: docContract("+comment-create", "创建全文评论,或按 selection 创建划词评论",
|
||||
"当用户要对整篇文档留言,或针对文档中唯一匹配的一段文字创建精确划词评论时使用;已知 block/start/end 时也可直接走高级通道。",
|
||||
[]string{`dws doc +comment-create --node <DOC_ID> --content "请补充数据来源"`, `dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"`},
|
||||
contract.ParamDecl{Name: "node", Property: "node"},
|
||||
contract.ParamDecl{Name: "content", Property: "content"},
|
||||
contract.ParamDecl{Name: "selection", Property: "selection"},
|
||||
contract.ParamDecl{Name: "block-id", Property: "blockId"},
|
||||
contract.ParamDecl{Name: "start", Property: "start"},
|
||||
contract.ParamDecl{Name: "end", Property: "end"},
|
||||
contract.ParamDecl{Name: "selected-text", Property: "selectedText"},
|
||||
contract.ParamDecl{Name: "mention", Property: "mention"}),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
|
||||
{Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容 (纯文本)", Required: true},
|
||||
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
|
||||
},
|
||||
Tips: []string{`dws doc +comment-create --node DOC_ID --content "这里需要修改"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"nodeId": rt.Str("node"),
|
||||
"content": rt.Str("content"),
|
||||
}
|
||||
if rt.Changed("mention") {
|
||||
params["mentionedUserIds"] = rt.StrSlice("mention")
|
||||
}
|
||||
return rt.CallMCP("create_comment", params)
|
||||
{Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容", Required: true},
|
||||
{Name: "selection", Type: shortcut.FlagString, Desc: "完整文字或 前缀...后缀;" + commentCreateTargetConstraint},
|
||||
{Name: "block-id", Type: shortcut.FlagString, Desc: "高级通道 block ID;" + commentCreateTargetConstraint},
|
||||
{Name: "start", Type: shortcut.FlagInt, Desc: "块内 UTF-16 起始偏移;" + commentCreateTargetConstraint},
|
||||
{Name: "end", Type: shortcut.FlagInt, Desc: "块内 UTF-16 结束偏移;" + commentCreateTargetConstraint},
|
||||
{Name: "selected-text", Type: shortcut.FlagString, Desc: "可选引用原文;CLI 会从 block 回读并交叉校验"},
|
||||
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid,多个值用逗号分隔;不要传 JSON 数组"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"selection", "block-id", "start", "end"}, Description: commentCreateTargetConstraint}},
|
||||
Tips: []string{`dws doc +comment-create --node <DOC_ID> --content "请补充数据来源"`, `dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"`},
|
||||
Validate: validateCommentCreate,
|
||||
Execute: executeCommentCreate,
|
||||
}
|
||||
|
||||
var CommentReply = shortcut.Shortcut{
|
||||
@@ -557,7 +554,7 @@ var CommentReply = shortcut.Shortcut{
|
||||
{Name: "content", Type: shortcut.FlagString, Desc: "回复文字内容 (表情回复时填表情名称)", Required: true},
|
||||
{Name: "comment-key", Type: shortcut.FlagString, Desc: "被回复评论的 commentKey (从 list/create 获取)", Required: true},
|
||||
{Name: "emoji", Type: shortcut.FlagBool, Desc: "作为表情贴图回复"},
|
||||
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
|
||||
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid,多个值用逗号分隔;不要传 JSON 数组"},
|
||||
},
|
||||
Tips: []string{`dws doc +comment-reply --node DOC_ID --comment-key COMMENT_KEY --content "同意"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
@@ -570,7 +567,11 @@ var CommentReply = shortcut.Shortcut{
|
||||
params["emoji"] = true
|
||||
}
|
||||
if rt.Changed("mention") {
|
||||
params["mentionedUserIds"] = rt.StrSlice("mention")
|
||||
mentions, err := normalizeMentionUserIDs(rt.StrSlice("mention"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["mentionedUserIds"] = mentions
|
||||
}
|
||||
return rt.CallMCP("reply_comment", params)
|
||||
},
|
||||
@@ -580,9 +581,10 @@ var CommentCreateInline = shortcut.Shortcut{
|
||||
Service: "doc",
|
||||
Command: "+comment-create-inline",
|
||||
Product: productComment,
|
||||
Description: "在文档选中文本区域上创建划词评论",
|
||||
Intent: "当你想针对文档里某段具体文字(而非整篇)留评论、做精确批注时使用;需先用 +block-list 定位块,再输入 node、block-id 及该块内的 start/end 字符偏移量,会实际在选中文本上创建划词评论。",
|
||||
Description: "兼容入口:按 block/start/end 创建划词评论",
|
||||
Intent: "仅兼容既有调用;新任务统一使用 +comment-create 的 selection 或 block/start/end 通道。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
|
||||
{Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容 (纯文本)", Required: true},
|
||||
@@ -590,25 +592,12 @@ var CommentCreateInline = shortcut.Shortcut{
|
||||
{Name: "start", Type: shortcut.FlagInt, Desc: "块内文本起始字符偏移量 (从 0 开始)", Required: true},
|
||||
{Name: "end", Type: shortcut.FlagInt, Desc: "块内文本结束字符偏移量 (须大于 start)", Required: true},
|
||||
{Name: "selected-text", Type: shortcut.FlagString, Desc: "选中文本内容 (展示引用原文)"},
|
||||
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
|
||||
},
|
||||
Tips: []string{`dws doc +comment-create-inline --node DOC_ID --block-id BLOCK_ID --start 0 --end 10 --content "这里需要修改"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"nodeId": rt.Str("node"),
|
||||
"content": rt.Str("content"),
|
||||
"blockId": rt.Str("block-id"),
|
||||
"start": rt.Int("start"),
|
||||
"end": rt.Int("end"),
|
||||
}
|
||||
if v := rt.Str("selected-text"); v != "" {
|
||||
params["selectedText"] = v
|
||||
}
|
||||
if rt.Changed("mention") {
|
||||
params["mentionedUserIds"] = rt.StrSlice("mention")
|
||||
}
|
||||
return rt.CallMCP("create_inline_comment", params)
|
||||
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid,多个值用逗号分隔;不要传 JSON 数组"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"block-id", "start", "end"}, Description: "block-id/start/end 必须一起提供,CLI 回读并校验 selectedText"}},
|
||||
Tips: []string{`dws doc +comment-create --node DOC_ID --block-id BLOCK_ID --start 0 --end 10 --content "这里需要修改"`},
|
||||
Validate: validateCommentCreate,
|
||||
Execute: executeCommentCreate,
|
||||
}
|
||||
|
||||
// ── 协作权限 ─────────────────────────────────────────────────
|
||||
@@ -620,7 +609,7 @@ var ExportSubmit = shortcut.Shortcut{
|
||||
Command: "+export-submit",
|
||||
Product: productDoc,
|
||||
Description: "提交在线文档导出任务 (docx/markdown/pdf),返回 jobId",
|
||||
Intent: "当你想把在线文档导出成 docx/markdown/pdf 文件(例如离线保存或外发)时使用;这是异步任务的第一步,输入 node 与 export-format 提交导出,返回 jobId,随后用 +export-get 轮询结果。",
|
||||
Intent: "仅当用户明确要求手工接管异步导出 job,且不需要当前命令下载文件时使用;返回 jobId 后只能用 +export-get 恢复。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
@@ -642,25 +631,25 @@ var ExportSubmit = shortcut.Shortcut{
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "提交在线文档导出任务 (docx/markdown/pdf),返回 jobId",
|
||||
UseWhen: []string{"当你想把在线文档导出成 docx/markdown/pdf 文件(例如离线保存或外发)时使用;这是异步任务的第一步,输入 node 与 export-format 提交导出,返回 jobId,随后用 +export-get 轮询结果。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
UseWhen: []string{"仅当用户明确要求手工接管异步导出 job,且不需要当前命令下载文件时使用;返回 jobId 后只能用 +export-get 恢复。"},
|
||||
AvoidWhen: []string{"正常导出和保存本地文件使用 doc +export;不要手工编排 submit/get,也不要用它绕过 +export 的安全下载"},
|
||||
Examples: []string{"dws doc +export-submit --node DOC_ID --export-format markdown"},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "要导出的文档 ID 或 URL", Required: true},
|
||||
{Name: "export-format", Type: shortcut.FlagString, Default: "docx", Desc: "导出格式", Enum: []string{"docx", "markdown", "pdf"}},
|
||||
{Name: "export-format", Type: shortcut.FlagString, Default: "docx", Desc: "导出格式;省略时默认为 docx", Enum: []string{"docx", "markdown", "pdf"}},
|
||||
},
|
||||
Tips: []string{`dws doc +export-submit --node DOC_ID --export-format markdown`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
format := rt.Str("export-format")
|
||||
if format == "" {
|
||||
format = "docx"
|
||||
}
|
||||
return rt.CallMCP("submit_export_job", map[string]any{
|
||||
result, err := rt.CallMCPWriteData(productDoc, "submit_export_job", map[string]any{
|
||||
"nodeId": rt.Str("node"),
|
||||
"exportFormat": format,
|
||||
"exportFormat": rt.Str("export-format"),
|
||||
})
|
||||
if err != nil {
|
||||
return docUnknownWriteError("doc.export_submit", "submit_export_job", rt.Str("node"), err)
|
||||
}
|
||||
return rt.Output(docEnvelope("doc.export_submit", map[string]any{"nodeId": rt.Str("node"), "result": result}, map[string]any{"name": "submit_export_job", "status": "success"}))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -669,7 +658,7 @@ var ExportGet = shortcut.Shortcut{
|
||||
Command: "+export-get",
|
||||
Product: productDoc,
|
||||
Description: "根据 jobId 查询文档导出任务结果",
|
||||
Intent: "当你已用 +export-submit 提交了导出任务、想查询它是否完成并拿到导出文件的下载链接时使用;输入上一步返回的 job-id,返回任务状态与结果。",
|
||||
Intent: "当 +export 已返回 jobId 但轮询、中断或下载失败时使用;复用同一 job 查询,给 output 时通过 CLI 安全下载,禁止重新提交或 curl 临时链接。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
@@ -691,18 +680,24 @@ var ExportGet = shortcut.Shortcut{
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "根据 jobId 查询文档导出任务结果",
|
||||
UseWhen: []string{"当你已用 +export-submit 提交了导出任务、想查询它是否完成并拿到导出文件的下载链接时使用;输入上一步返回的 job-id,返回任务状态与结果。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws doc +export-get --job-id JOB_ID"},
|
||||
UseWhen: []string{"当 +export 已返回 jobId 但轮询、中断或下载失败时使用;复用同一 job 查询,给 output 时通过 CLI 安全下载,禁止重新提交或 curl 临时链接。"},
|
||||
AvoidWhen: []string{"尚未提交导出或没有真实 jobId 时不要使用;正常首次导出使用 doc +export"},
|
||||
Examples: []string{"dws doc +export-get --job-id JOB_ID", "dws doc +export-get --job-id JOB_ID --output ./exports/"},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "job-id", Type: shortcut.FlagString, Desc: "导出任务 ID", Required: true},
|
||||
{Name: "output", Shorthand: "o", Type: shortcut.FlagString, Desc: "可选:任务完成后安全下载到工作目录内相对路径"},
|
||||
},
|
||||
Tips: []string{`dws doc +export-get --job-id JOB_ID`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("query_export_job", map[string]any{"jobId": rt.Str("job-id")})
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Str("output") == "" {
|
||||
return nil
|
||||
}
|
||||
return localio.ValidateOutput(rt.Str("output"))
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"output"}, Description: "提供 --output 时必须是工作目录内相对路径;默认 no-clobber"}},
|
||||
Tips: []string{`dws doc +export-get --job-id JOB_ID`, `dws doc +export-get --job-id JOB_ID --output ./exports/`},
|
||||
Execute: executeExportGet,
|
||||
}
|
||||
|
||||
// ── 历史版本 (server: doc) ───────────────────────────────────
|
||||
@@ -938,17 +933,57 @@ var TemplateSearch = shortcut.Shortcut{
|
||||
},
|
||||
Tips: []string{`dws doc +template-search --query "周报"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"searchName": rt.Str("query")}
|
||||
pageSize := rt.Int("limit")
|
||||
if pageSize <= 0 {
|
||||
pageSize = 50
|
||||
}
|
||||
params := map[string]any{"searchName": rt.Str("query"), "maxResults": pageSize}
|
||||
if v := rt.Str("source"); v != "" {
|
||||
params["templateSource"] = v
|
||||
}
|
||||
if rt.Changed("limit") {
|
||||
params["maxResults"] = rt.Int("limit")
|
||||
}
|
||||
if v := rt.Str("cursor"); v != "" {
|
||||
params["nextCursor"] = v
|
||||
}
|
||||
return rt.CallMCP("search_doc_templates", params)
|
||||
found, err := rt.CallMCPData(productDoc, "search_doc_templates", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
candidates := collectTemplateCandidates(found)
|
||||
hasMore, hasMoreKnown, nextCursor := docPageState(found)
|
||||
nextCursor = strings.TrimSpace(nextCursor)
|
||||
complete := hasMoreKnown && !hasMore
|
||||
if !hasMoreKnown && nextCursor == "" && len(candidates) < pageSize {
|
||||
complete = true
|
||||
}
|
||||
globalComplete := complete && rt.Str("cursor") == ""
|
||||
status := "selection_required"
|
||||
if globalComplete && len(candidates) == 0 {
|
||||
status = "not_found"
|
||||
} else if globalComplete && len(candidates) == 1 {
|
||||
status = "resolved"
|
||||
}
|
||||
selectedTemplateID := ""
|
||||
if status == "resolved" {
|
||||
selectedTemplateID = candidates[0]["templateId"].(string)
|
||||
}
|
||||
nextAction := map[string]string{"resolved": "create_once", "not_found": "stop", "selection_required": "ask_user"}[status]
|
||||
if !complete {
|
||||
nextAction = "continue_search"
|
||||
}
|
||||
return rt.Output(docEnvelope("doc.template_search", map[string]any{
|
||||
"query": rt.Str("query"),
|
||||
"source": rt.Str("source"),
|
||||
"count": len(candidates),
|
||||
"candidates": candidates,
|
||||
"complete": complete,
|
||||
"hasMore": !complete,
|
||||
"nextCursor": nextCursor,
|
||||
"selection": map[string]any{
|
||||
"status": status,
|
||||
"templateId": selectedTemplateID,
|
||||
"nextAction": nextAction,
|
||||
},
|
||||
}))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -986,7 +1021,6 @@ func init() {
|
||||
CommentCreateInline.Contract = corecmd.ContractDecl{}
|
||||
TemplateApply.Contract = corecmd.ContractDecl{}
|
||||
canonicalizeHistoryShortcuts()
|
||||
canonicalizeCommentShortcuts()
|
||||
shortcut.Register(
|
||||
Search,
|
||||
List,
|
||||
@@ -998,9 +1032,9 @@ func init() {
|
||||
CommentCreateInline,
|
||||
ExportSubmit,
|
||||
ExportGet,
|
||||
legacyVersionSave,
|
||||
legacyVersionList,
|
||||
legacyVersionRevert,
|
||||
compatHistorySave,
|
||||
compatHistoryList,
|
||||
compatHistoryRevert,
|
||||
VersionSave,
|
||||
VersionList,
|
||||
VersionRevert,
|
||||
|
||||
@@ -7,15 +7,18 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"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/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
|
||||
@@ -26,8 +29,10 @@ import (
|
||||
)
|
||||
|
||||
type docCoverageCaller struct {
|
||||
mu sync.Mutex
|
||||
failAt int
|
||||
calls int
|
||||
dryRun bool
|
||||
responses map[string][]map[string]any
|
||||
ctx context.Context
|
||||
history []docCoverageCall
|
||||
@@ -43,12 +48,17 @@ type docCoverageErrorReader struct{}
|
||||
func (docCoverageErrorReader) Read([]byte) (int, error) { return 0, errors.New("stdin failed") }
|
||||
|
||||
func (f *docCoverageCaller) CallTool(_ context.Context, _, tool string, params map[string]any) (*edition.ToolResult, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls++
|
||||
f.history = append(f.history, docCoverageCall{tool: tool, params: params})
|
||||
if f.failAt == f.calls {
|
||||
return nil, errors.New("injected doc coverage failure")
|
||||
}
|
||||
value := docCoveragePayload(tool)
|
||||
if tool == "revert_doc_version" {
|
||||
value = map[string]any{"version": params["version"]}
|
||||
}
|
||||
if queue := f.responses[tool]; len(queue) > 0 {
|
||||
value = queue[0]
|
||||
f.responses[tool] = queue[1:]
|
||||
@@ -61,7 +71,7 @@ func (f *docCoverageCaller) CallTool(_ context.Context, _, tool string, params m
|
||||
}
|
||||
|
||||
func (f *docCoverageCaller) Format() string { return "json" }
|
||||
func (f *docCoverageCaller) DryRun() bool { return false }
|
||||
func (f *docCoverageCaller) DryRun() bool { return f.dryRun }
|
||||
func (f *docCoverageCaller) Fields() string { return "" }
|
||||
func (f *docCoverageCaller) JQ() string { return "" }
|
||||
|
||||
@@ -70,9 +80,9 @@ func docCoveragePayload(tool string) map[string]any {
|
||||
case "create_document":
|
||||
return map[string]any{"data": map[string]any{"nodeId": "node-1"}}
|
||||
case "get_document_content":
|
||||
return map[string]any{"data": map[string]any{"revision": 1, "jsonml": `["root",{},["p",{"uuid":"block-1"},"alpha beta"]]`}}
|
||||
return map[string]any{"data": map[string]any{"revision": 1, "markdown": "alpha beta x body gamma legacy []", "jsonml": `["root",{},["p",{"uuid":"block-1"},"alpha beta x body gamma legacy []"]]`}}
|
||||
case "list_document_blocks":
|
||||
return map[string]any{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "alpha beta"}}}}}
|
||||
return map[string]any{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "alpha beta x body gamma []"}}}}}
|
||||
case "submit_export_job":
|
||||
return map[string]any{"jobId": "job-1"}
|
||||
case "query_export_job":
|
||||
@@ -156,6 +166,224 @@ func TestCrossPlatformCoverageRevisionSelectionAndKeywordUseLiveShapes(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFetchResolvesUniqueTitleBeforeRead(t *testing.T) {
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"search_documents": {{
|
||||
"documents": []any{map[string]any{"nodeId": "resolved-node", "name": "项目周报", "docType": "adoc"}},
|
||||
"hasMore": false,
|
||||
}},
|
||||
}}
|
||||
if err := runDocCoverage(t, Fetch, caller, "--query", "项目周报"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.history) != 2 || caller.history[0].tool != "search_documents" || caller.history[1].tool != "get_document_content" {
|
||||
t.Fatalf("fetch resolution calls = %#v", caller.history)
|
||||
}
|
||||
if caller.history[1].params["nodeId"] != "resolved-node" {
|
||||
t.Fatalf("resolved fetch params = %#v", caller.history[1].params)
|
||||
}
|
||||
|
||||
ambiguous := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"search_documents": {{
|
||||
"documents": []any{
|
||||
map[string]any{"nodeId": "a", "name": "项目周报", "docType": "adoc"},
|
||||
map[string]any{"nodeId": "b", "name": "项目周报", "docType": "adoc"},
|
||||
},
|
||||
"hasMore": false,
|
||||
}},
|
||||
}}
|
||||
if err := runDocCoverage(t, Fetch, ambiguous, "--query", "项目周报"); err == nil {
|
||||
t.Fatal("ambiguous title unexpectedly succeeded")
|
||||
}
|
||||
if len(ambiguous.history) != 1 || ambiguous.history[0].tool != "search_documents" {
|
||||
t.Fatalf("ambiguous fetch reached content read: %#v", ambiguous.history)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTemplateGoldenRouteStopsBeforeAmbiguousCreate(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"templates": []any{
|
||||
map[string]any{"templateId": "template-b", "templateName": "团队周报", "templateSource": "PUBLIC"},
|
||||
map[string]any{"templateId": "template-a", "templateName": "运营周报", "templateSource": "PUBLIC"},
|
||||
},
|
||||
}
|
||||
candidates := collectTemplateCandidates(payload)
|
||||
if len(candidates) != 2 || candidates[0]["templateId"] != "template-b" || candidates[1]["templateId"] != "template-a" {
|
||||
t.Fatalf("template candidates = %#v", candidates)
|
||||
}
|
||||
|
||||
search := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {payload}}}
|
||||
if err := runDocCoverage(t, TemplateSearch, search, "--query", "周报", "--source", "PUBLIC"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(search.history) != 1 || search.history[0].tool != "search_doc_templates" {
|
||||
t.Fatalf("template search calls = %#v", search.history)
|
||||
}
|
||||
|
||||
ambiguous := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {payload}}}
|
||||
err := runDocCoverage(t, CreateFromTemplate, ambiguous, "--query", "周报", "--source", "PUBLIC")
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "template_selection_required" || typed.ExecutionStarted == nil || *typed.ExecutionStarted || !typed.RetryableSet || typed.Retryable {
|
||||
t.Fatalf("ambiguous template error = %#v", err)
|
||||
}
|
||||
if len(ambiguous.history) != 1 || ambiguous.history[0].tool != "search_doc_templates" {
|
||||
t.Fatalf("ambiguous template reached write: %#v", ambiguous.history)
|
||||
}
|
||||
if got, ok := typed.Details["candidates"].([]map[string]any); !ok || len(got) != 2 {
|
||||
t.Fatalf("ambiguous template details = %#v", typed.Details)
|
||||
}
|
||||
|
||||
notFound := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {{"templates": []any{}}}}}
|
||||
err = runDocCoverage(t, CreateFromTemplate, notFound, "--query", "不存在")
|
||||
if !errors.As(err, &typed) || typed.Reason != "template_not_found" {
|
||||
t.Fatalf("not-found template error = %#v", err)
|
||||
}
|
||||
|
||||
if selection := CreateFromTemplate.Contract.Selection; len(selection.AvoidWhen) < 2 || !strings.Contains(strings.Join(selection.AvoidWhen, " "), "+template-search") {
|
||||
t.Fatalf("create-from-template selection = %#v", selection)
|
||||
}
|
||||
mutualRoutes := []struct {
|
||||
name string
|
||||
selection contract.SelectionSpec
|
||||
siblings []string
|
||||
}{
|
||||
{name: "+template-list", selection: TemplateList.Contract.Selection, siblings: []string{"+template-search", "+create-from-template"}},
|
||||
{name: "+template-search", selection: TemplateSearch.Contract.Selection, siblings: []string{"+template-list", "+create-from-template"}},
|
||||
{name: "+create-from-template", selection: CreateFromTemplate.Contract.Selection, siblings: []string{"+template-list", "+template-search"}},
|
||||
}
|
||||
for _, route := range mutualRoutes {
|
||||
avoid := strings.Join(route.selection.AvoidWhen, " ")
|
||||
for _, sibling := range route.siblings {
|
||||
if !strings.Contains(avoid, sibling) {
|
||||
t.Errorf("%s Selection does not exclude sibling %s: %#v", route.name, sibling, route.selection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTemplateCreateProvesUniquenessAcrossPages(t *testing.T) {
|
||||
firstPage := map[string]any{
|
||||
"templates": []any{map[string]any{"templateId": "template-a", "templateName": "周报"}},
|
||||
"hasMore": true,
|
||||
"nextCursor": "page-2",
|
||||
}
|
||||
|
||||
ambiguous := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"search_doc_templates": {
|
||||
firstPage,
|
||||
{"templates": []any{map[string]any{"templateId": "template-b", "templateName": "周报二"}}, "hasMore": false},
|
||||
},
|
||||
}}
|
||||
err := runDocCoverage(t, CreateFromTemplate, ambiguous, "--query", "周报")
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "template_selection_required" {
|
||||
t.Fatalf("cross-page ambiguity = %#v", err)
|
||||
}
|
||||
if len(ambiguous.history) != 2 || ambiguous.history[1].params["nextCursor"] != "page-2" {
|
||||
t.Fatalf("cross-page search calls = %#v", ambiguous.history)
|
||||
}
|
||||
|
||||
unique := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"search_doc_templates": {
|
||||
firstPage,
|
||||
{"templates": []any{}, "hasMore": false},
|
||||
},
|
||||
}}
|
||||
if err := runDocCoverage(t, CreateFromTemplate, unique, "--query", "周报"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(unique.history) != 3 || unique.history[2].tool != "apply_doc_template" || unique.history[2].params["templateId"] != "template-a" {
|
||||
t.Fatalf("unique cross-page create calls = %#v", unique.history)
|
||||
}
|
||||
|
||||
missingCursor := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {firstPage}}}
|
||||
missingCursor.responses["search_doc_templates"][0] = map[string]any{
|
||||
"templates": []any{map[string]any{"templateId": "template-a"}},
|
||||
"hasMore": true,
|
||||
}
|
||||
err = runDocCoverage(t, CreateFromTemplate, missingCursor, "--query", "周报")
|
||||
if !errors.As(err, &typed) || typed.Reason != "template_pagination_missing_next_cursor" || len(missingCursor.history) != 1 {
|
||||
t.Fatalf("missing template cursor = %#v history=%#v", err, missingCursor.history)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExportRecoveryReusesJobAndSafeDownloader(t *testing.T) {
|
||||
for _, status := range []string{"INIT", "init", " PROCESSING "} {
|
||||
if !docExportStatusPollable(status) {
|
||||
t.Errorf("export status %q should remain pollable", status)
|
||||
}
|
||||
}
|
||||
for _, status := range []string{"", "FAILED", "CANCELLED", "SUCCESS"} {
|
||||
if docExportStatusPollable(status) {
|
||||
t.Errorf("export status %q should not remain pollable", status)
|
||||
}
|
||||
}
|
||||
init := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "INIT"}}}}
|
||||
err := runDocCoverage(t, Export, init, "--node", "n", "--export-format", "docx", "--output", "export.docx", "--max-polls", "1")
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "doc_export_poll_timeout" {
|
||||
t.Fatalf("INIT export status = %#v, want poll timeout", err)
|
||||
}
|
||||
|
||||
pollFailure := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}
|
||||
err = runDocCoverage(t, Export, pollFailure, "--node", "n", "--export-format", "docx", "--output", "export.docx")
|
||||
if !errors.As(err, &typed) || typed.Reason != "doc_export_poll_failed" || typed.Details["jobId"] != "job-1" || typed.ExecutionStarted == nil || !*typed.ExecutionStarted {
|
||||
t.Fatalf("export poll error = %#v", err)
|
||||
}
|
||||
if !strings.Contains(strings.Join(typed.Actions, " "), "+export-get --job-id job-1") {
|
||||
t.Fatalf("export recovery actions = %#v", typed.Actions)
|
||||
}
|
||||
|
||||
t.Chdir(t.TempDir())
|
||||
testseam.Swap(t, &docDownload, func(_ context.Context, _ string, opts localio.DownloadOptions) (localio.DownloadResult, error) {
|
||||
if opts.Output != "recovered.docx" {
|
||||
t.Fatalf("recovery output = %#v", opts)
|
||||
}
|
||||
return localio.DownloadResult{RelativePath: "recovered.docx", SizeBytes: 42}, nil
|
||||
})
|
||||
recovery := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"query_export_job": {{"status": "SUCCESS", "downloadUrl": "https://download.dingtalk.com/recovered.docx"}},
|
||||
}}
|
||||
if err := runDocCoverage(t, ExportGet, recovery, "--job-id", "job-1", "--output", "recovered.docx"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(recovery.history) != 1 || recovery.history[0].tool != "query_export_job" {
|
||||
t.Fatalf("export recovery calls = %#v", recovery.history)
|
||||
}
|
||||
if err := runDocCoverage(t, ExportGet, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--job-id", "job-1", "--output", "/tmp/unsafe.docx"); err == nil {
|
||||
t.Fatal("absolute recovery output unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommentListPaginationFlagsAndAlias(t *testing.T) {
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
|
||||
if err := runDocCoverage(t, CommentList, caller, "--node", "n", "--page-size", "20", "--cursor", "next-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.history) != 1 || caller.history[0].tool != "list_comments" || caller.history[0].params["pageSize"] != 20 || caller.history[0].params["nextToken"] != "next-1" {
|
||||
t.Fatalf("comment pagination call = %#v", caller.history)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMediaFailuresKeepStableIDsAndForbidPathEscape(t *testing.T) {
|
||||
if err := runDocCoverage(t, MediaInsert, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--file", "/tmp/media.bin", "--dry-run", "--yes"); err == nil {
|
||||
t.Fatal("absolute media input unexpectedly accepted")
|
||||
}
|
||||
if err := runDocCoverage(t, Import, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--file", "/tmp/report.docx", "--workspace", "workspace-1"); err == nil {
|
||||
t.Fatal("absolute import input unexpectedly accepted")
|
||||
}
|
||||
|
||||
resolveFailure := &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}
|
||||
err := runDocCoverage(t, MediaDownload, resolveFailure, "--node", "node-1", "--resource-id", "resource-1", "--output", "media.bin")
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "doc_media_resolve_failed" || typed.Details["nodeId"] != "node-1" || typed.Details["resourceId"] != "resource-1" {
|
||||
t.Fatalf("media recovery error = %#v", err)
|
||||
}
|
||||
if !strings.Contains(strings.Join(typed.Actions, " "), "不要 curl/wget") {
|
||||
t.Fatalf("media recovery actions = %#v", typed.Actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocCompositePartialWriteContracts(t *testing.T) {
|
||||
assertPartial := func(t *testing.T, err error, reason, stage, nodeID string, wantSteps int) *apperrors.Error {
|
||||
t.Helper()
|
||||
@@ -184,7 +412,7 @@ func TestCrossPlatformCoverageDocCompositePartialWriteContracts(t *testing.T) {
|
||||
}
|
||||
|
||||
create := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}
|
||||
err := runDocCoverage(t, Create, create, "--name", "n", "--content", `[]`, "--doc-format", "jsonml")
|
||||
err := runDocCoverage(t, Create, create, "--name", "n", "--content", `["root",{}]`, "--doc-format", "jsonml")
|
||||
typed := assertPartial(t, err, "doc_create_initial_content_failed", "write_jsonml", "node-1", 2)
|
||||
compensation, _ := typed.Details["compensation"].(map[string]any)
|
||||
if compensation["available"] != true || compensation["nodeId"] != "node-1" || len(create.history) != 2 {
|
||||
@@ -206,6 +434,13 @@ func TestCrossPlatformCoverageDocCompositePartialWriteContracts(t *testing.T) {
|
||||
err = runDocCoverage(t, CheckpointUpdate, checkpointVerify, "--node", "n", "--content", "body", "--yes")
|
||||
assertPartial(t, err, "doc_checkpoint_verification_failed", "verify", "n", 3)
|
||||
|
||||
checkpointMismatch := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"save_doc_version": {{"version": 8.0}},
|
||||
"get_document_content": {{"markdown": "different content"}},
|
||||
}}
|
||||
err = runDocCoverage(t, CheckpointUpdate, checkpointMismatch, "--node", "n", "--content", "body", "--yes")
|
||||
assertPartial(t, err, "doc_checkpoint_verification_failed", "verify", "n", 3)
|
||||
|
||||
historyVerify := &docCoverageCaller{failAt: 3, responses: map[string][]map[string]any{}}
|
||||
err = runDocCoverage(t, VersionRevert, historyVerify, "--node", "n", "--version", "3", "--yes")
|
||||
assertPartial(t, err, "doc_history_revert_verification_failed", "verify", "n", 3)
|
||||
@@ -215,22 +450,31 @@ func TestCrossPlatformCoverageDocUpdateAliasReachesNestedBranches(t *testing.T)
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
responses map[string][]map[string]any
|
||||
wantTools []string
|
||||
}{
|
||||
{
|
||||
name: "plain text replace",
|
||||
args: []string{"--doc", "alias-node", "--command", "str_replace", "--old", "alpha", "--new", "gamma", "--yes"},
|
||||
wantTools: []string{"list_document_blocks", "update_document_block"},
|
||||
name: "plain text replace",
|
||||
args: []string{"--doc", "alias-node", "--command", "str_replace", "--old", "alpha", "--new", "gamma", "--yes"},
|
||||
responses: map[string][]map[string]any{"list_document_blocks": {
|
||||
{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "alpha beta"}}}}},
|
||||
{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "gamma beta"}}}}},
|
||||
}},
|
||||
wantTools: []string{"list_document_blocks", "update_document_block", "list_document_blocks"},
|
||||
},
|
||||
{
|
||||
name: "block copy",
|
||||
args: []string{"--doc", "alias-node", "--command", "block_copy_insert_after", "--block-id", "block-1", "--after-block-id", "after", "--yes"},
|
||||
wantTools: []string{"list_document_blocks", "insert_document_block"},
|
||||
name: "block copy",
|
||||
args: []string{"--doc", "alias-node", "--command", "block_copy_insert_after", "--block-id", "block-1", "--after-block-id", "after", "--yes"},
|
||||
responses: map[string][]map[string]any{"list_document_blocks": {
|
||||
{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "alpha"}}}}},
|
||||
{"blocks": []any{map[string]any{"element": map[string]any{"id": "id-1", "paragraph": map[string]any{"text": "alpha"}}}}},
|
||||
}},
|
||||
wantTools: []string{"list_document_blocks", "insert_document_block", "list_document_blocks"},
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
|
||||
caller := &docCoverageCaller{responses: tc.responses}
|
||||
if err := runDocCoverage(t, Update, caller, tc.args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -246,6 +490,246 @@ func TestCrossPlatformCoverageDocUpdateAliasReachesNestedBranches(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocWritesStopOnUnknownCommitAndRequireVerification(t *testing.T) {
|
||||
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
|
||||
if err == nil || !errors.As(err, &typed) || typed.Reason != "doc_write_commit_unknown" || !typed.RetryableSet || typed.Retryable {
|
||||
t.Fatalf("unknown write error = %#v", err)
|
||||
}
|
||||
if len(unknown.history) != 1 || unknown.history[0].tool != "update_document" {
|
||||
t.Fatalf("unknown write was replayed or verified: %#v", unknown.history)
|
||||
}
|
||||
|
||||
verificationFailure := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}
|
||||
err = runDocCoverage(t, Update, verificationFailure, "--node", "n", "--command", "append", "--content", "x", "--yes")
|
||||
if err == nil || !errors.As(err, &typed) || typed.Reason != "doc_write_verification_failed" || typed.Details["status"] != "partial_success" {
|
||||
t.Fatalf("verification failure = %#v", err)
|
||||
}
|
||||
if len(verificationFailure.history) != 2 || verificationFailure.history[1].tool != "get_document_content" {
|
||||
t.Fatalf("verification calls = %#v", verificationFailure.history)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocCreateRejectsSuccessfulMismatchedReadback(t *testing.T) {
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_document_content": {{"markdown": "truncated"}},
|
||||
}}
|
||||
err := runDocCoverage(t, Create, caller, "--name", "n", "--content", "complete body")
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "doc_write_verification_failed" || typed.FailureStage != "verify" {
|
||||
t.Fatalf("create mismatch error = %#v, want verification failure", err)
|
||||
}
|
||||
if len(caller.history) != 2 || caller.history[0].tool != "create_document" || caller.history[1].tool != "get_document_content" {
|
||||
t.Fatalf("create mismatch calls = %#v, want one write followed by one read", caller.history)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocJSONMLBlockVerificationUsesJSONMLReadback(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
command string
|
||||
blockArg string
|
||||
response string
|
||||
}{
|
||||
{name: "insert", command: "block_insert_after", blockArg: "--after-block-id", response: `["root",{},["p",{"uuid":"ref"},"before"],["p",{"uuid":"id-1"},"after"]]`},
|
||||
{name: "replace", command: "block_replace", blockArg: "--block-id", response: `["root",{},["p",{"uuid":"target"},"after"]]`},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
blockID := "ref"
|
||||
if test.command == "block_replace" {
|
||||
blockID = "target"
|
||||
}
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_document_blocks": {{"jsonml": test.response}},
|
||||
}}
|
||||
err := runDocCoverage(t, Update, caller,
|
||||
"--node", "n", "--command", test.command, test.blockArg, blockID,
|
||||
"--content", `["p",{},"after"]`, "--doc-format", "jsonml", "--yes")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := caller.history[len(caller.history)-1].params["format"]; got != "jsonml" {
|
||||
t.Fatalf("verification format = %#v, want jsonml", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocVersionRevertPaginationAndVerification(t *testing.T) {
|
||||
t.Run("target on second page", func(t *testing.T) {
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_doc_versions": {
|
||||
{"versions": []any{map[string]any{"version": 1.0}}, "hasMore": true, "nextCursor": "page-2"},
|
||||
{"versions": []any{map[string]any{"version": 3.0}}, "hasMore": false},
|
||||
},
|
||||
}}
|
||||
if err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.history) != 4 || caller.history[1].params["nextCursor"] != "page-2" {
|
||||
t.Fatalf("version pagination calls = %#v", caller.history)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty write response and ordinary document info", func(t *testing.T) {
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"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)
|
||||
}
|
||||
})
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
responses []map[string]any
|
||||
want string
|
||||
}{
|
||||
{name: "not found", responses: []map[string]any{{"versions": []any{}, "hasMore": false}}, want: "目标版本 3 不存在"},
|
||||
{name: "missing cursor", responses: []map[string]any{{"versions": []any{}, "hasMore": true}}, want: "无法证明分页已经完整"},
|
||||
{name: "stalled cursor", responses: []map[string]any{
|
||||
{"versions": []any{}, "hasMore": true, "nextCursor": "same"},
|
||||
{"versions": []any{}, "hasMore": true, "nextCursor": "same"},
|
||||
}, want: "无法证明分页已经完整"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_doc_versions": test.responses}}
|
||||
err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("version preflight error = %v, want %q", err, test.want)
|
||||
}
|
||||
for _, call := range caller.history {
|
||||
if call.tool == "revert_doc_version" {
|
||||
t.Fatalf("preflight failure executed revert: %#v", caller.history)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pages := make([]map[string]any, 20)
|
||||
for index := range pages {
|
||||
pages[index] = map[string]any{"versions": []any{}, "hasMore": true, "nextCursor": fmt.Sprintf("page-%d", index+1)}
|
||||
}
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{"list_doc_versions": pages}}
|
||||
if err := runDocCoverage(t, VersionRevert, caller, "--node", "n", "--version", "3", "--yes"); err == nil || !strings.Contains(err.Error(), "无法证明分页已经完整") {
|
||||
t.Fatalf("max-pages preflight error = %v", err)
|
||||
}
|
||||
|
||||
for _, value := range []any{json.Number("3"), 3, "3"} {
|
||||
if !versionNumberMatches(value, 3) {
|
||||
t.Errorf("version value %#v did not match", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []any{json.Number("bad"), 4, "bad", true} {
|
||||
if versionNumberMatches(value, 3) {
|
||||
t.Errorf("version value %#v unexpectedly matched", value)
|
||||
}
|
||||
}
|
||||
if !versionEvidenceMatches([]any{map[string]any{"target-version": "3"}}, 3, map[string]bool{"targetversion": true}) {
|
||||
t.Fatal("nested version evidence did not match")
|
||||
}
|
||||
if versionEvidenceMatches(map[string]any{"version": 4}, 3, map[string]bool{"version": true}) {
|
||||
t.Fatal("mismatched version evidence unexpectedly matched")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocWriteErrorStateMachine(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cause error
|
||||
wantState string
|
||||
wantReason string
|
||||
wantRetry bool
|
||||
}{
|
||||
{
|
||||
name: "permission is terminal",
|
||||
cause: apperrors.NewAuth("forbidden", apperrors.WithReason("http_403"), apperrors.WithRetryable(false)),
|
||||
wantState: "failed",
|
||||
wantReason: "permission_denied",
|
||||
},
|
||||
{
|
||||
name: "confirmed not started may retry",
|
||||
cause: apperrors.NewAPI("rate limited", apperrors.WithReason("http_429"),
|
||||
apperrors.WithExecutionStarted(false), apperrors.WithRetryable(true)),
|
||||
wantState: "retryable",
|
||||
wantReason: "http_429",
|
||||
wantRetry: true,
|
||||
},
|
||||
{
|
||||
name: "unknown write never replays",
|
||||
cause: errors.New("connection reset"),
|
||||
wantState: "unknown",
|
||||
wantReason: "doc_write_commit_unknown",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := docUnknownWriteError("doc.update", "update_document", "n", tc.cause)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
if typed.Details["state"] != tc.wantState || typed.Reason != tc.wantReason || typed.Retryable != tc.wantRetry {
|
||||
t.Fatalf("transition = reason=%q retry=%v details=%#v", typed.Reason, typed.Retryable, typed.Details)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocLongWritesChunkOnceAndVerify(t *testing.T) {
|
||||
long := strings.Repeat("段落😀", 4000)
|
||||
chunks := splitDocMarkdown(long, 10000)
|
||||
if len(chunks) < 2 || strings.Join(chunks, "") != long {
|
||||
t.Fatalf("split chunks=%d roundtrip=%v", len(chunks), strings.Join(chunks, "") == long)
|
||||
}
|
||||
|
||||
update := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_document_content": {{"markdown": long}},
|
||||
}}
|
||||
if err := runDocCoverage(t, Update, update, "--node", "n", "--command", "overwrite", "--content", long, "--yes"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(update.history) != len(chunks)+1 || update.history[0].params["mode"] != "overwrite" || update.history[1].params["mode"] != "append" || update.history[len(update.history)-1].tool != "get_document_content" {
|
||||
t.Fatalf("long update calls = %#v", update.history)
|
||||
}
|
||||
|
||||
partial := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}
|
||||
err := runDocCoverage(t, Update, partial, "--node", "n", "--command", "append", "--content", long, "--yes")
|
||||
var typed *apperrors.Error
|
||||
if err == nil || !errors.As(err, &typed) || typed.Reason != "doc_update_chunk_commit_unknown" || len(partial.history) != 2 {
|
||||
t.Fatalf("partial long update err=%#v calls=%#v", err, partial.history)
|
||||
}
|
||||
|
||||
create := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_document_content": {{"markdown": long}},
|
||||
}}
|
||||
if err := runDocCoverage(t, Create, create, "--name", "long", "--content", long); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(create.history) != len(chunks)+1 || create.history[0].tool != "create_document" || create.history[1].tool != "update_document" || create.history[len(create.history)-1].tool != "get_document_content" {
|
||||
t.Fatalf("long create calls = %#v", create.history)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageUpdateVerificationNormalizesMarkdownRoundTrip(t *testing.T) {
|
||||
expected := "# 值班表\r\n\r\n| 姓名 | 班次 |\r\n| --- | --- |\r\n| 小王 | 早班 |\r\n"
|
||||
serverMarkdown := "# 值班表\n\n|姓名|班次|\n|---|---|\n|小王|早班|\n"
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_document_content": {{"data": map[string]any{"markdown": serverMarkdown}}},
|
||||
}}
|
||||
if err := runDocCoverage(t, Update, caller, "--node", "n", "--command", "overwrite", "--content", expected, "--yes"); err != nil {
|
||||
t.Fatalf("normalized Markdown verification failed: %v", err)
|
||||
}
|
||||
if !verifyUpdatedDocumentContent(map[string]any{"markdown": "已有内容\n" + serverMarkdown}, expected, "append", "markdown") {
|
||||
t.Fatal("normalized append verification did not find the appended content")
|
||||
}
|
||||
if verifyUpdatedDocumentContent(map[string]any{"markdown": "# 值班表\n|姓名|班次|"}, expected, "overwrite", "markdown") {
|
||||
t.Fatal("incomplete overwrite content passed normalized verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSelectionMatchesEnumerateEveryCandidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -378,20 +862,20 @@ func TestCrossPlatformCoverageDocContentCommandsAndFailureBoundaries(t *testing.
|
||||
{"inspect base", Inspect, []string{"--node", "n"}},
|
||||
{"inspect all", Inspect, []string{"--node", "n", "--include-style", "--include-permissions", "--include-history", "--include-media", "--include-comments"}},
|
||||
{"update append", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--yes"}},
|
||||
{"update overwrite jsonml", Update, []string{"--node", "n", "--command", "overwrite", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
|
||||
{"update overwrite jsonml", Update, []string{"--node", "n", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--yes"}},
|
||||
{"update insert text", Update, []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", "x", "--yes"}},
|
||||
{"update insert jsonml", Update, []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
|
||||
{"update insert jsonml", Update, []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", `["p",{},"x"]`, "--doc-format", "jsonml", "--yes"}},
|
||||
{"update replace text", Update, []string{"--node", "n", "--command", "block_replace", "--block-id", "b", "--content", "x", "--yes"}},
|
||||
{"update replace jsonml", Update, []string{"--node", "n", "--command", "block_replace", "--block-id", "b", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
|
||||
{"update replace jsonml", Update, []string{"--node", "n", "--command", "block_replace", "--block-id", "b", "--content", `["p",{},"x"]`, "--doc-format", "jsonml", "--yes"}},
|
||||
{"update delete", Update, []string{"--node", "n", "--command", "block_delete", "--block-id", "b", "--yes"}},
|
||||
{"update replace", Update, []string{"--node", "n", "--command", "str_replace", "--old", "alpha", "--new", "gamma", "--yes"}},
|
||||
{"update copy", Update, []string{"--node", "n", "--command", "block_copy_insert_after", "--block-id", "block-1", "--after-block-id", "b", "--yes"}},
|
||||
{"update revision", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"}},
|
||||
{"update revision", Update, []string{"--node", "n", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"}},
|
||||
{"update dry", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--dry-run", "--yes"}},
|
||||
{"checkpoint dry", CheckpointUpdate, []string{"--node", "n", "--content", "x", "--dry-run", "--yes"}},
|
||||
{"checkpoint success", CheckpointUpdate, []string{"--node", "n", "--content", "x", "--yes"}},
|
||||
{"export dry", Export, []string{"--node", "n", "--output", "out.docx", "--dry-run"}},
|
||||
{"export success", Export, []string{"--node", "n", "--output", "out.docx"}},
|
||||
{"export dry", Export, []string{"--node", "n", "--export-format", "docx", "--output", "out.docx", "--dry-run"}},
|
||||
{"export success", Export, []string{"--node", "n", "--export-format", "docx", "--output", "out.docx"}},
|
||||
}
|
||||
|
||||
testseam.Swap(t, &docDownload, func(_ context.Context, _ string, _ localio.DownloadOptions) (localio.DownloadResult, error) {
|
||||
@@ -400,6 +884,43 @@ func TestCrossPlatformCoverageDocContentCommandsAndFailureBoundaries(t *testing.
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
|
||||
switch tc.name {
|
||||
case "create markdown":
|
||||
caller.responses["get_document_content"] = []map[string]any{{"markdown": "body"}}
|
||||
case "create jsonml file":
|
||||
caller.responses["get_document_content"] = []map[string]any{{"jsonml": `["root",{},"body"]`}}
|
||||
case "create stdin":
|
||||
caller.responses["get_document_content"] = []map[string]any{{"markdown": "stdin body"}}
|
||||
}
|
||||
if tc.name == "update overwrite jsonml" || tc.name == "update revision" {
|
||||
caller.responses["get_document_content"] = []map[string]any{{"jsonml": `["root",{}]`}}
|
||||
}
|
||||
switch tc.name {
|
||||
case "update append":
|
||||
caller.responses["get_document_content"] = []map[string]any{{"markdown": "existing\nx"}}
|
||||
case "update insert text":
|
||||
caller.responses["list_document_blocks"] = []map[string]any{{"items": []any{map[string]any{"id": "b", "text": "reference"}, map[string]any{"id": "id-1", "text": "x"}}}}
|
||||
case "update insert jsonml":
|
||||
caller.responses["list_document_blocks"] = []map[string]any{{"jsonml": `["root",{},["p",{"uuid":"b"},"reference"],["p",{"uuid":"id-1"},"x"]]`}}
|
||||
case "update replace text":
|
||||
caller.responses["list_document_blocks"] = []map[string]any{{"items": []any{map[string]any{"id": "b", "text": "x"}}}}
|
||||
case "update replace jsonml":
|
||||
caller.responses["list_document_blocks"] = []map[string]any{{"jsonml": `["root",{},["p",{"uuid":"b"},"x"]]`}}
|
||||
case "update delete":
|
||||
caller.responses["list_document_blocks"] = []map[string]any{{"items": []any{map[string]any{"id": "other", "text": "x"}}}}
|
||||
case "update replace":
|
||||
caller.responses["list_document_blocks"] = []map[string]any{
|
||||
{"items": []any{map[string]any{"id": "block-1", "text": "alpha beta"}}},
|
||||
{"items": []any{map[string]any{"id": "block-1", "text": "gamma beta"}}},
|
||||
}
|
||||
case "update copy":
|
||||
caller.responses["list_document_blocks"] = []map[string]any{
|
||||
{"items": []any{map[string]any{"id": "block-1", "text": "alpha beta"}}},
|
||||
{"items": []any{map[string]any{"id": "b", "text": "reference"}, map[string]any{"id": "id-1", "text": "alpha beta"}}},
|
||||
}
|
||||
case "checkpoint success":
|
||||
caller.responses["get_document_content"] = []map[string]any{{"markdown": "existing\nx"}}
|
||||
}
|
||||
var err error
|
||||
if tc.name == "create stdin" {
|
||||
err = runDocCoverageInput(t, tc.cmd, caller, strings.NewReader("stdin body"), tc.args...)
|
||||
@@ -415,18 +936,73 @@ func TestCrossPlatformCoverageDocContentCommandsAndFailureBoundaries(t *testing.
|
||||
for _, command := range []shortcut.Shortcut{Create, Fetch, Inspect, Update, CheckpointUpdate, Export} {
|
||||
for failAt := 1; failAt <= 7; failAt++ {
|
||||
args := map[string][]string{
|
||||
"+create": {"--name", "n", "--content", `[]`, "--doc-format", "jsonml"},
|
||||
"+create": {"--name", "n", "--content", `["root",{}]`, "--doc-format", "jsonml"},
|
||||
"+fetch": {"--node", "n", "--scope", "keyword", "--keyword", "x"},
|
||||
"+inspect": {"--node", "n", "--include-style", "--include-permissions", "--include-history", "--include-media", "--include-comments"},
|
||||
"+update": {"--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"},
|
||||
"+update": {"--node", "n", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"},
|
||||
"+checkpoint-update": {"--node", "n", "--content", "x", "--yes"},
|
||||
"+export": {"--node", "n", "--output", "out.docx"},
|
||||
"+export": {"--node", "n", "--export-format", "docx", "--output", "out.docx"},
|
||||
}[command.Command]
|
||||
_ = runDocCoverage(t, command, &docCoverageCaller{failAt: failAt, responses: map[string][]map[string]any{}}, args...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageUpdateContractAndPreflight(t *testing.T) {
|
||||
flags := make(map[string]shortcut.Flag, len(Update.Flags))
|
||||
for _, flag := range Update.Flags {
|
||||
flags[flag.Name] = flag
|
||||
}
|
||||
if !flags["node"].Required || flags["command"].Required {
|
||||
t.Fatalf("unconditional required flags: node=%v command=%v", flags["node"].Required, flags["command"].Required)
|
||||
}
|
||||
for _, name := range []string{"content", "block-id", "after-block-id", "old", "new"} {
|
||||
if got := flags[name].RequiredWhen; got != "" {
|
||||
t.Errorf("--%s RequiredWhen = %q, want compatibility-safe custom constraint", name, got)
|
||||
}
|
||||
}
|
||||
if len(Update.Constraints) != 1 || Update.Constraints[0].Kind != shortcut.ConstraintCustom ||
|
||||
!strings.Contains(Update.Constraints[0].Description, "依 command 校验") {
|
||||
t.Fatalf("update custom constraint = %#v", Update.Constraints)
|
||||
}
|
||||
cmd := corecmd.New(shortcut.FromShortcut(Update))
|
||||
for _, alias := range []string{"doc", "text"} {
|
||||
flag := cmd.Flags().Lookup(alias)
|
||||
if flag == nil {
|
||||
t.Errorf("compatibility alias --%s is not mounted", alias)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "missing command", args: []string{"--node", "n"}, want: "--command"},
|
||||
{name: "insert missing reference", args: []string{"--node", "n", "--command", "block_insert_after", "--content", "x"}, want: "--after-block-id"},
|
||||
{name: "copy missing reference", args: []string{"--node", "n", "--command", "block_copy_insert_after", "--block-id", "b"}, want: "--after-block-id"},
|
||||
{name: "jsonml append", args: []string{"--node", "n", "--command", "append", "--content", `["root",{}]`, "--doc-format", "jsonml"}, want: "JSONML 当前不支持 append"},
|
||||
{name: "revision without server CAS path", args: []string{"--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1"}, want: "仅支持 --command overwrite --doc-format jsonml"},
|
||||
}
|
||||
for _, test := range invalid {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
|
||||
err := runDocCoverage(t, Update, caller, test.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("error = %v, want %q", err, test.want)
|
||||
}
|
||||
if strings.Contains(err.Error(), "confirmation_required") || caller.calls != 0 {
|
||||
t.Fatalf("invalid input reached confirmation or execution: err=%v calls=%d", err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if err := runDocCoverage(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{}},
|
||||
"--node", "n", "--command", "str_replace", "--old", "x", "--new", "", "--dry-run"); err != nil {
|
||||
t.Fatalf("explicit empty --new should satisfy str_replace contract: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
if err := os.WriteFile("body.md", []byte("body"), 0o600); err != nil {
|
||||
@@ -442,6 +1018,8 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
|
||||
{Create, []string{"--name", "n", "--content", "@/absolute"}},
|
||||
{Create, []string{"--name", "n", "--content", "not-json", "--doc-format", "jsonml"}},
|
||||
{Create, []string{"--name", "n", "--content", `{}`, "--doc-format", "jsonml"}},
|
||||
{Create, []string{"--name", "n", "--content", `[]`, "--doc-format", "jsonml"}},
|
||||
{Create, []string{"--name", "n", "--content", `[["p",{},"x"]]`, "--doc-format", "jsonml"}},
|
||||
{Fetch, []string{"--node", "n", "--revision", "1"}},
|
||||
{Fetch, []string{"--node", "n", "--scope", "keyword"}},
|
||||
{Update, []string{"--node", "n"}},
|
||||
@@ -449,25 +1027,28 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
|
||||
{Update, []string{"--node", "n", "--command", "append", "--yes"}},
|
||||
{Update, []string{"--node", "n", "--command", "block_delete", "--yes"}},
|
||||
{Update, []string{"--node", "n", "--command", "str_replace", "--old", "x", "--yes"}},
|
||||
{Update, []string{"--node", "n", "--command", "append", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
|
||||
{Update, []string{"--node", "n", "--command", "append", "--content", `["root",{}]`, "--doc-format", "jsonml", "--yes"}},
|
||||
}
|
||||
for _, tc := range badCases {
|
||||
if err := runDocCoverage(t, tc.cmd, &docCoverageCaller{responses: map[string][]map[string]any{}}, tc.args...); err == nil {
|
||||
t.Errorf("%s %#v unexpectedly succeeded", tc.cmd.Command, tc.args)
|
||||
}
|
||||
}
|
||||
absoluteInputErr := runDocCoverage(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--name", "n", "--content", "@/absolute")
|
||||
if absoluteInputErr == nil || !strings.Contains(absoluteInputErr.Error(), "暂存到工作目录") || !strings.Contains(absoluteInputErr.Error(), "stdin") {
|
||||
t.Fatalf("absolute @file guidance = %v", absoluteInputErr)
|
||||
}
|
||||
|
||||
createNoNode := &docCoverageCaller{responses: map[string][]map[string]any{"create_document": {{"ok": true}}}}
|
||||
if err := runDocCoverage(t, Create, createNoNode, "--name", "n", "--content", `[]`, "--doc-format", "jsonml"); err == nil {
|
||||
if err := runDocCoverage(t, Create, createNoNode, "--name", "n", "--content", `["root",{}]`, "--doc-format", "jsonml"); err == nil {
|
||||
t.Fatal("jsonml create without node id succeeded")
|
||||
}
|
||||
conflict := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_content": {{"revision": 2}}}}
|
||||
if err := runDocCoverage(t, Update, conflict, "--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"); err == nil {
|
||||
t.Fatal("revision conflict succeeded")
|
||||
revision := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_content": {{"jsonml": `["root",{}]`}}}}
|
||||
if err := runDocCoverage(t, Update, revision, "--node", "n", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "7", "--yes"); err != nil {
|
||||
t.Fatalf("server revision update failed: %v", err)
|
||||
}
|
||||
missingRevision := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_content": {{"ok": true}}}}
|
||||
if err := runDocCoverage(t, Update, missingRevision, "--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"); err == nil {
|
||||
t.Fatal("missing revision succeeded")
|
||||
if len(revision.history) < 1 || revision.history[0].tool != "update_document" || revision.history[0].params["revision"] != 7 {
|
||||
t.Fatalf("expected revision was not forwarded atomically: %#v", revision.history)
|
||||
}
|
||||
|
||||
for _, value := range []any{
|
||||
@@ -482,6 +1063,9 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
|
||||
if _, err := validateJSONML(`{}`); err == nil {
|
||||
t.Fatal("object jsonml succeeded")
|
||||
}
|
||||
if _, err := validateJSONML(`[["p",{},"x"]]`); err == nil {
|
||||
t.Fatal("nested element-array jsonml succeeded")
|
||||
}
|
||||
if nestedMap(map[string]any{"result": map[string]any{"data": map[string]any{"x": 1}}})["x"] != 1 {
|
||||
t.Fatal("nestedMap did not unwrap")
|
||||
}
|
||||
@@ -496,8 +1080,11 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
|
||||
_ = containsResourceReference([]any{map[string]any{"x": "y"}})
|
||||
stripBlockIDs(blocks)
|
||||
|
||||
if err := runDocCoveragePath(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{}}, strings.NewReader(""), "+update", "--doc", "n", "--command", "append", "--text", "legacy", "--yes"); err != nil {
|
||||
t.Fatalf("visible update flag aliases: %v", err)
|
||||
aliasCaller := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"get_document_content": {{"markdown": "legacy"}},
|
||||
}}
|
||||
if err := runDocCoveragePath(t, Update, aliasCaller, strings.NewReader(""), "+update", "--doc", "n", "--command", "append", "--text", "legacy", "--yes"); err != nil {
|
||||
t.Fatalf("compatibility aliases --doc/--text must remain executable: %v", err)
|
||||
}
|
||||
missingNode := Update
|
||||
missingNode.Flags = append([]shortcut.Flag(nil), Update.Flags...)
|
||||
@@ -562,19 +1149,19 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
|
||||
{"jobId": "j"},
|
||||
} {
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{"submit_export_job": {response}, "query_export_job": {{"status": "FAILED", "message": "bad"}}}}
|
||||
_ = runDocCoverage(t, Export, caller, "--node", "n", "--output", "x", "--max-polls", "1")
|
||||
_ = runDocCoverage(t, Export, caller, "--node", "n", "--export-format", "docx", "--output", "x", "--max-polls", "1")
|
||||
}
|
||||
timeout := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}}}}
|
||||
_ = runDocCoverage(t, Export, timeout, "--node", "n", "--output", "x", "--max-polls", "1")
|
||||
_ = runDocCoverage(t, Export, timeout, "--node", "n", "--export-format", "docx", "--output", "x", "--max-polls", "1")
|
||||
processingThenSuccess := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}, {"status": "SUCCESS", "downloadUrl": "https://download.dingtalk.com/x"}}}}
|
||||
_ = runDocCoverage(t, Export, processingThenSuccess, "--node", "n", "--output", "x", "--max-polls", "2")
|
||||
_ = runDocCoverage(t, Export, processingThenSuccess, "--node", "n", "--export-format", "docx", "--output", "x", "--max-polls", "2")
|
||||
cancelled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
cancelledCaller := &docCoverageCaller{ctx: cancelled, responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}}}}
|
||||
_ = runDocCoverage(t, Export, cancelledCaller, "--node", "n", "--output", "x", "--max-polls", "2")
|
||||
_ = runDocCoverage(t, Export, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--output", "x", "--max-polls", "0")
|
||||
_ = runDocCoverage(t, Export, cancelledCaller, "--node", "n", "--export-format", "docx", "--output", "x", "--max-polls", "2")
|
||||
_ = runDocCoverage(t, Export, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--export-format", "docx", "--output", "x", "--max-polls", "0")
|
||||
missingURL := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS"}}}}
|
||||
_ = runDocCoverage(t, Export, missingURL, "--node", "n", "--output", "x")
|
||||
_ = runDocCoverage(t, Export, missingURL, "--node", "n", "--export-format", "docx", "--output", "x")
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) {
|
||||
@@ -596,7 +1183,7 @@ func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) {
|
||||
{CommentUpdate, []string{"--node", "n", "--comment-key", "c", "--content", "x", "--mention", "u"}},
|
||||
{CommentDelete, []string{"--node", "n", "--comment-key", "c", "--dry-run", "--yes"}},
|
||||
{CommentCreate, []string{"--node", "n", "--content", "x", "--yes"}},
|
||||
{CommentCreate, []string{"--node", "n", "--content", "x", "--block-id", "b", "--start", "0", "--end", "1", "--selected-text", "a", "--mention", "u", "--yes"}},
|
||||
{CommentCreate, []string{"--node", "n", "--content", "x", "--block-id", "block-1", "--start", "0", "--end", "1", "--selected-text", "a", "--mention", "u", "--yes"}},
|
||||
{CommentCreate, []string{"--node", "n", "--content", "x", "--selection", "alpha", "--yes"}},
|
||||
{MediaList, []string{"--node", "n"}},
|
||||
{MediaPreview, []string{"--node", "n", "--resource-id", "r"}},
|
||||
@@ -620,7 +1207,7 @@ func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) {
|
||||
|
||||
for _, declaration := range []shortcut.Shortcut{VersionRevert, CreateFromTemplate, Review, MediaList, MediaDownload, ResourceDownload} {
|
||||
args := map[string][]string{
|
||||
"+history-revert": {"--node", "n", "--version", "3", "--yes"},
|
||||
"+version-revert": {"--node", "n", "--version", "3", "--yes"},
|
||||
"+create-from-template": {"--query", "q"},
|
||||
"+review": {"--node", "n"},
|
||||
"+media-list": {"--node", "n"},
|
||||
@@ -676,6 +1263,27 @@ func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) {
|
||||
_ = runDocCoverage(t, MediaInsert, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--file", "media.bin", "--dry-run", "--yes")
|
||||
_ = runDocCoverage(t, ResourceUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--image", "https://example.com/cover.png", "--dry-run", "--yes")
|
||||
_ = runDocCoverage(t, Import, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--file", "media.bin", "--folder", "f", "--dry-run")
|
||||
if err := runDocCoverage(t, Import, &docCoverageCaller{dryRun: true, responses: map[string][]map[string]any{}}, "--file", "media.bin", "--dry-run"); err != nil {
|
||||
t.Fatalf("import to default root failed: %v", err)
|
||||
}
|
||||
if err := runDocCoverage(t, Export, &docCoverageCaller{dryRun: true, responses: map[string][]map[string]any{}}, "--node", "n", "--output", "out.docx", "--dry-run"); err != nil {
|
||||
t.Fatalf("export default format failed: %v", err)
|
||||
}
|
||||
if selected, ok := sliceUTF16Range("A😀B", 1, 3); !ok || selected != "😀" {
|
||||
t.Fatalf("UTF-16 selection = %q/%v", selected, ok)
|
||||
}
|
||||
if _, ok := sliceUTF16Range("A😀B", 2, 3); ok {
|
||||
t.Fatal("selection splitting a surrogate pair succeeded")
|
||||
}
|
||||
for _, raw := range []string{`["550582"]`, `[550582]`} {
|
||||
mentions, err := normalizeMentionUserIDs([]string{raw})
|
||||
if err != nil || len(mentions) != 1 || mentions[0] != "550582" {
|
||||
t.Fatalf("mention normalization %q = %#v/%v", raw, mentions, err)
|
||||
}
|
||||
}
|
||||
if _, err := normalizeMentionUserIDs([]string{`[{"uid":"550582"}]`}); err == nil {
|
||||
t.Fatal("object mention unexpectedly succeeded")
|
||||
}
|
||||
|
||||
resourceOnly := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_style": {{"resourceId": "r"}}}}
|
||||
_ = runDocCoverage(t, ResourceDownload, resourceOnly, "--node", "n", "--output", "cover.png")
|
||||
@@ -704,6 +1312,53 @@ func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageVersionRoutesAreCanonicalAndHistoryRoutesAreCompatible(t *testing.T) {
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "doc" {
|
||||
registered[item.Command] = item
|
||||
}
|
||||
}
|
||||
|
||||
pairs := map[string]string{
|
||||
"+history-save": "+version-save",
|
||||
"+history-list": "+version-list",
|
||||
"+history-revert": "+version-revert",
|
||||
}
|
||||
for compatibility, canonical := range pairs {
|
||||
compatItem, ok := registered[compatibility]
|
||||
if !ok {
|
||||
t.Fatalf("missing compatibility route %s", compatibility)
|
||||
}
|
||||
canonicalItem, ok := registered[canonical]
|
||||
if !ok {
|
||||
t.Fatalf("missing canonical route %s", canonical)
|
||||
}
|
||||
if compatItem.Disposition != shortcut.DispositionAliasInternal || compatItem.PrimaryCommand != canonical {
|
||||
t.Errorf("%s routing = %s/%s, want alias_internal/%s", compatibility, compatItem.Disposition, compatItem.PrimaryCommand, canonical)
|
||||
}
|
||||
if !strings.Contains(strings.Join(compatItem.Contract.Selection.AvoidWhen, " "), canonical) {
|
||||
t.Errorf("%s compatibility Selection does not route Agent to %s", compatibility, canonical)
|
||||
}
|
||||
if canonicalItem.Contract.Selection.UseWhen[0] != canonicalItem.Intent {
|
||||
t.Errorf("%s UseWhen and Intent drifted", canonical)
|
||||
}
|
||||
if strings.Contains(strings.Join(canonicalItem.Contract.Selection.AvoidWhen, " "), "+history-") {
|
||||
t.Errorf("%s canonical Selection routes back to history compatibility commands", canonical)
|
||||
}
|
||||
}
|
||||
|
||||
if VersionSave.Command != "+version-save" || VersionSave.Safety.Confirmation != "user_required" {
|
||||
t.Errorf("version-save command/confirmation = %s/%s", VersionSave.Command, VersionSave.Safety.Confirmation)
|
||||
}
|
||||
if compatHistorySave.Safety.Confirmation != "not_required" {
|
||||
t.Errorf("history-save compatibility confirmation = %s", compatHistorySave.Safety.Confirmation)
|
||||
}
|
||||
if VersionRevert.Command != "+version-revert" || VersionRevert.Execute == nil {
|
||||
t.Errorf("version-revert canonical smart route is incomplete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocDownloadAndWorkingDirectoryErrors(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
testseam.Swap(t, &docDownload, func(_ context.Context, _ string, _ localio.DownloadOptions) (localio.DownloadResult, error) {
|
||||
@@ -713,7 +1368,7 @@ func TestCrossPlatformCoverageDocDownloadAndWorkingDirectoryErrors(t *testing.T)
|
||||
decl shortcut.Shortcut
|
||||
args []string
|
||||
}{
|
||||
{Export, []string{"--node", "n", "--output", "out.docx"}},
|
||||
{Export, []string{"--node", "n", "--export-format", "docx", "--output", "out.docx"}},
|
||||
{MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "out.bin"}},
|
||||
{ResourceDownload, []string{"--node", "n", "--output", "out.png"}},
|
||||
} {
|
||||
@@ -727,7 +1382,7 @@ func TestCrossPlatformCoverageDocDownloadAndWorkingDirectoryErrors(t *testing.T)
|
||||
decl shortcut.Shortcut
|
||||
args []string
|
||||
}{
|
||||
{Export, []string{"--node", "n", "--output", "out.docx"}},
|
||||
{Export, []string{"--node", "n", "--export-format", "docx", "--output", "out.docx"}},
|
||||
{MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "out.bin"}},
|
||||
{ResourceDownload, []string{"--node", "n", "--output", "out.png"}},
|
||||
} {
|
||||
|
||||
@@ -4,112 +4,165 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
var (
|
||||
legacyVersionSave shortcut.Shortcut
|
||||
legacyVersionList shortcut.Shortcut
|
||||
legacyVersionRevert shortcut.Shortcut
|
||||
compatHistorySave shortcut.Shortcut
|
||||
compatHistoryList shortcut.Shortcut
|
||||
compatHistoryRevert shortcut.Shortcut
|
||||
)
|
||||
|
||||
func canonicalizeHistoryShortcuts() {
|
||||
legacyVersionSave = VersionSave
|
||||
legacyVersionList = VersionList
|
||||
legacyVersionRevert = VersionRevert
|
||||
|
||||
VersionSave.Command = "+history-save"
|
||||
VersionSave.Command = "+version-save"
|
||||
VersionSave.Aliases = nil
|
||||
VersionSave.Description = "手动保存当前文档版本快照"
|
||||
VersionSave.Intent = "当用户要在重要修改前后手动建立一个可回滚的文档历史快照时使用;保存快照本身无需交互确认。"
|
||||
VersionSave.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"}
|
||||
VersionSave.Contract = docContract("+history-save", VersionSave.Description, VersionSave.Intent, []string{`dws doc +history-save --node <DOC_ID>`})
|
||||
VersionSave.Tips = []string{`dws doc +history-save --node <DOC_ID>`}
|
||||
VersionSave.Intent = "当用户要求保存、创建或建立当前文档版本快照时使用;只保存快照,不更新正文。"
|
||||
VersionSave.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"}
|
||||
VersionSave.Contract = versionSaveContract()
|
||||
VersionSave.Tips = []string{`dws doc +version-save --node <DOC_ID>`}
|
||||
|
||||
VersionList.Command = "+history-list"
|
||||
VersionList.Command = "+version-list"
|
||||
VersionList.Aliases = nil
|
||||
VersionList.Description = "分页列出文档历史版本"
|
||||
VersionList.Intent = "当用户要查看文档已有版本、选择回滚目标或审计版本时间线时使用;返回版本号和分页游标。"
|
||||
VersionList.Contract = docContract("+history-list", VersionList.Description, VersionList.Intent, []string{`dws doc +history-list --node <DOC_ID>`, `dws doc +history-list --node <DOC_ID> --page-size 20`})
|
||||
VersionList.Contract = versionListContract()
|
||||
VersionList.Flags = []shortcut.Flag{
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
|
||||
{Name: "page-size", Type: shortcut.FlagInt, Desc: "每页版本数量"},
|
||||
{Name: "page-token", Type: shortcut.FlagString, Desc: "分页游标"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "--page-size 的兼容别名"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "--page-token 的兼容别名"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "返回版本数量上限", Aliases: []string{"page-size"}, AliasesVisible: true},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标", Aliases: []string{"page-token"}, AliasesVisible: true},
|
||||
}
|
||||
VersionList.Tips = []string{`dws doc +history-list --node <DOC_ID>`, `dws doc +history-list --node <DOC_ID> --page-size 20`}
|
||||
VersionList.Tips = []string{`dws doc +version-list --node <DOC_ID>`, `dws doc +version-list --node <DOC_ID> --limit 20`}
|
||||
VersionList.Execute = func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"nodeId": rt.Str("node")}
|
||||
if size := rt.IntFirst("page-size", "limit"); size > 0 {
|
||||
if size := rt.IntFirst("limit", "page-size"); size > 0 {
|
||||
params["maxResults"] = size
|
||||
}
|
||||
if token := rt.StrFirst("page-token", "cursor"); token != "" {
|
||||
if token := rt.StrFirst("cursor", "page-token"); token != "" {
|
||||
params["nextCursor"] = token
|
||||
}
|
||||
return rt.CallMCP("list_doc_versions", params)
|
||||
}
|
||||
|
||||
VersionRevert.Command = "+history-revert"
|
||||
VersionRevert.Command = "+version-revert"
|
||||
VersionRevert.Aliases = nil
|
||||
VersionRevert.Description = "预检并回滚文档到指定历史版本"
|
||||
VersionRevert.Intent = "当用户明确要把整篇文档恢复到某个历史版本时使用;先确认目标版本存在,再执行高风险回滚并读回验证。"
|
||||
VersionRevert.Contract = docContract("+history-revert", VersionRevert.Description, VersionRevert.Intent, []string{`dws doc +history-revert --node <DOC_ID> --version 3`})
|
||||
VersionRevert.Tips = []string{`dws doc +history-revert --node <DOC_ID> --version 3`}
|
||||
VersionRevert.Contract = versionRevertContract()
|
||||
VersionRevert.Tips = []string{`dws doc +version-revert --node <DOC_ID> --version 3`}
|
||||
VersionRevert.Execute = executeHistoryRevert
|
||||
|
||||
compatHistorySave = compatibilityHistoryShortcut(VersionSave, "+history-save", "+version-save")
|
||||
compatHistorySave.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"}
|
||||
compatHistoryList = compatibilityHistoryShortcut(VersionList, "+history-list", "+version-list")
|
||||
compatHistoryRevert = compatibilityHistoryShortcut(VersionRevert, "+history-revert", "+version-revert")
|
||||
|
||||
TemplateList.Description = "浏览当前用户可用的 MY/PUBLIC 文档模板"
|
||||
TemplateList.Intent = "当用户要浏览自己的或公开的文档模板并获取 templateId 时使用;若已知名称可改用 template-search。"
|
||||
TemplateList.Contract = docContract("+template-list", TemplateList.Description, TemplateList.Intent, []string{`dws doc +template-list --source PUBLIC`})
|
||||
TemplateSearch.Description = "按名称检索文档模板"
|
||||
TemplateSearch.Intent = "当用户知道模板名称关键词、要快速定位唯一 templateId 后继续创建文档时使用。"
|
||||
TemplateSearch.Contract = docContract("+template-search", TemplateSearch.Description, TemplateSearch.Intent, []string{`dws doc +template-search --query "周报"`})
|
||||
TemplateList.Intent = "当用户没有明确模板名称或关键词,只要浏览自己的或公开模板并获取 templateId 时使用。"
|
||||
TemplateList.Contract = templateListContract()
|
||||
TemplateSearch.Description = "按名称或关键词检索文档模板"
|
||||
TemplateSearch.Intent = "当用户已提供明确模板名称或关键词时使用;返回结构化候选和 resolved/not_found/selection_required 状态,零命中或多候选时停止创建。"
|
||||
TemplateSearch.Contract = templateSearchContract()
|
||||
}
|
||||
|
||||
func canonicalizeCommentShortcuts() {
|
||||
// Preserve the historical confirmation contract for comment writes. The
|
||||
// richer canonical implementations must not silently weaken that gate.
|
||||
CommentCreate.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"}
|
||||
CommentReply.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"}
|
||||
CommentCreate.Description = "创建全文评论,或按 selection 创建划词评论"
|
||||
CommentCreate.Intent = "当用户要对整篇文档留言,或针对文档中唯一匹配的一段文字创建精确划词评论时使用;已知 block/start/end 时也可直接走高级通道。"
|
||||
CommentCreate.Contract = docContract("+comment-create", CommentCreate.Description, CommentCreate.Intent, []string{`dws doc +comment-create --node <DOC_ID> --content "请补充数据来源"`, `dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"`})
|
||||
CommentCreate.Flags = []shortcut.Flag{
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
|
||||
{Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容", Required: true},
|
||||
{Name: "selection", Type: shortcut.FlagString, Desc: "完整文字或 前缀...后缀 selection;使用时不能为空且必须唯一匹配"},
|
||||
{Name: "block-id", Type: shortcut.FlagString, Desc: "高级通道 block ID;使用时不能为空且须与 start/end 一起提供"},
|
||||
{Name: "start", Type: shortcut.FlagInt, Desc: "块内起始字符偏移;高级通道参数不能为空且须一起提供"},
|
||||
{Name: "end", Type: shortcut.FlagInt, Desc: "块内结束字符偏移;高级通道参数不能为空且须一起提供"},
|
||||
{Name: "selected-text", Type: shortcut.FlagString, Desc: "高级通道引用原文"},
|
||||
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
|
||||
func versionSaveContract() corecmd.ContractDecl {
|
||||
decl := docContract("+version-save", VersionSave.Description, VersionSave.Intent, []string{`dws doc +version-save --node <DOC_ID>`})
|
||||
decl.Selection.AvoidWhen = []string{
|
||||
"只查看历史版本时使用 doc +version-list;需要恢复历史内容时使用 doc +version-revert",
|
||||
"需要保存恢复点后继续修改正文时使用 doc +checkpoint-update",
|
||||
}
|
||||
CommentCreate.Constraints = []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"selection", "block-id", "start", "end"}, Description: "selection/高级通道参数不能为空;selection 必须唯一匹配,block-id/start/end 必须一起提供"}}
|
||||
CommentCreate.Validate = validateCommentCreate
|
||||
CommentCreate.Execute = executeCommentCreate
|
||||
CommentCreate.Tips = []string{`dws doc +comment-create --node <DOC_ID> --content "请补充数据来源"`, `dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"`}
|
||||
return decl
|
||||
}
|
||||
|
||||
func versionListContract() corecmd.ContractDecl {
|
||||
decl := docContract("+version-list", VersionList.Description, VersionList.Intent, []string{`dws doc +version-list --node <DOC_ID>`, `dws doc +version-list --node <DOC_ID> --limit 20`})
|
||||
decl.Selection.AvoidWhen = []string{
|
||||
"需要创建当前版本快照时使用 doc +version-save",
|
||||
"已经明确目标版本并要求恢复正文时使用 doc +version-revert",
|
||||
}
|
||||
return decl
|
||||
}
|
||||
|
||||
func versionRevertContract() corecmd.ContractDecl {
|
||||
decl := docContract("+version-revert", VersionRevert.Description, VersionRevert.Intent, []string{`dws doc +version-revert --node <DOC_ID> --version 3`})
|
||||
decl.Selection.AvoidWhen = []string{
|
||||
"只需要创建当前版本快照时使用 doc +version-save",
|
||||
"尚未确定目标版本号时先使用 doc +version-list",
|
||||
}
|
||||
return decl
|
||||
}
|
||||
|
||||
func compatibilityHistoryShortcut(source shortcut.Shortcut, command, canonical string) shortcut.Shortcut {
|
||||
result := source
|
||||
result.Command = command
|
||||
result.Aliases = nil
|
||||
result.Description = fmt.Sprintf("兼容入口:%s", source.Description)
|
||||
result.Intent = fmt.Sprintf("仅当调用方明确使用既有 doc %s 路径时兼容执行。", command)
|
||||
result.Contract = docContract(command, result.Description, result.Intent, []string{fmt.Sprintf("dws doc %s --node <DOC_ID>", command)})
|
||||
result.Contract.Selection.AvoidWhen = []string{fmt.Sprintf("新的 Agent 任务统一使用 doc %s;不要因自然语言版本意图选择本兼容入口", canonical)}
|
||||
result.Tips = []string{fmt.Sprintf("dws doc %s --node <DOC_ID>", command)}
|
||||
if command == "+history-revert" {
|
||||
result.Contract.Selection.Examples = []string{`dws doc +history-revert --node <DOC_ID> --version 3`}
|
||||
result.Tips = []string{`dws doc +history-revert --node <DOC_ID> --version 3`}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func templateSearchContract() corecmd.ContractDecl {
|
||||
decl := docContract("+template-search", TemplateSearch.Description, TemplateSearch.Intent, []string{`dws doc +template-search --query "周报"`})
|
||||
decl.Selection.AvoidWhen = []string{
|
||||
"没有明确模板名称或关键词、只是浏览可用模板时使用 doc +template-list",
|
||||
"已经拿到唯一 templateId 时不要重复搜索,直接使用 doc +create-from-template --template-id",
|
||||
"不要为了预览多个候选而逐个创建文档;多候选必须让用户选择",
|
||||
}
|
||||
return decl
|
||||
}
|
||||
|
||||
func templateListContract() corecmd.ContractDecl {
|
||||
decl := docContract("+template-list", TemplateList.Description, TemplateList.Intent, []string{`dws doc +template-list --source PUBLIC`})
|
||||
decl.Selection.AvoidWhen = []string{
|
||||
"已经有明确模板名称或关键词时使用 doc +template-search --query",
|
||||
"已经拿到 templateId 且要创建文档时使用 doc +create-from-template --template-id",
|
||||
}
|
||||
return decl
|
||||
}
|
||||
|
||||
func createFromTemplateContract() corecmd.ContractDecl {
|
||||
decl := docContract("+create-from-template", "使用已选定的 templateId 创建文档",
|
||||
"当模板搜索已经唯一解析或用户明确提供 templateId 时使用;只创建一次并返回稳定 nodeId。",
|
||||
[]string{`dws doc +create-from-template --template-id <TEMPLATE_ID> --name "我的周报"`})
|
||||
decl.Selection.AvoidWhen = []string{
|
||||
"只有模板名称或关键词时先使用 doc +template-search;零命中或多候选时停止并让用户选择",
|
||||
"没有模板名称、关键词或 templateId,只想浏览可用模板时使用 doc +template-list",
|
||||
"不要通过创建多个候选文档来预览模板,也不要反复改写关键词自动重试",
|
||||
}
|
||||
return decl
|
||||
}
|
||||
|
||||
func executeHistoryRevert(rt *shortcut.RuntimeContext) error {
|
||||
nodeID := rt.Str("node")
|
||||
target := rt.Int("version")
|
||||
versions, err := rt.CallMCPData(productDoc, "list_doc_versions", map[string]any{"nodeId": nodeID})
|
||||
found, err := findHistoryVersion(rt, nodeID, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !containsVersion(versions, target) {
|
||||
if !found {
|
||||
return apperrors.NewValidation(fmt.Sprintf("目标版本 %d 不存在,已停止回滚", target))
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(docEnvelope("doc.history_revert", map[string]any{"executed": false, "nodeId": nodeID, "version": target, "preflight": "version_exists"}))
|
||||
}
|
||||
if _, err := rt.CallMCPWriteData(productDoc, "revert_doc_version", map[string]any{"nodeId": nodeID, "version": target}); err != nil {
|
||||
reverted, err := rt.CallMCPWriteData(productDoc, "revert_doc_version", map[string]any{"nodeId": nodeID, "version": target})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := rt.CallMCPData(productDoc, "get_document_info", map[string]any{"nodeId": nodeID})
|
||||
@@ -125,28 +178,111 @@ func executeHistoryRevert(rt *shortcut.RuntimeContext) error {
|
||||
map[string]any{"available": false, "reason": "the requested revert completed; verify the current document before any further write"},
|
||||
)
|
||||
}
|
||||
return rt.Output(docEnvelope("doc.history_revert", map[string]any{"version": target, "current": current},
|
||||
return rt.Output(docEnvelope("doc.history_revert", map[string]any{
|
||||
"version": target, "revertResult": reverted, "current": current, "verified": true,
|
||||
"verification": "revert_acknowledged_and_document_readable",
|
||||
},
|
||||
map[string]any{"name": "preflight", "status": "success"},
|
||||
map[string]any{"name": "revert", "status": "success"},
|
||||
map[string]any{"name": "verify", "status": "success"}))
|
||||
}
|
||||
|
||||
func findHistoryVersion(rt *shortcut.RuntimeContext, nodeID string, target int) (bool, error) {
|
||||
const maxPages = 20
|
||||
cursor := ""
|
||||
seenCursors := map[string]bool{}
|
||||
for page := 1; page <= maxPages; page++ {
|
||||
params := map[string]any{"nodeId": nodeID}
|
||||
if cursor != "" {
|
||||
params["nextCursor"] = cursor
|
||||
}
|
||||
versions, err := rt.CallMCPData(productDoc, "list_doc_versions", params)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if containsVersion(versions, target) {
|
||||
return true, nil
|
||||
}
|
||||
hasMore, hasMoreKnown, nextCursor := docPageState(versions)
|
||||
nextCursor = strings.TrimSpace(nextCursor)
|
||||
if hasMoreKnown && !hasMore {
|
||||
return false, nil
|
||||
}
|
||||
if nextCursor == "" {
|
||||
if hasMoreKnown && hasMore {
|
||||
return false, historyVersionPaginationError("missing_next_cursor", page, cursor)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
if seenCursors[nextCursor] {
|
||||
return false, historyVersionPaginationError("stalled_cursor", page, nextCursor)
|
||||
}
|
||||
seenCursors[nextCursor] = true
|
||||
cursor = nextCursor
|
||||
}
|
||||
return false, historyVersionPaginationError("max_pages", maxPages, cursor)
|
||||
}
|
||||
|
||||
func historyVersionPaginationError(reason string, page int, cursor string) error {
|
||||
return apperrors.NewAPI(
|
||||
"文档版本预检无法证明分页已经完整,已停止回滚",
|
||||
apperrors.WithOperation("doc.history_revert"),
|
||||
apperrors.WithReason("doc_history_version_"+reason),
|
||||
apperrors.WithFailureStage("preflight"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(true),
|
||||
apperrors.WithDetails(map[string]any{"page": page, "cursor": cursor, "targetVerified": false}),
|
||||
)
|
||||
}
|
||||
|
||||
func versionEvidenceMatches(value any, target int, acceptedKeys map[string]bool) bool {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for key, child := range typed {
|
||||
normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key))
|
||||
if acceptedKeys[normalized] {
|
||||
if versionNumberMatches(child, target) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if versionEvidenceMatches(child, target, acceptedKeys) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
if versionEvidenceMatches(child, target, acceptedKeys) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func versionNumberMatches(value any, target int) bool {
|
||||
switch number := value.(type) {
|
||||
case float64:
|
||||
return number == float64(target)
|
||||
case json.Number:
|
||||
parsed, err := number.Int64()
|
||||
return err == nil && parsed == int64(target)
|
||||
case int:
|
||||
return number == target
|
||||
case string:
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(number))
|
||||
return err == nil && parsed == target
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsVersion(value any, target int) bool {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for key, child := range typed {
|
||||
normalized := strings.ToLower(strings.ReplaceAll(key, "_", ""))
|
||||
if normalized == "version" || normalized == "versionnumber" || normalized == "revision" {
|
||||
switch number := child.(type) {
|
||||
case float64:
|
||||
if int(number) == target && number == float64(target) {
|
||||
return true
|
||||
}
|
||||
case string:
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(number))
|
||||
if err == nil && parsed == target {
|
||||
return true
|
||||
}
|
||||
if versionNumberMatches(child, target) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if containsVersion(child, target) {
|
||||
@@ -167,39 +303,53 @@ var CreateFromTemplate = shortcut.Shortcut{
|
||||
Service: "doc",
|
||||
Command: "+create-from-template",
|
||||
Product: productDoc,
|
||||
Description: "按 templateId 直达或搜索消歧后创建文档",
|
||||
Intent: "当用户要基于文档模板创建新文档时使用;可直接给 template-id,或给 query 搜索且只在唯一命中时继续创建。",
|
||||
Description: "使用已选定的 templateId 创建文档",
|
||||
Intent: "当模板搜索已经唯一解析或用户明确提供 templateId 时使用;只创建一次并返回稳定 nodeId。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"},
|
||||
Contract: docContract("+create-from-template", "按 templateId 直达或搜索消歧后创建文档",
|
||||
"当用户要基于文档模板创建新文档时使用;可直接给 template-id,或给 query 搜索且只在唯一命中时继续创建。",
|
||||
[]string{`dws doc +create-from-template --template-id <TEMPLATE_ID> --name "我的周报"`, `dws doc +create-from-template --query "会议纪要" --name "项目例会"`}),
|
||||
Contract: createFromTemplateContract(),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "template-id", Type: shortcut.FlagString, Desc: "模板 ID"},
|
||||
{Name: "query", Type: shortcut.FlagString, Desc: "模板搜索名称"},
|
||||
{Name: "query", Type: shortcut.FlagString, Desc: "兼容入口:先搜索且仅唯一命中时创建;新的 Agent 流程应先调用 +template-search"},
|
||||
{Name: "source", Type: shortcut.FlagString, Desc: "模板来源", Enum: []string{"MY", "PUBLIC"}},
|
||||
{Name: "name", Type: shortcut.FlagString, Desc: "新文档名称"},
|
||||
{Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"},
|
||||
{Name: "workspace", Type: shortcut.FlagString, Desc: "目标知识库 ID"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"template-id", "query"}, Description: "--template-id 与 --query 必须且只能提供一个"}},
|
||||
Tips: []string{`dws doc +create-from-template --template-id <TEMPLATE_ID> --name "我的周报"`, `dws doc +create-from-template --query "会议纪要" --name "项目例会"`},
|
||||
Tips: []string{`dws doc +create-from-template --template-id <TEMPLATE_ID> --name "我的周报"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
templateID := rt.Str("template-id")
|
||||
if templateID == "" {
|
||||
params := map[string]any{"searchName": rt.Str("query")}
|
||||
if rt.Str("source") != "" {
|
||||
params["templateSource"] = rt.Str("source")
|
||||
}
|
||||
found, err := rt.CallMCPData(productDoc, "search_doc_templates", params)
|
||||
candidates, err := searchTemplateCandidatesForCreate(rt, rt.Str("query"), rt.Str("source"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := collectTemplateIDs(found)
|
||||
if len(ids) != 1 {
|
||||
return apperrors.NewValidation(fmt.Sprintf("模板搜索需要唯一命中,实际 %d 个候选: %v", len(ids), ids))
|
||||
if len(candidates) != 1 {
|
||||
status := "selection_required"
|
||||
message := fmt.Sprintf("模板搜索返回 %d 个候选,必须先选择唯一 templateId", len(candidates))
|
||||
actions := []string{"运行 dws doc +template-search 查看结构化候选", "让用户选择后使用 --template-id 创建一次"}
|
||||
if len(candidates) == 0 {
|
||||
status = "not_found"
|
||||
message = "模板搜索没有命中;已停止创建"
|
||||
actions = []string{"检查模板来源或请用户提供更准确的模板名", "不要自动变换关键词循环搜索"}
|
||||
}
|
||||
return apperrors.NewValidation(message,
|
||||
apperrors.WithOperation("doc.create_from_template"),
|
||||
apperrors.WithReason("template_"+status),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithActions(actions...),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"contractVersion": "doc.template-selection.v1",
|
||||
"status": status,
|
||||
"query": rt.Str("query"),
|
||||
"source": rt.Str("source"),
|
||||
"candidates": candidates,
|
||||
}),
|
||||
)
|
||||
}
|
||||
templateID = ids[0]
|
||||
templateID = candidates[0]["templateId"].(string)
|
||||
}
|
||||
params := map[string]any{"templateId": templateID}
|
||||
for flag, property := range map[string]string{"name": "name", "folder": "folderId", "workspace": "workspaceId"} {
|
||||
@@ -212,12 +362,151 @@ var CreateFromTemplate = shortcut.Shortcut{
|
||||
}
|
||||
result, err := rt.CallMCPWriteData(productDoc, "apply_doc_template", params)
|
||||
if err != nil {
|
||||
return err
|
||||
return docUnknownWriteError("doc.create_from_template", "apply_template", "", err)
|
||||
}
|
||||
return rt.Output(docEnvelope("doc.create_from_template", result, map[string]any{"name": "apply_template", "status": "success"}))
|
||||
return rt.Output(docEnvelope("doc.create_from_template", map[string]any{"templateId": templateID, "result": result}, map[string]any{"name": "apply_template", "status": "success"}))
|
||||
},
|
||||
}
|
||||
|
||||
func searchTemplateCandidatesForCreate(rt *shortcut.RuntimeContext, query, source string) ([]map[string]any, error) {
|
||||
const (
|
||||
pageSize = 2
|
||||
maxPages = 20
|
||||
)
|
||||
cursor := ""
|
||||
seenCursors := map[string]bool{}
|
||||
seenCandidates := map[string]bool{}
|
||||
candidates := make([]map[string]any, 0, pageSize)
|
||||
for page := 1; page <= maxPages; page++ {
|
||||
params := map[string]any{"searchName": query, "maxResults": pageSize}
|
||||
if source != "" {
|
||||
params["templateSource"] = source
|
||||
}
|
||||
if cursor != "" {
|
||||
params["nextCursor"] = cursor
|
||||
}
|
||||
found, err := rt.CallMCPData(productDoc, "search_doc_templates", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pageCandidates := collectTemplateCandidates(found)
|
||||
for _, candidate := range pageCandidates {
|
||||
id, _ := candidate["templateId"].(string)
|
||||
if id == "" || seenCandidates[id] {
|
||||
continue
|
||||
}
|
||||
seenCandidates[id] = true
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
|
||||
hasMore, hasMoreKnown, next := docPageState(found)
|
||||
next = strings.TrimSpace(next)
|
||||
if len(candidates) > 1 {
|
||||
return sortedTemplateCandidates(candidates), nil
|
||||
}
|
||||
complete := hasMoreKnown && !hasMore
|
||||
if !hasMoreKnown && next == "" && len(pageCandidates) < pageSize {
|
||||
complete = true
|
||||
}
|
||||
if complete {
|
||||
return sortedTemplateCandidates(candidates), nil
|
||||
}
|
||||
if next == "" {
|
||||
return nil, templatePaginationError("missing_next_cursor", page, cursor, candidates)
|
||||
}
|
||||
if next == cursor || seenCursors[next] {
|
||||
return nil, templatePaginationError("stalled_cursor", page, next, candidates)
|
||||
}
|
||||
seenCursors[next] = true
|
||||
cursor = next
|
||||
}
|
||||
return nil, templatePaginationError("max_pages", maxPages, cursor, candidates)
|
||||
}
|
||||
|
||||
func templatePaginationError(reason string, page int, cursor string, candidates []map[string]any) error {
|
||||
return apperrors.NewAPI(
|
||||
"模板搜索分页未完整结束;为避免基于非唯一候选创建文档,已停止执行",
|
||||
apperrors.WithOperation("doc.create_from_template"),
|
||||
apperrors.WithReason("template_pagination_"+reason),
|
||||
apperrors.WithFailureStage("template_search"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithActions("使用 dws doc +template-search 分页查看候选", "选择唯一 templateId 后重新执行创建"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"contractVersion": "doc.template-selection.v1",
|
||||
"status": "pagination_incomplete",
|
||||
"reason": reason,
|
||||
"page": page,
|
||||
"nextCursor": cursor,
|
||||
"candidates": sortedTemplateCandidates(candidates),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func sortedTemplateCandidates(candidates []map[string]any) []map[string]any {
|
||||
out := append([]map[string]any(nil), candidates...)
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
leftName, _ := out[i]["name"].(string)
|
||||
rightName, _ := out[j]["name"].(string)
|
||||
if leftName != rightName {
|
||||
return leftName < rightName
|
||||
}
|
||||
leftID, _ := out[i]["templateId"].(string)
|
||||
rightID, _ := out[j]["templateId"].(string)
|
||||
return leftID < rightID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func collectTemplateCandidates(value any) []map[string]any {
|
||||
seen := map[string]bool{}
|
||||
var out []map[string]any
|
||||
var walk func(any)
|
||||
walk = func(current any) {
|
||||
switch typed := current.(type) {
|
||||
case map[string]any:
|
||||
id := directTemplateString(typed, "templateId", "template_id")
|
||||
if id != "" && !seen[id] {
|
||||
seen[id] = true
|
||||
candidate := map[string]any{"templateId": id}
|
||||
if name := directTemplateString(typed, "templateName", "name", "title"); name != "" {
|
||||
candidate["name"] = name
|
||||
}
|
||||
if source := directTemplateString(typed, "templateSource", "source"); source != "" {
|
||||
candidate["source"] = source
|
||||
}
|
||||
if description := directTemplateString(typed, "description", "summary"); description != "" {
|
||||
candidate["description"] = description
|
||||
}
|
||||
out = append(out, candidate)
|
||||
}
|
||||
keys := make([]string, 0, len(typed))
|
||||
for key := range typed {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
walk(typed[key])
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(value)
|
||||
return sortedTemplateCandidates(out)
|
||||
}
|
||||
|
||||
func directTemplateString(value map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if text, ok := value[key].(string); ok && strings.TrimSpace(text) != "" {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func collectTemplateIDs(value any) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
|
||||
@@ -17,11 +17,11 @@ import (
|
||||
var MediaList = shortcut.Shortcut{
|
||||
Service: "doc", Command: "+media-list", Product: productDoc,
|
||||
Description: "列出文档正文中的图片和附件资源",
|
||||
Intent: "当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;只读取并投影媒体节点。",
|
||||
Intent: "当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;先取得稳定 ID,再交给 +media-download,禁止提取临时 URL 后 curl。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: docContract("+media-list", "列出文档正文中的图片和附件资源",
|
||||
"当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;只读取并投影媒体节点。",
|
||||
"当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;先取得稳定 ID,再交给 +media-download,禁止提取临时 URL 后 curl。",
|
||||
[]string{`dws doc +media-list --node <DOC_ID>`}),
|
||||
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}},
|
||||
Tips: []string{`dws doc +media-list --node <DOC_ID>`},
|
||||
@@ -31,40 +31,42 @@ var MediaList = shortcut.Shortcut{
|
||||
return err
|
||||
}
|
||||
items := collectMediaItems(data)
|
||||
return rt.Output(map[string]any{"count": len(items), "media": items})
|
||||
return rt.Output(docEnvelope("doc.media_list", map[string]any{"nodeId": rt.Str("node"), "count": len(items), "media": items}))
|
||||
},
|
||||
}
|
||||
|
||||
var MediaInsert = shortcut.Shortcut{
|
||||
Service: "doc", Command: "+media-insert", Product: productDoc,
|
||||
Description: "上传本地图片或文件并插入文档正文",
|
||||
Intent: "当用户要把本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败时不会伪造完整回滚。",
|
||||
Intent: "当用户要把工作目录内的本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败后保留稳定 ID,禁止改走手写 HTTP。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
||||
Contract: withDryRun(docContract("+media-insert", "上传本地图片或文件并插入文档正文",
|
||||
"当用户要把本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败时不会伪造完整回滚。",
|
||||
"当用户要把工作目录内的本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败后保留稳定 ID,禁止改走手写 HTTP。",
|
||||
[]string{`dws doc +media-insert --node <DOC_ID> --file ./report.pdf`, `dws doc +media-insert --node <DOC_ID> --file ./image.png --ref-block <BLOCK_ID> --where after`}), contract.DryRunPreviewPlan, false),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
|
||||
{Name: "file", Type: shortcut.FlagString, Desc: "本地文件路径", Required: true},
|
||||
{Name: "file", Type: shortcut.FlagString, Desc: "工作目录内已存在的相对文件路径", Required: true},
|
||||
{Name: "name", Type: shortcut.FlagString, Desc: "显示名称"},
|
||||
{Name: "mime-type", Type: shortcut.FlagString, Desc: "MIME 类型"},
|
||||
{Name: "index", Type: shortcut.FlagInt, Desc: "顶层插入索引"},
|
||||
{Name: "where", Type: shortcut.FlagString, Desc: "相对参考块的位置", Enum: []string{"before", "after"}},
|
||||
{Name: "ref-block", Type: shortcut.FlagString, Desc: "参考 block ID"},
|
||||
},
|
||||
Tips: []string{`dws doc +media-insert --node <DOC_ID> --file ./report.pdf`, `dws doc +media-insert --node <DOC_ID> --file ./image.png --ref-block <BLOCK_ID> --where after`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocMediaInsertShortcut(rt.Command()) },
|
||||
Validate: func(rt *shortcut.RuntimeContext) error { return validateWorkspaceInputPath("file", rt.Str("file")) },
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"file"}, Description: "--file 必须是工作目录内存在且不能经符号链接逃逸的相对文件"}},
|
||||
Tips: []string{`dws doc +media-insert --node <DOC_ID> --file ./report.pdf`, `dws doc +media-insert --node <DOC_ID> --file ./image.png --ref-block <BLOCK_ID> --where after`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocMediaInsertShortcut(rt.Command()) },
|
||||
}
|
||||
|
||||
var MediaDownload = shortcut.Shortcut{
|
||||
Service: "doc", Command: "+media-download", Product: productDoc,
|
||||
Description: "安全下载文档正文附件到工作目录",
|
||||
Intent: "当用户已从 media-list 或 block 数据拿到 resourceId,要把正文附件保存到本地时使用;默认拒绝覆盖并原子发布文件。",
|
||||
Intent: "当用户已从 +media-list 拿到真实 resourceId,要把正文附件保存到工作目录时使用;CLI 内部换取临时链接并原子下载,默认拒绝覆盖。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: docContract("+media-download", "安全下载文档正文附件到工作目录",
|
||||
"当用户已从 media-list 或 block 数据拿到 resourceId,要把正文附件保存到本地时使用;默认拒绝覆盖并原子发布文件。",
|
||||
"当用户已从 +media-list 拿到真实 resourceId,要把正文附件保存到工作目录时使用;CLI 内部换取临时链接并原子下载,默认拒绝覆盖。",
|
||||
[]string{`dws doc +media-download --node <DOC_ID> --resource-id <RESOURCE_ID> --output ./downloads/`}),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
|
||||
@@ -97,7 +99,7 @@ var MediaPreview = shortcut.Shortcut{
|
||||
}
|
||||
data, err := rt.CallMCPData(productDoc, "download_doc_attachment", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id")})
|
||||
if err != nil {
|
||||
return err
|
||||
return docMediaRecoveryError("doc_media_resolve_failed", "resolve_download", rt.Str("node"), rt.Str("resource-id"), err)
|
||||
}
|
||||
dir, err := docMkdirTemp("", "dws-doc-preview-*")
|
||||
if err != nil {
|
||||
@@ -106,9 +108,13 @@ var MediaPreview = shortcut.Shortcut{
|
||||
result, err := downloadResolvedResource(rt, data, dir, ".")
|
||||
if err != nil {
|
||||
_ = docRemoveAll(dir)
|
||||
return err
|
||||
return docMediaRecoveryError("doc_media_preview_failed", "download", rt.Str("node"), rt.Str("resource-id"), err)
|
||||
}
|
||||
return rt.Output(docEnvelope("doc.media_preview", map[string]any{"previewPath": result.AbsolutePath, "sizeBytes": result.SizeBytes}))
|
||||
if result.SizeBytes <= 0 {
|
||||
_ = docRemoveAll(dir)
|
||||
return docMediaRecoveryError("doc_media_empty_download", "verify", rt.Str("node"), rt.Str("resource-id"), fmt.Errorf("下载结果为空"))
|
||||
}
|
||||
return rt.Output(docEnvelope("doc.media_preview", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id"), "previewPath": result.AbsolutePath, "sizeBytes": result.SizeBytes, "verified": true}))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -124,11 +130,20 @@ var ResourceUpdate = shortcut.Shortcut{
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
|
||||
{Name: "image", Type: shortcut.FlagString, Desc: "HTTPS 封面图片 URL"},
|
||||
{Name: "file", Type: shortcut.FlagString, Desc: "本地封面图片"},
|
||||
{Name: "file", Type: shortcut.FlagString, Desc: "工作目录内已存在封面图片的相对路径"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"image", "file"}, Description: "--image 与 --file 必须且只能提供一个"}},
|
||||
Tips: []string{`dws doc +resource-update --node <DOC_ID> --image https://example.com/cover.png`, `dws doc +resource-update --node <DOC_ID> --file ./cover.png`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocResourceUpdateShortcut(rt.Command()) },
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Str("file") == "" {
|
||||
return nil
|
||||
}
|
||||
return validateWorkspaceInputPath("file", rt.Str("file"))
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"image", "file"}, Description: "--image 与 --file 必须且只能提供一个"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"file"}, Description: "提供 --file 时必须是工作目录内已存在且不通过符号链接逃逸的相对路径"},
|
||||
},
|
||||
Tips: []string{`dws doc +resource-update --node <DOC_ID> --image https://example.com/cover.png`, `dws doc +resource-update --node <DOC_ID> --file ./cover.png`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocResourceUpdateShortcut(rt.Command()) },
|
||||
}
|
||||
|
||||
var ResourceDownload = shortcut.Shortcut{
|
||||
@@ -225,7 +240,7 @@ func executeMediaDownload(rt *shortcut.RuntimeContext) error {
|
||||
}
|
||||
data, err := rt.CallMCPData(productDoc, "download_doc_attachment", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id")})
|
||||
if err != nil {
|
||||
return err
|
||||
return docMediaRecoveryError("doc_media_resolve_failed", "resolve_download", rt.Str("node"), rt.Str("resource-id"), err)
|
||||
}
|
||||
cwd, err := docGetwd()
|
||||
if err != nil {
|
||||
@@ -233,9 +248,35 @@ func executeMediaDownload(rt *shortcut.RuntimeContext) error {
|
||||
}
|
||||
result, err := downloadResolvedResource(rt, data, cwd, rt.Str("output"))
|
||||
if err != nil {
|
||||
return err
|
||||
return docMediaRecoveryError("doc_media_download_failed", "download", rt.Str("node"), rt.Str("resource-id"), err)
|
||||
}
|
||||
return rt.Output(docEnvelope("doc.media_download", map[string]any{"resourceId": rt.Str("resource-id"), "localPath": result.RelativePath, "sizeBytes": result.SizeBytes}))
|
||||
if result.SizeBytes <= 0 {
|
||||
return docMediaRecoveryError("doc_media_empty_download", "verify", rt.Str("node"), rt.Str("resource-id"), fmt.Errorf("下载结果为空"))
|
||||
}
|
||||
return rt.Output(docEnvelope("doc.media_download", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id"), "localPath": result.RelativePath, "sizeBytes": result.SizeBytes, "verified": true}))
|
||||
}
|
||||
|
||||
func docMediaRecoveryError(reason, stage, nodeID, resourceID string, cause error) error {
|
||||
return apperrors.NewAPI(
|
||||
"文档媒体操作未完成;已保留 nodeId/resourceId,请稍后重试同一 shortcut,禁止改用临时 URL 或手写 HTTP",
|
||||
apperrors.WithOperation("doc.media_download"),
|
||||
apperrors.WithReason(reason),
|
||||
apperrors.WithFailureStage(stage),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(true),
|
||||
apperrors.WithActions(
|
||||
fmt.Sprintf("dws doc +media-download --node %s --resource-id %s --output <工作目录相对路径>", nodeID, resourceID),
|
||||
"不要 curl/wget 临时 OSS URL,不要安装本地下载或文档转换依赖",
|
||||
),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"contractVersion": "doc.operation.v1",
|
||||
"status": "incomplete",
|
||||
"nodeId": nodeID,
|
||||
"resourceId": resourceID,
|
||||
"stage": stage,
|
||||
}),
|
||||
apperrors.WithCause(cause),
|
||||
)
|
||||
}
|
||||
|
||||
func executeResourceDownload(rt *shortcut.RuntimeContext) error {
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
type docPageOptions struct {
|
||||
PageAll bool
|
||||
PageSize int
|
||||
MaxPages int
|
||||
MaxItems int
|
||||
Cursor string
|
||||
}
|
||||
|
||||
func collectDocPages(
|
||||
rt *shortcut.RuntimeContext,
|
||||
tool, outputKey string,
|
||||
base map[string]any,
|
||||
project func(map[string]any) []map[string]any,
|
||||
options docPageOptions,
|
||||
) (map[string]any, error) {
|
||||
if options.PageSize <= 0 {
|
||||
options.PageSize = 30
|
||||
}
|
||||
if options.MaxPages <= 0 {
|
||||
options.MaxPages = 20
|
||||
}
|
||||
if options.MaxItems <= 0 {
|
||||
options.MaxItems = 500
|
||||
}
|
||||
pageLimit := 1
|
||||
if options.PageAll {
|
||||
pageLimit = options.MaxPages
|
||||
}
|
||||
|
||||
items := make([]map[string]any, 0)
|
||||
seenItems := map[string]bool{}
|
||||
seenCursors := map[string]bool{}
|
||||
cursor := strings.TrimSpace(options.Cursor)
|
||||
if cursor != "" {
|
||||
seenCursors[cursor] = true
|
||||
}
|
||||
complete := false
|
||||
truncated := false
|
||||
stopReason := ""
|
||||
nextCursor := ""
|
||||
hasMore := false
|
||||
pagesRead := 0
|
||||
|
||||
for page := 1; page <= pageLimit; page++ {
|
||||
remaining := options.MaxItems - len(items)
|
||||
requestPageSize := options.PageSize
|
||||
if requestPageSize > remaining {
|
||||
requestPageSize = remaining
|
||||
}
|
||||
params := cloneMap(base)
|
||||
params["pageSize"] = requestPageSize
|
||||
if cursor != "" {
|
||||
params["pageToken"] = cursor
|
||||
}
|
||||
data, err := rt.CallMCPData(productDoc, tool, params)
|
||||
if err != nil {
|
||||
return nil, docPaginationError(tool, "page_read_failed", err, page, items, cursor)
|
||||
}
|
||||
pagesRead++
|
||||
projected := project(data)
|
||||
pageItems := make([]map[string]any, 0, len(projected))
|
||||
pageSeen := map[string]bool{}
|
||||
for _, item := range projected {
|
||||
key := pageItemKey(item)
|
||||
if key != "" && (seenItems[key] || pageSeen[key]) {
|
||||
continue
|
||||
}
|
||||
if key != "" {
|
||||
pageSeen[key] = true
|
||||
}
|
||||
pageItems = append(pageItems, item)
|
||||
}
|
||||
if len(pageItems) > remaining {
|
||||
return nil, docPaginationError(tool, "page_size_exceeded", nil, page, items, cursor)
|
||||
}
|
||||
for _, item := range pageItems {
|
||||
if key := pageItemKey(item); key != "" {
|
||||
seenItems[key] = true
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
pageHasMore, hasMoreKnown, next := docPageState(data)
|
||||
nextCursor = strings.TrimSpace(next)
|
||||
switch {
|
||||
case hasMoreKnown:
|
||||
hasMore = pageHasMore
|
||||
complete = !pageHasMore
|
||||
case nextCursor != "":
|
||||
hasMore = true
|
||||
case len(projected) < requestPageSize:
|
||||
complete = true
|
||||
default:
|
||||
hasMore = true
|
||||
stopReason = "pagination_unproven"
|
||||
}
|
||||
if len(items) >= options.MaxItems && hasMore {
|
||||
truncated = true
|
||||
stopReason = "max_items"
|
||||
}
|
||||
if truncated {
|
||||
complete = false
|
||||
hasMore = true
|
||||
}
|
||||
|
||||
if truncated || complete || !options.PageAll {
|
||||
break
|
||||
}
|
||||
if stopReason == "pagination_unproven" {
|
||||
return nil, docPaginationError(tool, stopReason, nil, page, items, cursor)
|
||||
}
|
||||
if nextCursor == "" {
|
||||
return nil, docPaginationError(tool, "missing_next_cursor", nil, page, items, cursor)
|
||||
}
|
||||
if seenCursors[nextCursor] {
|
||||
return nil, docPaginationError(tool, "stalled_cursor", nil, page, items, nextCursor)
|
||||
}
|
||||
seenCursors[nextCursor] = true
|
||||
cursor = nextCursor
|
||||
if page == pageLimit {
|
||||
truncated = true
|
||||
stopReason = "max_pages"
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"contractVersion": "doc.list.v1",
|
||||
"status": "success",
|
||||
"count": len(items),
|
||||
outputKey: items,
|
||||
"pagesRead": pagesRead,
|
||||
"complete": complete,
|
||||
"truncated": truncated,
|
||||
"hasMore": hasMore,
|
||||
"nextCursor": nextCursor,
|
||||
"stopReason": stopReason,
|
||||
"failures": []map[string]any{},
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func docPaginationError(tool, reason string, cause error, page int, items []map[string]any, cursor string) error {
|
||||
message := fmt.Sprintf("%s 分页未完成,已在第 %d 页停止", tool, page)
|
||||
return apperrors.NewAPI(
|
||||
message,
|
||||
apperrors.WithOperation("doc/"+tool),
|
||||
apperrors.WithReason("doc_pagination_"+reason),
|
||||
apperrors.WithFailureStage("pagination"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(cause != nil),
|
||||
apperrors.WithActions("保留 nextCursor 后继续读取", "缩小查询范围后重试"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"contractVersion": "doc.list.v1",
|
||||
"status": "partial_success",
|
||||
"complete": false,
|
||||
"reason": reason,
|
||||
"page": page,
|
||||
"nextCursor": cursor,
|
||||
"count": len(items),
|
||||
"items": items,
|
||||
}),
|
||||
apperrors.WithCause(cause),
|
||||
)
|
||||
}
|
||||
|
||||
func cloneMap(source map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(source)+2)
|
||||
for key, value := range source {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pageItemKey(item map[string]any) string {
|
||||
for _, key := range []string{"nodeId", "id", "url"} {
|
||||
if value, ok := item[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return key + ":" + strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func docPageState(data map[string]any) (bool, bool, string) {
|
||||
var hasMore bool
|
||||
var known bool
|
||||
var next string
|
||||
var walk func(map[string]any)
|
||||
walk = func(value map[string]any) {
|
||||
if !known {
|
||||
for _, key := range []string{"hasMore", "has_more"} {
|
||||
if flag, ok := value[key].(bool); ok {
|
||||
hasMore, known = flag, true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if next == "" {
|
||||
if value, ok := docFirst(value, "nextPageToken", "nextCursor", "next_page_token"); ok {
|
||||
next, _ = value.(string)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"result", "data", "page", "pagination"} {
|
||||
if nested, ok := value[key].(map[string]any); ok {
|
||||
walk(nested)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(data)
|
||||
return hasMore, known, next
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageDocSearchPageAllContract(t *testing.T) {
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"search_documents": {
|
||||
{"documents": []any{map[string]any{"nodeId": "a", "name": "A"}}, "hasMore": true, "nextPageToken": "p2"},
|
||||
{"documents": []any{map[string]any{"nodeId": "b", "name": "B"}, map[string]any{"nodeId": "a", "name": "A"}}, "hasMore": false},
|
||||
},
|
||||
}}
|
||||
if err := runDocCoverage(t, Search, caller, "--query", "report", "--page-all", "--limit", "2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.history) != 2 || caller.history[1].params["pageToken"] != "p2" {
|
||||
t.Fatalf("pagination calls = %#v", caller.history)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocPaginationFailsClosedOnStalledCursor(t *testing.T) {
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"list_nodes": {
|
||||
{"nodes": []any{map[string]any{"nodeId": "a"}}, "hasMore": true, "nextPageToken": "p2"},
|
||||
{"nodes": []any{map[string]any{"nodeId": "b"}}, "hasMore": true, "nextPageToken": "p2"},
|
||||
},
|
||||
}}
|
||||
err := runDocCoverage(t, List, caller, "--folder", "f", "--page-all", "--limit", "1")
|
||||
if err == nil || len(caller.history) != 2 {
|
||||
t.Fatalf("stalled pagination err=%v history=%#v", err, caller.history)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocPaginationMaxItemsStopsAtPageBoundary(t *testing.T) {
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"search_documents": {
|
||||
{"documents": []any{map[string]any{"nodeId": "a"}, map[string]any{"nodeId": "b"}}, "hasMore": true, "nextPageToken": "p2"},
|
||||
{"documents": []any{map[string]any{"nodeId": "c"}}, "hasMore": true, "nextPageToken": "p3"},
|
||||
},
|
||||
}}
|
||||
if err := runDocCoverage(t, Search, caller, "--query", "report", "--page-all", "--limit", "2", "--max-items", "3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.history) != 2 || caller.history[1].params["pageSize"] != 1 || caller.history[1].params["pageToken"] != "p2" {
|
||||
t.Fatalf("max-items pagination calls = %#v", caller.history)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocPaginationRejectsServerPageOverflow(t *testing.T) {
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"search_documents": {{
|
||||
"documents": []any{map[string]any{"nodeId": "a"}, map[string]any{"nodeId": "b"}},
|
||||
"hasMore": true, "nextPageToken": "p2",
|
||||
}},
|
||||
}}
|
||||
err := runDocCoverage(t, Search, caller, "--query", "report", "--page-all", "--limit", "2", "--max-items", "1")
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "doc_pagination_page_size_exceeded" {
|
||||
t.Fatalf("page overflow error = %#v", err)
|
||||
}
|
||||
if len(caller.history) != 1 || caller.history[0].params["pageSize"] != 1 {
|
||||
t.Fatalf("page overflow calls = %#v", caller.history)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocPaginationFinalBranchMatrix(t *testing.T) {
|
||||
run := func(t *testing.T, caller *docCoverageCaller, options docPageOptions) (map[string]any, error) {
|
||||
t.Helper()
|
||||
declaration := Search
|
||||
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
|
||||
result, err := collectDocPages(rt, "search_documents", "documents", map[string]any{"base": true}, searchDocsProject, options)
|
||||
if err == nil {
|
||||
err = rt.Output(result)
|
||||
}
|
||||
return err
|
||||
}
|
||||
err := runDocCoverage(t, declaration, caller)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defaults := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"search_documents": {{"documents": []any{map[string]any{"url": "u"}}}},
|
||||
}}
|
||||
if _, err := run(t, defaults, docPageOptions{Cursor: " initial "}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(defaults.history) != 1 || defaults.history[0].params["pageSize"] != 30 || defaults.history[0].params["pageToken"] != "initial" {
|
||||
t.Fatalf("default pagination call = %#v", defaults.history)
|
||||
}
|
||||
|
||||
readFailure := &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}
|
||||
if _, err := run(t, readFailure, docPageOptions{PageAll: true}); err == nil {
|
||||
t.Fatal("page read failure succeeded")
|
||||
}
|
||||
|
||||
duplicate := &docCoverageCaller{responses: map[string][]map[string]any{
|
||||
"search_documents": {{
|
||||
"documents": []any{
|
||||
map[string]any{"nodeId": "a"}, map[string]any{"nodeId": "a"}, map[string]any{"name": "no-key"},
|
||||
},
|
||||
"nextPageToken": "p2",
|
||||
}},
|
||||
}}
|
||||
if _, err := run(t, duplicate, docPageOptions{PageAll: false, PageSize: 3, MaxPages: 2, MaxItems: 10}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
response map[string]any
|
||||
maxPages int
|
||||
wantError bool
|
||||
}{
|
||||
{"unproven", map[string]any{"documents": []any{map[string]any{"nodeId": "a"}}}, 2, true},
|
||||
{"missing cursor", map[string]any{"documents": []any{}, "hasMore": true}, 2, true},
|
||||
{"max pages", map[string]any{"documents": []any{}, "hasMore": true, "nextPageToken": "p2"}, 1, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &docCoverageCaller{responses: map[string][]map[string]any{"search_documents": {tc.response}}}
|
||||
_, err := run(t, caller, docPageOptions{PageAll: true, PageSize: 1, MaxPages: tc.maxPages, MaxItems: 10})
|
||||
if (err != nil) != tc.wantError {
|
||||
t.Fatalf("err=%v wantError=%v", err, tc.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if got := pageItemKey(map[string]any{"url": " u "}); got != "url:u" {
|
||||
t.Fatalf("URL page key = %q", got)
|
||||
}
|
||||
if got := pageItemKey(map[string]any{"id": 1}); got != "" {
|
||||
t.Fatalf("invalid page key = %q", got)
|
||||
}
|
||||
if more, known, next := docPageState(map[string]any{"data": map[string]any{"has_more": true, "nextCursor": "c"}}); !more || !known || next != "c" {
|
||||
t.Fatalf("nested page state = %v/%v/%q", more, known, next)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
package doc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf16"
|
||||
@@ -60,13 +61,17 @@ var CommentUpdate = shortcut.Shortcut{
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
|
||||
{Name: "comment-key", Type: shortcut.FlagString, Desc: "评论 commentKey", Required: true},
|
||||
{Name: "content", Type: shortcut.FlagString, Desc: "更新后的评论正文", Required: true},
|
||||
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
|
||||
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid,多个值用逗号分隔;不要传 JSON 数组"},
|
||||
},
|
||||
Tips: []string{`dws doc +comment-update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"nodeId": rt.Str("node"), "commentKey": rt.Str("comment-key"), "content": rt.Str("content")}
|
||||
if rt.Changed("mention") {
|
||||
params["mentionedUserIds"] = rt.StrSlice("mention")
|
||||
mentions, err := normalizeMentionUserIDs(rt.StrSlice("mention"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["mentionedUserIds"] = mentions
|
||||
}
|
||||
return rt.CallMCP("update_comment", params)
|
||||
},
|
||||
@@ -113,14 +118,29 @@ func validateCommentCreate(rt *shortcut.RuntimeContext) error {
|
||||
func executeCommentCreate(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"nodeId": rt.Str("node"), "content": rt.Str("content")}
|
||||
if rt.Changed("mention") {
|
||||
params["mentionedUserIds"] = rt.StrSlice("mention")
|
||||
mentions, err := normalizeMentionUserIDs(rt.StrSlice("mention"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["mentionedUserIds"] = mentions
|
||||
}
|
||||
if rt.Str("block-id") != "" {
|
||||
params["blockId"], params["start"], params["end"] = rt.Str("block-id"), rt.Int("start"), rt.Int("end")
|
||||
if rt.Str("selected-text") != "" {
|
||||
params["selectedText"] = rt.Str("selected-text")
|
||||
blockID, start, end := rt.Str("block-id"), rt.Int("start"), rt.Int("end")
|
||||
blocks, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": rt.Str("node"), "blockId": blockID, "format": "element"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.CallMCP("create_inline_comment", params)
|
||||
blockText := map[string]string{}
|
||||
collectBlockText(blocks, "", blockText)
|
||||
selectedText, ok := sliceUTF16Range(blockText[blockID], start, end)
|
||||
if !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("INVALID_SELECTION_RANGE: block %s 的 UTF-16 范围 [%d,%d) 无效", blockID, start, end))
|
||||
}
|
||||
if supplied := rt.Str("selected-text"); supplied != "" && supplied != selectedText {
|
||||
return apperrors.NewValidation("SELECTED_TEXT_MISMATCH: --selected-text 与 block/start/end 对应的真实原文不一致")
|
||||
}
|
||||
params["blockId"], params["start"], params["end"], params["selectedText"] = blockID, start, end, selectedText
|
||||
return executeCommentWrite(rt, "create_inline_comment", "inline", params)
|
||||
}
|
||||
if selection := rt.Str("selection"); selection != "" {
|
||||
blocks, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": rt.Str("node"), "format": "element"})
|
||||
@@ -136,9 +156,62 @@ func executeCommentCreate(rt *shortcut.RuntimeContext) error {
|
||||
return apperrors.NewValidation(fmt.Sprintf("AMBIGUOUS_SELECTION: selection 需要唯一匹配,实际 %d 处;候选=%v", len(matches), candidates))
|
||||
}
|
||||
params["blockId"], params["start"], params["end"], params["selectedText"] = matches[0].blockID, matches[0].start, matches[0].end, matches[0].selected
|
||||
return rt.CallMCP("create_inline_comment", params)
|
||||
return executeCommentWrite(rt, "create_inline_comment", "inline", params)
|
||||
}
|
||||
return rt.CallMCP("create_comment", params)
|
||||
return executeCommentWrite(rt, "create_comment", "global", params)
|
||||
}
|
||||
|
||||
func normalizeMentionUserIDs(values []string) ([]string, error) {
|
||||
if len(values) == 1 && strings.HasPrefix(strings.TrimSpace(values[0]), "[") {
|
||||
var decoded []any
|
||||
decoder := json.NewDecoder(strings.NewReader(values[0]))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&decoded); err != nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--mention JSON 数组解析失败: %v", err))
|
||||
}
|
||||
values = make([]string, 0, len(decoded))
|
||||
for _, value := range decoded {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
values = append(values, typed)
|
||||
case json.Number:
|
||||
values = append(values, typed.String())
|
||||
default:
|
||||
return nil, apperrors.NewValidation("--mention JSON 数组只接受字符串或数字 uid")
|
||||
}
|
||||
}
|
||||
}
|
||||
values = stringSliceNonEmpty(values)
|
||||
if len(values) == 0 {
|
||||
return nil, apperrors.NewValidation("--mention 至少包含一个非空用户 uid")
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func executeCommentWrite(rt *shortcut.RuntimeContext, tool, commentType string, params map[string]any) error {
|
||||
result, err := rt.CallMCPWriteData(productComment, tool, params)
|
||||
if err != nil {
|
||||
return docUnknownWriteError("doc.comment_create", tool, rt.Str("node"), err)
|
||||
}
|
||||
return rt.Output(docEnvelope("doc.comment_create", map[string]any{
|
||||
"nodeId": rt.Str("node"), "commentType": commentType, "result": result,
|
||||
}, map[string]any{"name": tool, "status": "success"}))
|
||||
}
|
||||
|
||||
func sliceUTF16Range(text string, start, end int) (string, bool) {
|
||||
units := utf16.Encode([]rune(text))
|
||||
if start < 0 || end <= start || end > len(units) {
|
||||
return "", false
|
||||
}
|
||||
isHigh := func(value uint16) bool { return value >= 0xD800 && value <= 0xDBFF }
|
||||
isLow := func(value uint16) bool { return value >= 0xDC00 && value <= 0xDFFF }
|
||||
if start > 0 && start < len(units) && isHigh(units[start-1]) && isLow(units[start]) {
|
||||
return "", false
|
||||
}
|
||||
if end > 0 && end < len(units) && isHigh(units[end-1]) && isLow(units[end]) {
|
||||
return "", false
|
||||
}
|
||||
return string(utf16.Decode(units[start:end])), true
|
||||
}
|
||||
|
||||
type selectionMatch struct {
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package docresolver resolves document IDs, URLs, and natural titles to one
|
||||
// typed document target before a shortcut performs a business operation.
|
||||
package docresolver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
searchPageSize = 30
|
||||
searchPageLimit = 40
|
||||
)
|
||||
|
||||
// Reader is the read-only RuntimeContext surface needed by the resolver.
|
||||
type Reader interface {
|
||||
CallMCPData(product, tool string, params map[string]any) (map[string]any, error)
|
||||
}
|
||||
|
||||
// Candidate is a credential-free document search candidate.
|
||||
type Candidate struct {
|
||||
CanonicalID string `json:"canonicalId"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Product string `json:"product"`
|
||||
ResourceType string `json:"resourceType,omitempty"`
|
||||
URL string `json:"canonicalUrl,omitempty"`
|
||||
ContainerID string `json:"containerId,omitempty"`
|
||||
}
|
||||
|
||||
// Resolution is the stable result consumed by document shortcuts.
|
||||
type Resolution struct {
|
||||
ContractVersion string `json:"contractVersion"`
|
||||
Status string `json:"status"`
|
||||
Query string `json:"query,omitempty"`
|
||||
MatchedBy string `json:"matchedBy"`
|
||||
Complete bool `json:"complete"`
|
||||
Selected Candidate `json:"selected"`
|
||||
Candidates []Candidate `json:"candidates"`
|
||||
}
|
||||
|
||||
// Resolve accepts exactly one stable node/URL or natural title. Natural title
|
||||
// resolution exhausts pagination before selecting a unique candidate.
|
||||
func Resolve(rt Reader, node, query string) (Resolution, error) {
|
||||
node = strings.TrimSpace(node)
|
||||
query = strings.TrimSpace(query)
|
||||
if (node == "") == (query == "") {
|
||||
return Resolution{}, apperrors.NewValidation(
|
||||
"文档目标必须且只能提供一个:--node 或 --query",
|
||||
apperrors.WithReason("document_target_invalid"),
|
||||
apperrors.WithFailureStage("target_resolution"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
)
|
||||
}
|
||||
if node != "" {
|
||||
return Resolution{
|
||||
ContractVersion: "doc.target.v1",
|
||||
Status: "resolved",
|
||||
MatchedBy: "stable_id_or_url",
|
||||
Complete: true,
|
||||
Selected: Candidate{
|
||||
CanonicalID: node,
|
||||
Product: "doc",
|
||||
},
|
||||
Candidates: []Candidate{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
candidates, err := searchAll(rt, query)
|
||||
if err != nil {
|
||||
return Resolution{}, err
|
||||
}
|
||||
selected, matchedBy := selectCandidates(candidates, query)
|
||||
if len(selected) == 0 {
|
||||
return Resolution{}, resolutionError("not_found", query, candidates, "没有找到匹配的在线文字文档")
|
||||
}
|
||||
if len(selected) > 1 {
|
||||
return Resolution{}, resolutionError("ambiguous", query, selected, "找到多个匹配文档,请使用 nodeId 或 URL 消歧")
|
||||
}
|
||||
if typ := normalizeType(selected[0].ResourceType); typ != "" && typ != "adoc" && typ != "document" && typ != "doc" {
|
||||
return Resolution{}, apperrors.NewValidation(
|
||||
fmt.Sprintf("目标 %q 的资源类型为 %s,不是在线文字文档", selected[0].Name, selected[0].ResourceType),
|
||||
apperrors.WithReason("document_target_type_mismatch"),
|
||||
apperrors.WithFailureStage("target_resolution"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithDetails(map[string]any{"status": "type_mismatch", "target": selected[0]}),
|
||||
)
|
||||
}
|
||||
return Resolution{
|
||||
ContractVersion: "doc.target.v1",
|
||||
Status: "resolved",
|
||||
Query: query,
|
||||
MatchedBy: matchedBy,
|
||||
Complete: true,
|
||||
Selected: selected[0],
|
||||
Candidates: []Candidate{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func searchAll(rt Reader, query string) ([]Candidate, error) {
|
||||
cursor := ""
|
||||
seenCursors := map[string]bool{}
|
||||
all := make([]Candidate, 0)
|
||||
for page := 1; page <= searchPageLimit; page++ {
|
||||
params := map[string]any{"keyword": query, "pageSize": searchPageSize}
|
||||
if cursor != "" {
|
||||
params["pageToken"] = cursor
|
||||
}
|
||||
data, err := rt.CallMCPData("doc", "search_documents", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := extractRows(data)
|
||||
all = append(all, extractCandidates(rows)...)
|
||||
hasMore, hasMoreKnown, next := extractPage(data)
|
||||
switch {
|
||||
case hasMoreKnown && !hasMore:
|
||||
return dedupe(all), nil
|
||||
case hasMoreKnown && hasMore:
|
||||
// next is validated below.
|
||||
case next != "":
|
||||
// Older responses may only publish the continuation.
|
||||
case len(rows) < searchPageSize:
|
||||
return dedupe(all), nil
|
||||
default:
|
||||
return nil, incompleteError(query, all, "搜索返回满页结果但没有结束标记或下一页游标")
|
||||
}
|
||||
next = strings.TrimSpace(next)
|
||||
if next == "" {
|
||||
return nil, incompleteError(query, all, "搜索声明仍有更多结果但缺少下一页游标")
|
||||
}
|
||||
if next == cursor || seenCursors[next] {
|
||||
return nil, incompleteError(query, all, fmt.Sprintf("搜索分页游标停滞在 %q", next))
|
||||
}
|
||||
seenCursors[next] = true
|
||||
cursor = next
|
||||
}
|
||||
return nil, incompleteError(query, all, fmt.Sprintf("搜索超过最大页数 %d", searchPageLimit))
|
||||
}
|
||||
|
||||
func selectCandidates(candidates []Candidate, query string) ([]Candidate, string) {
|
||||
exact := make([]Candidate, 0)
|
||||
for _, candidate := range candidates {
|
||||
if strings.EqualFold(strings.TrimSpace(candidate.Name), query) {
|
||||
exact = append(exact, candidate)
|
||||
}
|
||||
}
|
||||
if len(exact) > 0 {
|
||||
return exact, "exact_title"
|
||||
}
|
||||
return candidates, "search_candidate"
|
||||
}
|
||||
|
||||
func extractCandidates(rows []any) []Candidate {
|
||||
out := make([]Candidate, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
item, ok := row.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
candidate := Candidate{
|
||||
CanonicalID: firstString(item, "nodeId", "node_id", "docId", "doc_id", "id"),
|
||||
Name: firstString(item, "name", "title", "docName", "fileName"),
|
||||
Product: "doc",
|
||||
ResourceType: firstString(item, "docType", "doc_type", "extension", "fileType", "type"),
|
||||
URL: firstString(item, "url", "docUrl", "nodeUrl", "webUrl"),
|
||||
ContainerID: firstString(item, "workspaceId", "spaceId", "folderId", "parentId"),
|
||||
}
|
||||
if candidate.CanonicalID != "" {
|
||||
out = append(out, candidate)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func dedupe(in []Candidate) []Candidate {
|
||||
seen := make(map[string]bool, len(in))
|
||||
out := make([]Candidate, 0, len(in))
|
||||
for _, candidate := range in {
|
||||
if candidate.CanonicalID == "" || seen[candidate.CanonicalID] {
|
||||
continue
|
||||
}
|
||||
seen[candidate.CanonicalID] = true
|
||||
out = append(out, candidate)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func extractRows(data map[string]any) []any {
|
||||
for _, key := range []string{"documents", "nodes", "items", "list", "records", "result", "data"} {
|
||||
value, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if rows, ok := value.([]any); ok {
|
||||
return rows
|
||||
}
|
||||
if nested, ok := value.(map[string]any); ok {
|
||||
if rows := extractRows(nested); rows != nil {
|
||||
return rows
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
func extractPage(data map[string]any) (bool, bool, string) {
|
||||
var hasMore bool
|
||||
var hasMoreKnown bool
|
||||
var next string
|
||||
var walk func(map[string]any)
|
||||
walk = func(value map[string]any) {
|
||||
if !hasMoreKnown {
|
||||
for _, key := range []string{"hasMore", "has_more"} {
|
||||
if flag, ok := value[key].(bool); ok {
|
||||
hasMore, hasMoreKnown = flag, true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if next == "" {
|
||||
next = firstString(value, "nextPageToken", "nextCursor", "next_page_token")
|
||||
}
|
||||
for _, key := range []string{"result", "data", "page", "pagination"} {
|
||||
if nested, ok := value[key].(map[string]any); ok {
|
||||
walk(nested)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(data)
|
||||
return hasMore, hasMoreKnown, next
|
||||
}
|
||||
|
||||
func firstString(value map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if text, ok := value[key].(string); ok && strings.TrimSpace(text) != "" {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeType(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(strings.TrimPrefix(value, ".")))
|
||||
}
|
||||
|
||||
func resolutionError(status, query string, candidates []Candidate, message string) error {
|
||||
return apperrors.NewValidation(
|
||||
message,
|
||||
apperrors.WithReason("document_target_"+status),
|
||||
apperrors.WithFailureStage("target_resolution"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"contractVersion": "doc.target.v1",
|
||||
"status": status,
|
||||
"query": query,
|
||||
"complete": true,
|
||||
"candidates": candidates,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func incompleteError(query string, candidates []Candidate, cause string) error {
|
||||
return apperrors.NewAPI(
|
||||
"无法证明文档搜索候选完整,已停止目标解析",
|
||||
apperrors.WithReason("document_target_incomplete"),
|
||||
apperrors.WithFailureStage("target_resolution"),
|
||||
apperrors.WithExecutionStarted(false),
|
||||
apperrors.WithRetryable(true),
|
||||
apperrors.WithActions("稍后以相同条件重试", "使用已知 nodeId 或 URL 直接指定目标"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"contractVersion": "doc.target.v1",
|
||||
"status": "incomplete",
|
||||
"query": query,
|
||||
"complete": false,
|
||||
"cause": cause,
|
||||
"candidates": dedupe(candidates),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package docresolver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
type scriptedReader struct {
|
||||
pages []map[string]any
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (r *scriptedReader) CallMCPData(_ string, _ string, _ map[string]any) (map[string]any, error) {
|
||||
r.calls++
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
if len(r.pages) == 0 {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
page := r.pages[0]
|
||||
r.pages = r.pages[1:]
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func row(id, name, typ string) any {
|
||||
return map[string]any{"nodeId": id, "name": name, "docType": typ, "url": "https://alidocs.dingtalk.com/i/nodes/" + id}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveStableTargetDoesNotSearch(t *testing.T) {
|
||||
reader := &scriptedReader{}
|
||||
resolution, err := Resolve(reader, "node-1", "")
|
||||
if err != nil || reader.calls != 0 || resolution.Selected.CanonicalID != "node-1" || !resolution.Complete {
|
||||
t.Fatalf("resolution=%#v calls=%d err=%v", resolution, reader.calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveNaturalTitleExhaustsPagesBeforeSelecting(t *testing.T) {
|
||||
first := make([]any, 0, searchPageSize)
|
||||
for i := 0; i < searchPageSize; i++ {
|
||||
first = append(first, row(fmt.Sprintf("other-%d", i), "其他", "adoc"))
|
||||
}
|
||||
reader := &scriptedReader{pages: []map[string]any{
|
||||
{"documents": first, "hasMore": true, "nextPageToken": "p2"},
|
||||
{"documents": []any{row("wanted", "项目周报", "adoc")}, "hasMore": false},
|
||||
}}
|
||||
resolution, err := Resolve(reader, "", "项目周报")
|
||||
if err != nil || reader.calls != 2 || resolution.Selected.CanonicalID != "wanted" || resolution.MatchedBy != "exact_title" {
|
||||
t.Fatalf("resolution=%#v calls=%d err=%v", resolution, reader.calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveFailsClosedForAmbiguousIncompleteAndWrongType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pages []map[string]any
|
||||
query string
|
||||
reason string
|
||||
}{
|
||||
{"ambiguous", []map[string]any{{"documents": []any{row("1", "周报", "adoc"), row("2", "周报", "adoc")}, "hasMore": false}}, "周报", "ambiguous"},
|
||||
{"not found", []map[string]any{{"documents": []any{}, "hasMore": false}}, "missing", "not_found"},
|
||||
{"stalled", []map[string]any{{"documents": []any{row("1", "周报", "adoc")}, "hasMore": true, "nextPageToken": "p1"}, {"documents": []any{}, "hasMore": true, "nextPageToken": "p1"}}, "周报", "incomplete"},
|
||||
{"wrong type", []map[string]any{{"documents": []any{row("1", "周报", "pdf")}, "hasMore": false}}, "周报", "type_mismatch"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := Resolve(&scriptedReader{pages: tc.pages}, "", tc.query)
|
||||
var typed *apperrors.Error
|
||||
if err == nil || !errors.As(err, &typed) || !strings.Contains(typed.Reason, tc.reason) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
reader := &scriptedReader{err: errors.New("backend")}
|
||||
if _, err := Resolve(reader, "", "x"); !errors.Is(err, reader.err) {
|
||||
t.Fatalf("transport error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolverFinalBranchMatrix(t *testing.T) {
|
||||
for _, target := range [][2]string{{"", ""}, {"node", "query"}} {
|
||||
if _, err := Resolve(&scriptedReader{}, target[0], target[1]); err == nil {
|
||||
t.Fatalf("invalid target %#v succeeded", target)
|
||||
}
|
||||
}
|
||||
|
||||
full := make([]any, searchPageSize)
|
||||
for i := range full {
|
||||
full[i] = row(fmt.Sprintf("full-%d", i), "other", "adoc")
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
pages []map[string]any
|
||||
}{
|
||||
{"unproven full page", []map[string]any{{"documents": full}}},
|
||||
{"missing cursor", []map[string]any{{"documents": []any{}, "hasMore": true}}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := Resolve(&scriptedReader{pages: tc.pages}, "", "wanted"); err == nil {
|
||||
t.Fatal("incomplete pagination succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := Resolve(&scriptedReader{pages: []map[string]any{{"documents": []any{}}}}, "", "wanted"); err == nil {
|
||||
t.Fatal("underfilled implicit final page should resolve to not-found")
|
||||
}
|
||||
pages := make([]map[string]any, searchPageLimit)
|
||||
for i := range pages {
|
||||
pages[i] = map[string]any{"documents": []any{}, "hasMore": true, "nextPageToken": fmt.Sprintf("p-%d", i+1)}
|
||||
}
|
||||
if _, err := Resolve(&scriptedReader{pages: pages}, "", "wanted"); err == nil {
|
||||
t.Fatal("max-page pagination succeeded")
|
||||
}
|
||||
|
||||
nested := map[string]any{
|
||||
"result": map[string]any{
|
||||
"data": map[string]any{
|
||||
"records": []any{
|
||||
"invalid",
|
||||
map[string]any{"id": "", "title": "empty"},
|
||||
map[string]any{"doc_id": "d", "fileName": "Doc", "fileType": ".ADOC", "webUrl": " u ", "parentId": "p"},
|
||||
},
|
||||
"pagination": map[string]any{"has_more": false, "next_page_token": " next "},
|
||||
},
|
||||
},
|
||||
}
|
||||
rows := extractRows(nested)
|
||||
candidates := extractCandidates(rows)
|
||||
if len(candidates) != 1 || candidates[0].CanonicalID != "d" || normalizeType(candidates[0].ResourceType) != "adoc" {
|
||||
t.Fatalf("nested candidates = %#v", candidates)
|
||||
}
|
||||
if hasMore, known, next := extractPage(nested); !known || hasMore || next != "next" {
|
||||
t.Fatalf("nested page = %v/%v/%q", hasMore, known, next)
|
||||
}
|
||||
if rows := extractRows(map[string]any{"items": "not-a-list"}); len(rows) != 0 {
|
||||
t.Fatalf("non-list rows = %#v", rows)
|
||||
}
|
||||
deduped := dedupe([]Candidate{{}, {CanonicalID: "d"}, {CanonicalID: "d"}})
|
||||
if len(deduped) != 1 {
|
||||
t.Fatalf("deduped = %#v", deduped)
|
||||
}
|
||||
selected, matchedBy := selectCandidates([]Candidate{{CanonicalID: "d", Name: "Different"}}, "query")
|
||||
if len(selected) != 1 || matchedBy != "search_candidate" {
|
||||
t.Fatalf("fallback selection = %#v/%q", selected, matchedBy)
|
||||
}
|
||||
}
|
||||
@@ -153,6 +153,20 @@ func (rt *RuntimeContext) CallMCPData(product, tool string, params map[string]an
|
||||
return rt.callMCPData(product, tool, params)
|
||||
}
|
||||
|
||||
// CallMCPReadData dispatches a tool whose name is explicitly classified as
|
||||
// read-only without consulting Cobra state. Callers must invoke it serially
|
||||
// unless the injected ToolCaller separately documents and enforces concurrent
|
||||
// safety. Write-shaped tool names fail closed.
|
||||
func (rt *RuntimeContext) CallMCPReadData(product, tool string, params map[string]any) (map[string]any, error) {
|
||||
if !looksReadTool(tool) {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf(
|
||||
"并发只读入口拒绝写工具 %s/%s;写操作必须使用 CallMCPWriteData",
|
||||
product, tool,
|
||||
))
|
||||
}
|
||||
return rt.callMCPReadData(product, tool, params)
|
||||
}
|
||||
|
||||
// CallMCPWriteData dispatches a write tool call and returns its parsed response.
|
||||
// Unlike CallMCPData, it refuses to run under --dry-run so smart shortcuts cannot
|
||||
// accidentally perform writes while rendering a preview. For compatibility with
|
||||
|
||||
@@ -698,7 +698,7 @@
|
||||
},
|
||||
"+thread-replies": {
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
|
||||
"semantic_delta": "接受话题主消息 ID 并通过只读消息详情自动解析 conversation/thread,也接受显式 group + threadId(兼容 topicId);拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文,使用下层毫秒级 nextCursor 安全分页以避免同秒回复漏读,并支持全量结果 asc/desc;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
|
||||
"risk": "read",
|
||||
"public": true,
|
||||
"reviewed": true
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
"+update": {"disposition":"primary_smart","semantic_delta":"统一追加、覆盖和 block 级精确修改,并集中处理内容输入、定位和确认。","risk":"write","public":true,"reviewed":true},
|
||||
"+checkpoint-update": {"disposition":"primary_smart","semantic_delta":"写入前保存版本快照,更新后读回验证并输出逐步 ledger。","risk":"write","public":true,"reviewed":true},
|
||||
"+export": {"disposition":"primary_smart","semantic_delta":"一体化提交、轮询导出任务并按 no-clobber 策略安全下载到本地。","risk":"read","public":true,"reviewed":true},
|
||||
"+import": {"disposition":"primary_smart","semantic_delta":"一体化创建会话、上传、确认转换并轮询导入结果。","risk":"write","public":true,"reviewed":true},
|
||||
"+history-save": {"disposition":"semantic_adapter","semantic_delta":"以文档历史语义命名手动版本快照,避免暴露底层 RPC 命名。","risk":"write","public":true,"reviewed":true},
|
||||
"+history-list": {"disposition":"semantic_adapter","semantic_delta":"统一历史版本分页参数并返回可用于回滚的版本列表。","risk":"read","public":true,"reviewed":true},
|
||||
"+history-revert": {"disposition":"primary_smart","semantic_delta":"先验证目标版本存在,再执行回滚并读回当前文档状态。","risk":"high-risk-write","public":true,"reviewed":true},
|
||||
"+import": {"disposition":"primary_smart","semantic_delta":"一体化创建会话、上传、确认转换并轮询导入结果;白名单外格式自动改走文件上传原样入库(fallback=upload、converted=false)。","risk":"write","public":true,"reviewed":true},
|
||||
"+version-save": {"disposition":"semantic_adapter","semantic_delta":"版本快照的 Agent 主入口;只保存当前快照,不隐式修改正文。","risk":"write","public":true,"reviewed":true},
|
||||
"+version-list": {"disposition":"semantic_adapter","semantic_delta":"版本浏览的 Agent 主入口;统一分页参数并返回可用于回滚的版本号。","risk":"read","public":true,"reviewed":true},
|
||||
"+version-revert": {"disposition":"primary_smart","semantic_delta":"版本回滚的 Agent 主入口;先验证目标版本存在,再回滚并读回当前状态。","risk":"high-risk-write","public":true,"reviewed":true},
|
||||
"+template-list": {"disposition":"semantic_adapter","semantic_delta":"统一 MY/PUBLIC 模板浏览和分页参数。","risk":"read","public":true,"reviewed":true},
|
||||
"+template-search": {"disposition":"semantic_adapter","semantic_delta":"按名称检索模板并返回可继续创建的 templateId。","risk":"read","public":true,"reviewed":true},
|
||||
"+template-search": {"disposition":"semantic_adapter","semantic_delta":"按名称或关键词检索模板并返回可消歧候选和 templateId。","risk":"read","public":true,"reviewed":true},
|
||||
"+create-from-template": {"disposition":"primary_smart","semantic_delta":"支持 templateId 直达或按名称搜索消歧后创建文档。","risk":"write","public":true,"reviewed":true},
|
||||
"+media-list": {"disposition":"semantic_adapter","semantic_delta":"从文档块中提取图片、附件及其 block/resource 标识。","risk":"read","public":true,"reviewed":true},
|
||||
"+media-insert": {"disposition":"primary_smart","semantic_delta":"组合本地文件校验、上传凭证、OSS PUT、插块和验证。","risk":"write","public":true,"reviewed":true},
|
||||
@@ -40,9 +40,9 @@
|
||||
|
||||
"+find-doc": {"disposition":"alias_internal","semantic_delta":"保留历史文档搜索命令及其稳定 Schema identity;新场景优先使用 +search。","risk":"read","primary":"+search","public":true,"reviewed":true},
|
||||
"+doc-append": {"disposition":"alias_internal","semantic_delta":"保留历史文档末尾追加命令及其稳定 Schema identity;新场景优先使用 +update。","risk":"write","primary":"+update","public":true,"reviewed":true},
|
||||
"+version-save": {"disposition":"alias_internal","semantic_delta":"保留历史版本快照命令及其稳定 Schema identity;新场景优先使用 +history-save。","risk":"write","primary":"+history-save","public":true,"reviewed":true},
|
||||
"+version-list": {"disposition":"alias_internal","semantic_delta":"保留历史版本列表命令及其稳定 Schema identity;新场景优先使用 +history-list。","risk":"read","primary":"+history-list","public":true,"reviewed":true},
|
||||
"+version-revert": {"disposition":"alias_internal","semantic_delta":"保留历史版本回滚命令及其稳定 Schema identity;新场景优先使用 +history-revert。","risk":"high-risk-write","primary":"+history-revert","public":true,"reviewed":true},
|
||||
"+history-save": {"disposition":"alias_internal","semantic_delta":"保留既有历史快照路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-save。","risk":"write","primary":"+version-save","public":true,"reviewed":true},
|
||||
"+history-list": {"disposition":"alias_internal","semantic_delta":"保留既有历史列表路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-list。","risk":"read","primary":"+version-list","public":true,"reviewed":true},
|
||||
"+history-revert": {"disposition":"alias_internal","semantic_delta":"保留既有历史回滚路径及稳定 Schema identity;新的 Agent 场景统一使用 +version-revert。","risk":"high-risk-write","primary":"+version-revert","public":true,"reviewed":true},
|
||||
"+share-doc": {"disposition":"alias_internal","semantic_delta":"保留历史单人文档分享命令及其稳定 Schema identity;新场景优先使用 +share。","risk":"write","primary":"+share","public":true,"reviewed":true},
|
||||
|
||||
"+list": {"disposition":"alias_internal","semantic_delta":"旧 Doc 导航入口,仅为兼容保留;新的文件树导航应使用 Drive 命令。","risk":"read","primary":"drive list","public":true,"reviewed":true},
|
||||
@@ -50,7 +50,7 @@
|
||||
"+move": {"disposition":"alias_internal","semantic_delta":"旧 Doc 移动入口,仅为兼容保留;新的文件移动应使用 Drive 命令。","risk":"write","primary":"drive move","public":true,"reviewed":true},
|
||||
"+export-submit": {"disposition":"alias_internal","semantic_delta":"导出中断恢复所需的专家入口;常规场景使用一体化 +export。","risk":"read","primary":"+export","public":true,"reviewed":true},
|
||||
"+export-get": {"disposition":"alias_internal","semantic_delta":"按 jobId 查询导出状态的恢复入口;常规场景使用一体化 +export。","risk":"read","primary":"+export","public":true,"reviewed":true},
|
||||
"+comment-create-inline": {"disposition":"alias_internal","semantic_delta":"已知 block/start/end 时使用的低级批注入口;常规场景使用 +comment-create。","risk":"write","primary":"+comment-create","public":false,"reviewed":true},
|
||||
"+comment-create-inline": {"disposition":"alias_internal","semantic_delta":"仅兼容既有调用;selection 与 block/start/end 新场景都统一使用 +comment-create。","risk":"write","primary":"+comment-create","public":false,"reviewed":true},
|
||||
"+template-apply": {"disposition":"alias_internal","semantic_delta":"已知 templateId 时使用的低级入口;常规场景使用 +create-from-template。","risk":"write","primary":"+create-from-template","public":false,"reviewed":true}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -685,6 +685,14 @@ func TestCrossPlatformCoverageCallMCPWriteDataRejectsDryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCallMCPReadDataRejectsWriteName(t *testing.T) {
|
||||
rt := &RuntimeContext{}
|
||||
_, err := rt.CallMCPReadData("doc", "update_document", map[string]any{"nodeId": "n"})
|
||||
if err == nil || !strings.Contains(err.Error(), "doc/update_document") {
|
||||
t.Fatalf("read-only guard error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCallMCPDataDryRunReadNameFailsClosed(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -21,12 +22,18 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
)
|
||||
|
||||
const (
|
||||
atMeDefaultPageLimit = 50
|
||||
atMeHardPageLimit = 500
|
||||
)
|
||||
|
||||
// AtMe: pull the messages that recently @-mentioned ME across chats in one step.
|
||||
//
|
||||
// Steps:
|
||||
@@ -93,74 +100,96 @@ var AtMe = shortcut.Shortcut{
|
||||
{Name: "days", Type: shortcut.FlagInt, Desc: "回溯天数(默认 7);--days 必须在 1-3650 之间", Default: "7", Required: false},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 50);--limit 必须大于 0", Default: "50"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor", Default: "0"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部 @我 消息;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
|
||||
}, chatshortcut.MessageResourceDownloadFlags()...),
|
||||
Constraints: append([]shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"group", "chat-query", "group-query"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"days"}, Description: "--days 必须在 1-3650 之间"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
}, chatshortcut.MessageResourceDownloadConstraints()...),
|
||||
Tips: []string{
|
||||
`dws chat +at-me`,
|
||||
`dws chat +at-me --days 3`,
|
||||
`dws chat +at-me --group "项目群"`,
|
||||
`dws chat +at-me --days 30 --page-all --page-limit 50`,
|
||||
},
|
||||
Validate: validateAtMe,
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
groupID := ""
|
||||
directTarget := strings.TrimSpace(rt.Str("group"))
|
||||
queryTarget := strings.TrimSpace(rt.StrFirst("chat-query", "group-query"))
|
||||
if directTarget != "" || queryTarget != "" {
|
||||
resolved, err := targetresolver.ResolveChatTarget(rt, directTarget, queryTarget)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groupID = resolved.Selected.OpenConversationID
|
||||
}
|
||||
Execute: executeAtMe,
|
||||
}
|
||||
|
||||
// Step 1 — look-back window [now-Nd, now] in epoch millis. days defaults
|
||||
// to 7; guard against non-positive overrides so the window stays sane.
|
||||
days := rt.Int("days")
|
||||
if days <= 0 {
|
||||
days = 7
|
||||
func executeAtMe(rt *shortcut.RuntimeContext) error {
|
||||
groupID := ""
|
||||
directTarget := strings.TrimSpace(rt.Str("group"))
|
||||
queryTarget := strings.TrimSpace(rt.StrFirst("chat-query", "group-query"))
|
||||
if directTarget != "" || queryTarget != "" {
|
||||
resolved, err := targetresolver.ResolveChatTarget(rt, directTarget, queryTarget)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
startMs := now.AddDate(0, 0, -days).UnixMilli()
|
||||
endMs := now.UnixMilli()
|
||||
groupID = resolved.Selected.OpenConversationID
|
||||
}
|
||||
|
||||
// Step 2 — search @me messages. startTime/endTime/limit/cursor and the
|
||||
// first-page defaults (limit 50, cursor "0") mirror
|
||||
// helpers.chatMessageListMentionsCmd's search_at_me_message call.
|
||||
params := map[string]any{
|
||||
"startTime": startMs,
|
||||
"endTime": endMs,
|
||||
"limit": rt.Int("limit"),
|
||||
"cursor": rt.Str("cursor"),
|
||||
}
|
||||
if groupID != "" {
|
||||
params["openConversationId"] = groupID
|
||||
// Step 1 — look-back window [now-Nd, now] in epoch millis. Validation has
|
||||
// already constrained days to the published 1-3650 range.
|
||||
days := rt.Int("days")
|
||||
now := time.Now()
|
||||
startMs := now.AddDate(0, 0, -days).UnixMilli()
|
||||
endMs := now.UnixMilli()
|
||||
|
||||
// Step 2 — search @me messages. startTime/endTime/limit/cursor and the
|
||||
// first-page defaults (limit 50, cursor "0") mirror
|
||||
// helpers.chatMessageListMentionsCmd's search_at_me_message call.
|
||||
params := map[string]any{
|
||||
"startTime": startMs,
|
||||
"endTime": endMs,
|
||||
"limit": rt.Int("limit"),
|
||||
"cursor": rt.Str("cursor"),
|
||||
}
|
||||
if groupID != "" {
|
||||
params["openConversationId"] = groupID
|
||||
}
|
||||
var items []map[string]any
|
||||
var payload map[string]any
|
||||
var readErr error
|
||||
if rt.Bool("page-all") {
|
||||
payload, items, readErr = readAllAtMePages(rt, params)
|
||||
if payload == nil {
|
||||
return readErr
|
||||
}
|
||||
} else {
|
||||
data, err := rt.CallMCPData("chat", "search_at_me_message", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3 — always publish the stable list envelope, including for an empty
|
||||
// or newly-shaped response. This keeps common .messages[]/.items[] jq
|
||||
// projections deterministic instead of turning absence into null.
|
||||
items := atMeMessageItems(data)
|
||||
results := make([]map[string]any, 0, len(items))
|
||||
for _, m := range items {
|
||||
results = append(results, atMeProjectWithReactions(m, !rt.Bool("no-reactions")))
|
||||
}
|
||||
payload := chatmsg.NewMessageListPayload(results)
|
||||
payload["items"] = atMeCompatibilityItems(results)
|
||||
items = atMeMessageItems(data)
|
||||
payload = atMePayload(items, !rt.Bool("no-reactions"))
|
||||
chatmsg.ApplyPagination(payload, data)
|
||||
if rt.Bool("download-resources") {
|
||||
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, groupID)
|
||||
payload["pagesFetched"] = 1
|
||||
if payload["complete"] == true {
|
||||
payload["stopReason"] = "source_complete"
|
||||
} else {
|
||||
payload["stopReason"] = "single_page"
|
||||
}
|
||||
return rt.Output(payload)
|
||||
},
|
||||
}
|
||||
|
||||
results := make([]map[string]any, 0, len(items))
|
||||
if projected, ok := payload["messages"].([]map[string]any); ok {
|
||||
results = projected
|
||||
}
|
||||
if rt.Bool("download-resources") && readErr == nil {
|
||||
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, groupID)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
payload["messages"] = []map[string]any{}
|
||||
payload["items"] = []map[string]any{}
|
||||
}
|
||||
if err := rt.Output(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return readErr
|
||||
}
|
||||
|
||||
func validateAtMe(rt *shortcut.RuntimeContext) error {
|
||||
@@ -174,9 +203,147 @@ func validateAtMe(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("limit") <= 0 {
|
||||
return localChatOptionError("invalid_page_size", "+at-me 的 --limit 必须大于 0", "--limit")
|
||||
}
|
||||
if !rt.Bool("page-all") && rt.Changed("page-limit") {
|
||||
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
|
||||
}
|
||||
if rt.Bool("page-all") {
|
||||
if limit := rt.Int("page-limit"); limit < 1 || limit > atMeHardPageLimit {
|
||||
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func atMePayload(items []map[string]any, includeReactions bool) map[string]any {
|
||||
results := make([]map[string]any, 0, len(items))
|
||||
for _, message := range items {
|
||||
results = append(results, atMeProjectWithReactions(message, includeReactions))
|
||||
}
|
||||
payload := chatmsg.NewMessageListPayload(results)
|
||||
payload["items"] = atMeCompatibilityItems(results)
|
||||
return payload
|
||||
}
|
||||
|
||||
func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (map[string]any, []map[string]any, error) {
|
||||
pageLimit := defaultChatPageLimit(rt.Int("page-limit"), atMeDefaultPageLimit)
|
||||
cursor := strings.TrimSpace(fmt.Sprint(baseParams["cursor"]))
|
||||
if cursor == "" || cursor == "<nil>" {
|
||||
cursor = "0"
|
||||
}
|
||||
seenCursors := map[string]bool{cursor: true}
|
||||
seenMessages := map[string]bool{}
|
||||
allItems := make([]map[string]any, 0)
|
||||
failures := make([]map[string]any, 0)
|
||||
pagesFetched := 0
|
||||
complete := false
|
||||
hasMore := false
|
||||
nextCursor := ""
|
||||
stopReason := "source_complete"
|
||||
truncatedByPageLimit := false
|
||||
|
||||
for pagesFetched < pageLimit {
|
||||
params := make(map[string]any, len(baseParams))
|
||||
for key, value := range baseParams {
|
||||
params[key] = value
|
||||
}
|
||||
params["cursor"] = cursor
|
||||
data, err := rt.CallMCPData("chat", "search_at_me_message", params)
|
||||
if err != nil {
|
||||
if pagesFetched == 0 {
|
||||
return nil, nil, err
|
||||
}
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched + 1, "stage": "read", "cursor": cursor, "error": err.Error(),
|
||||
})
|
||||
stopReason = "read_failure"
|
||||
break
|
||||
}
|
||||
pagesFetched++
|
||||
pageItems := atMeMessageItems(data)
|
||||
for _, item := range pageItems {
|
||||
id := chatmsg.StableMessageID(item)
|
||||
if id != "" && seenMessages[id] {
|
||||
continue
|
||||
}
|
||||
if id != "" {
|
||||
seenMessages[id] = true
|
||||
}
|
||||
allItems = append(allItems, item)
|
||||
}
|
||||
|
||||
page := chatmsg.Pagination(data)
|
||||
pageHasMore, known := page["hasMore"].(bool)
|
||||
if !known {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "@我消息下层未返回可靠的 hasMore,无法证明结果完整",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
hasMore = pageHasMore
|
||||
if !hasMore {
|
||||
complete = true
|
||||
nextCursor = ""
|
||||
stopReason = "source_complete"
|
||||
break
|
||||
}
|
||||
nextCursor = atMeCursorString(page["nextCursor"])
|
||||
if nextCursor == "" || seenCursors[nextCursor] {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "@我消息下层返回 hasMore=true,但 nextCursor 缺失、无效或未前进",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
seenCursors[nextCursor] = true
|
||||
cursor = nextCursor
|
||||
}
|
||||
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
|
||||
truncatedByPageLimit = true
|
||||
stopReason = "page_limit"
|
||||
}
|
||||
|
||||
payload := atMePayload(allItems, !rt.Bool("no-reactions"))
|
||||
payload["pagesFetched"] = pagesFetched
|
||||
payload["paginationKnown"] = true
|
||||
payload["complete"] = complete && len(failures) == 0
|
||||
payload["hasMore"] = hasMore
|
||||
payload["stopReason"] = stopReason
|
||||
payload["truncatedByPageLimit"] = truncatedByPageLimit
|
||||
payload["failedCount"] = len(failures)
|
||||
payload["failures"] = failures
|
||||
payload["partial"] = len(failures) > 0 && len(allItems) > 0
|
||||
if hasMore && nextCursor != "" {
|
||||
payload["nextCursor"] = nextCursor
|
||||
}
|
||||
if len(failures) == 0 {
|
||||
return payload, allItems, nil
|
||||
}
|
||||
return payload, allItems, apperrors.NewAPI(
|
||||
fmt.Sprintf("@我消息分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)),
|
||||
apperrors.WithOperation("chat/search_at_me_message"),
|
||||
apperrors.WithReason("at_me_incomplete"),
|
||||
apperrors.WithOrigin("mcp_gateway"),
|
||||
apperrors.WithFailureStage("pagination"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(true),
|
||||
apperrors.WithHint("请根据 failures 和 nextCursor 重试"),
|
||||
)
|
||||
}
|
||||
|
||||
func atMeCursorString(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprint(value))
|
||||
if text == "" || text == "<nil>" || text == "0" {
|
||||
return ""
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// atMeCompatibilityItems preserves the common list/items projection used by
|
||||
// older Agent snippets while keeping messages as the canonical v1 contract.
|
||||
// Its conversation field is an object so `.items[].conversation.name` is safe.
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -36,7 +39,11 @@ const (
|
||||
)
|
||||
|
||||
func formatDingTalkMessageBoundary(now time.Time) string {
|
||||
return now.In(dingTalkMessageLocation).Format("2006-01-02 15:04:05")
|
||||
localized := now.In(dingTalkMessageLocation)
|
||||
if localized.Nanosecond() != 0 {
|
||||
return localized.Format(time.RFC3339Nano)
|
||||
}
|
||||
return localized.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// ChatMessages resolves one conversation, projects messages into the shared
|
||||
@@ -49,10 +56,10 @@ var ChatMessages = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-messages",
|
||||
Product: "chat",
|
||||
Description: "按会话 ID、群名或用户 ID 读取消息,支持有界全量分页与原子 JSON 导出",
|
||||
Intent: "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;" +
|
||||
Description: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出",
|
||||
Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" +
|
||||
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
|
||||
"省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。" +
|
||||
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" +
|
||||
"全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
|
||||
"默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。",
|
||||
Risk: shortcut.RiskRead,
|
||||
@@ -68,20 +75,20 @@ var ChatMessages = shortcut.Shortcut{
|
||||
CLIPath: "chat +chat-messages",
|
||||
PrimaryCLIPath: "chat +chat-messages",
|
||||
},
|
||||
Description: "按会话 ID、群名或用户 ID 读取消息,支持有界全量分页与原子 JSON 导出",
|
||||
Description: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed built-in Shortcut adapter: it routes group or direct-message history reads, projects a stable message shape, and optionally orchestrates safe resource downloads with a failure ledger.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按会话 ID、群名或用户 ID 读取消息,支持有界全量分页与原子 JSON 导出",
|
||||
UseWhen: []string{"当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;" +
|
||||
AgentSummary: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出",
|
||||
UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" +
|
||||
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
|
||||
"省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。" +
|
||||
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" +
|
||||
"全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
|
||||
"默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。"},
|
||||
AvoidWhen: []string{"要跨多个会话按关键词、发送者或消息类型检索时使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget"},
|
||||
AvoidWhen: []string{"以发送者、关键词、@对象或消息类型为主的直接条件检索优先使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget。已选择会话读取时可在同一次调用附带发送者姓名,不需要再搜索消息"},
|
||||
Examples: []string{
|
||||
"dws chat +chat-messages --group <openConversationId> --direction older",
|
||||
"dws chat +chat-messages --open-dingtalk-id <openDingTalkId> --download-resources --output-dir ./downloads",
|
||||
@@ -93,11 +100,18 @@ var ChatMessages = shortcut.Shortcut{
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
|
||||
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
|
||||
{Name: "chat-query", Type: shortcut.FlagString, Desc: "--group 的旧版自然名称入口", Hidden: true},
|
||||
{Name: "chat-query", Type: shortcut.FlagString, Desc: "按群名唯一解析目标会话(可选,与其他会话目标参数互斥)"},
|
||||
{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-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 对齐别名(可选、包含)"},
|
||||
{Name: "end", Type: shortcut.FlagString, Desc: "范围结束时间(可选、不包含);仅传开始时间时默认为当前时间"},
|
||||
{Name: "end-time", Type: shortcut.FlagString, Desc: "--end 的 lark-cli 对齐别名(可选、不包含)"},
|
||||
{Name: "order", Type: shortcut.FlagString, Enum: []string{"asc", "desc"}, Desc: "结果及范围遍历顺序 asc/desc(可选,默认 desc;asc 必须指定 --start/--start-time)"},
|
||||
{Name: "sort", Type: shortcut.FlagString, Enum: []string{"asc", "desc"}, Desc: "--order 的 lark-cli 对齐别名(可选;asc 必须指定 --start/--start-time)"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的消息条数;显式页大小必须大于 0"},
|
||||
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true},
|
||||
{Name: "page-size", Type: shortcut.FlagInt, Desc: "--limit 的兼容别名", Hidden: true},
|
||||
@@ -111,7 +125,17 @@ var ChatMessages = shortcut.Shortcut{
|
||||
Constraints: append([]shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "id", "open-conversation-id", "chat-query", "user", "user-query", "open-dingtalk-id"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"limit", "size", "page-size"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"start", "start-time"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"end", "end-time"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"order", "sort"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "start", "start-time"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "end", "end-time"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "order", "sort"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "start", "start-time"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "end", "end-time"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "order", "sort"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"time"}, Description: "--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"order", "sort"}, Description: "asc 必须指定 --start/--start-time"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式页大小必须大于 0"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "max-results"}, Description: "--max-results 仅与 --page-all 一起使用且不能为负数"},
|
||||
@@ -123,6 +147,7 @@ var ChatMessages = shortcut.Shortcut{
|
||||
}, chatshortcut.MessageResourceDownloadConstraints()...),
|
||||
Tips: []string{
|
||||
`dws chat +chat-messages --group <openconversation_id> --time "2025-03-01 00:00:00"`,
|
||||
`dws chat +chat-messages --group <openconversation_id> --start "2025-03-01T00:00:00+08:00" --end "2025-03-02T00:00:00+08:00" --order asc --page-all`,
|
||||
`dws chat +chat-messages --user <userId> --time "2025-03-01 00:00:00" --page-all --page-limit 50`,
|
||||
`dws chat +chat-messages --group <openconversation_id> --direction older --page-all --output ./exports/messages.json`,
|
||||
},
|
||||
@@ -137,6 +162,17 @@ func validateChatMessages(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Changed("time") && strings.TrimSpace(rt.Str("time")) != "" && !validChatTime(rt.Str("time")) {
|
||||
return localChatOptionError("invalid_time_boundary", "+chat-messages 的 --time 格式无效", "--time")
|
||||
}
|
||||
rangeFlagsChanged := rt.Changed("start") || rt.Changed("start-time") || rt.Changed("end") ||
|
||||
rt.Changed("end-time") || rt.Changed("order") || rt.Changed("sort")
|
||||
if rt.Changed("time") && rangeFlagsChanged {
|
||||
return apperrors.NewValidation("--time 兼容边界模式不能与 --start/--end/--order 范围模式混用")
|
||||
}
|
||||
if rt.Changed("direction") && rangeFlagsChanged {
|
||||
return apperrors.NewValidation("--direction 兼容方向不能与 --start/--end/--order 范围模式混用")
|
||||
}
|
||||
if _, err := resolveChatMessageTimeRange(rt, time.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range []string{"limit", "size", "page-size"} {
|
||||
if rt.Changed(name) && rt.Int(name) <= 0 {
|
||||
return localChatOptionError("invalid_page_size", "+chat-messages 的 --"+name+" 必须大于 0", "--"+name)
|
||||
@@ -168,6 +204,107 @@ type chatMessagesRequest struct {
|
||||
params map[string]any
|
||||
direction string
|
||||
fallbackConversationID string
|
||||
timeRange chatMessageTimeRange
|
||||
}
|
||||
|
||||
type chatMessagesSenderFilter struct {
|
||||
requested bool
|
||||
applied bool
|
||||
stableIDs map[string]bool
|
||||
resolutions []targetresolver.UserResolution
|
||||
failure map[string]any
|
||||
}
|
||||
|
||||
// resolveOptionalChatMessagesSenderFilter is deliberately local to
|
||||
// +chat-messages. Sender resolution is an optional post-read filter here: an
|
||||
// unresolved name must not suppress the command's primary conversation-list
|
||||
// result. +search-msg keeps its stricter semantics because sender identity is
|
||||
// part of the lower search request there.
|
||||
func resolveOptionalChatMessagesSenderFilter(rt *shortcut.RuntimeContext) chatMessagesSenderFilter {
|
||||
queries := rt.StrSlice("sender-query")
|
||||
filter := chatMessagesSenderFilter{
|
||||
requested: len(queries) > 0,
|
||||
stableIDs: map[string]bool{},
|
||||
}
|
||||
if !filter.requested {
|
||||
return filter
|
||||
}
|
||||
|
||||
resolutions, err := targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny)
|
||||
if err != nil {
|
||||
failure := map[string]any{
|
||||
"stage": "sender_resolution",
|
||||
"queries": queries,
|
||||
"error": err.Error(),
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if errors.As(err, &typed) {
|
||||
if typed.Reason != "" {
|
||||
failure["reason"] = typed.Reason
|
||||
}
|
||||
if len(typed.Details) > 0 {
|
||||
failure["details"] = typed.Details
|
||||
}
|
||||
}
|
||||
filter.failure = failure
|
||||
return filter
|
||||
}
|
||||
|
||||
filter.resolutions = resolutions
|
||||
for _, resolution := range resolutions {
|
||||
for _, identity := range []string{
|
||||
resolution.Selected.UserID,
|
||||
resolution.Selected.OpenDingTalkID,
|
||||
} {
|
||||
if identity = strings.TrimSpace(identity); identity != "" {
|
||||
filter.stableIDs[identity] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// ResolveUsers(IdentityAny) only returns users extracted with at least one
|
||||
// stable userId/openDingTalkId and fails when no resolvable user remains.
|
||||
filter.applied = true
|
||||
return filter
|
||||
}
|
||||
|
||||
// applyOptionalChatMessagesSenderFilter preserves the unfiltered message list
|
||||
// when optional resolution fails. Successful resolution filters both the
|
||||
// public projection and raw rows so exports and resource downloads cannot
|
||||
// accidentally include messages outside the requested sender set.
|
||||
func applyOptionalChatMessagesSenderFilter(
|
||||
rt *shortcut.RuntimeContext,
|
||||
payload map[string]any,
|
||||
rawItems []map[string]any,
|
||||
filter chatMessagesSenderFilter,
|
||||
) []map[string]any {
|
||||
if !filter.requested || payload == nil {
|
||||
return rawItems
|
||||
}
|
||||
if !filter.applied {
|
||||
failures, _ := payload["failures"].([]map[string]any)
|
||||
priorFailures := len(failures)
|
||||
failures = append(failures, filter.failure)
|
||||
payload["failures"] = failures
|
||||
payload["failedCount"] = len(failures)
|
||||
payload["complete"] = false
|
||||
payload["partial"] = len(rawItems) > 0
|
||||
if priorFailures == 0 {
|
||||
payload["stopReason"] = "sender_resolution_failed"
|
||||
}
|
||||
return rawItems
|
||||
}
|
||||
|
||||
filtered := make([]map[string]any, 0, len(rawItems))
|
||||
for _, item := range rawItems {
|
||||
identity := strings.TrimSpace(fmt.Sprint(chatmsg.SenderID(item)))
|
||||
if identity != "" && identity != "<nil>" && filter.stableIDs[identity] {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
payload["messages"] = projectChatMessages(filtered, !rt.Bool("no-reactions"))
|
||||
payload["count"] = len(filtered)
|
||||
payload["resolvedFilters"] = map[string]any{"senders": filter.resolutions}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesRequest, error) {
|
||||
@@ -194,12 +331,17 @@ func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesReques
|
||||
openID = resolved.Selected.OpenDingTalkID
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
timeRange, err := resolveChatMessageTimeRange(rt, now)
|
||||
if err != nil {
|
||||
return chatMessagesRequest{}, err
|
||||
}
|
||||
direction := strings.TrimSpace(strings.ToLower(rt.Str("direction")))
|
||||
if direction == "" {
|
||||
direction = "older"
|
||||
direction = timeRange.direction()
|
||||
}
|
||||
params := map[string]any{
|
||||
"time": formatDingTalkMessageBoundary(time.Now()),
|
||||
"time": timeRange.initialBoundary(now),
|
||||
"forward": direction == "newer",
|
||||
}
|
||||
if rt.Changed("time") && rt.Str("time") != "" {
|
||||
@@ -211,7 +353,7 @@ func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesReques
|
||||
params["limit"] = chatMessagesAllPageSize
|
||||
}
|
||||
|
||||
request := chatMessagesRequest{params: params, direction: direction}
|
||||
request := chatMessagesRequest{params: params, direction: direction, timeRange: timeRange}
|
||||
switch {
|
||||
case groupID != "":
|
||||
request.tool = "list_conversation_message_v2"
|
||||
@@ -239,6 +381,19 @@ func executeChatMessages(rt *shortcut.RuntimeContext) error {
|
||||
} else {
|
||||
payload, rawItems, err = collectOneChatMessagesPage(rt, request)
|
||||
}
|
||||
if err != nil && (payload == nil || payload["pagesFetched"] == 0) {
|
||||
// A sender name is only an optional post-read filter. If the primary
|
||||
// message read never produced a page, do not make a misleading and
|
||||
// unnecessary directory request before returning the read failure.
|
||||
if payload != nil {
|
||||
if outputErr := rt.Output(payload); outputErr != nil {
|
||||
return outputErr
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
senderFilter := resolveOptionalChatMessagesSenderFilter(rt)
|
||||
rawItems = applyOptionalChatMessagesSenderFilter(rt, payload, rawItems, senderFilter)
|
||||
if err != nil {
|
||||
// Full-page collection returns its failure ledger together with a
|
||||
// non-zero error. Publish that ledger for diagnosis, but stop before
|
||||
@@ -285,10 +440,32 @@ func collectOneChatMessagesPage(rt *shortcut.RuntimeContext, request chatMessage
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
items := chatMessageItems(data)
|
||||
rawItems := chatMessageItems(data)
|
||||
items, terminalReached, rangeFailures := request.timeRange.filter(rawItems)
|
||||
sortMessagesByCreateTimeStable(items, request.timeRange.order)
|
||||
results := projectChatMessages(items, !rt.Bool("no-reactions"))
|
||||
payload := chatmsg.NewMessageListPayload(results)
|
||||
chatmsg.ApplyMessagePagination(payload, data, items, request.direction)
|
||||
chatmsg.ApplyMessagePagination(payload, data, rawItems, request.direction)
|
||||
if metadata := request.timeRange.metadata(); metadata != nil {
|
||||
payload["queryRange"] = metadata
|
||||
}
|
||||
if len(rangeFailures) > 0 {
|
||||
failures, _ := payload["failures"].([]map[string]any)
|
||||
failures = append(failures, rangeFailures...)
|
||||
payload["failures"] = failures
|
||||
payload["failedCount"] = len(failures)
|
||||
payload["complete"] = false
|
||||
payload["partial"] = len(items) > 0
|
||||
payload["stopReason"] = "time_filter_error"
|
||||
return payload, items, nil
|
||||
}
|
||||
if terminalReached {
|
||||
payload["complete"] = true
|
||||
payload["hasMore"] = false
|
||||
payload["stopReason"] = request.timeRange.stopReason()
|
||||
delete(payload, "nextPage")
|
||||
return payload, items, nil
|
||||
}
|
||||
if payload["complete"] == true {
|
||||
payload["stopReason"] = "source_complete"
|
||||
} else {
|
||||
@@ -300,8 +477,12 @@ func collectOneChatMessagesPage(rt *shortcut.RuntimeContext, request chatMessage
|
||||
func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesRequest) (map[string]any, []map[string]any, error) {
|
||||
pageLimit := defaultChatPageLimit(rt.Int("page-limit"), chatMessagesDefaultPageLimit)
|
||||
maxResults := rt.Int("max-results")
|
||||
basePageSize, _ := request.params["limit"].(int)
|
||||
if basePageSize <= 0 {
|
||||
basePageSize = chatMessagesAllPageSize
|
||||
}
|
||||
seenIDs := map[string]bool{}
|
||||
seenBoundaries := map[string]bool{fmt.Sprint(request.params["time"]): true}
|
||||
seenCursors := map[string]bool{}
|
||||
allItems := make([]map[string]any, 0)
|
||||
failures := make([]map[string]any, 0)
|
||||
pagesFetched := 0
|
||||
@@ -314,6 +495,13 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
|
||||
var nextPage map[string]any
|
||||
|
||||
for pagesFetched < pageLimit {
|
||||
request.params["limit"] = basePageSize
|
||||
if maxResults > 0 {
|
||||
remaining := maxResults - len(allItems)
|
||||
if remaining < basePageSize {
|
||||
request.params["limit"] = remaining
|
||||
}
|
||||
}
|
||||
data, err := rt.CallMCPData("chat", request.tool, request.params)
|
||||
if err != nil {
|
||||
failures = append(failures, map[string]any{
|
||||
@@ -325,8 +513,10 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
|
||||
break
|
||||
}
|
||||
pagesFetched++
|
||||
items := chatMessageItems(data)
|
||||
keptOnPage := make([]map[string]any, 0, len(items))
|
||||
rawItems := chatMessageItems(data)
|
||||
items, terminalReached, rangeFailures := request.timeRange.filter(rawItems)
|
||||
failures = append(failures, rangeFailures...)
|
||||
moreEligibleOnPage := false
|
||||
for _, item := range items {
|
||||
stableID := chatmsg.StableMessageID(item)
|
||||
if stableID != "" && seenIDs[stableID] {
|
||||
@@ -335,11 +525,27 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
|
||||
if stableID != "" {
|
||||
seenIDs[stableID] = true
|
||||
}
|
||||
allItems = append(allItems, item)
|
||||
keptOnPage = append(keptOnPage, item)
|
||||
if maxResults > 0 && len(allItems) >= maxResults {
|
||||
break
|
||||
moreEligibleOnPage = true
|
||||
continue
|
||||
}
|
||||
allItems = append(allItems, item)
|
||||
}
|
||||
if len(rangeFailures) > 0 {
|
||||
stopReason = "time_filter_error"
|
||||
break
|
||||
}
|
||||
if terminalReached {
|
||||
if maxResults > 0 && moreEligibleOnPage {
|
||||
truncatedByResultLimit = true
|
||||
hasMore = true
|
||||
stopReason = "result_limit"
|
||||
} else {
|
||||
complete = true
|
||||
hasMore = false
|
||||
stopReason = request.timeRange.stopReason()
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
page := chatmsg.Pagination(data)
|
||||
@@ -357,22 +563,34 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
|
||||
hasMore = pageHasMore
|
||||
|
||||
if maxResults > 0 && len(allItems) >= maxResults {
|
||||
truncatedByResultLimit = pageHasMore || len(keptOnPage) < len(items)
|
||||
truncatedByResultLimit = pageHasMore || moreEligibleOnPage
|
||||
if truncatedByResultLimit {
|
||||
hasMore = true
|
||||
stopReason = "result_limit"
|
||||
if len(keptOnPage) > 0 {
|
||||
nextPage = messageNextPage(keptOnPage[len(keptOnPage)-1], request.direction)
|
||||
boundary := strings.TrimSpace(fmt.Sprint(nextPage["time"]))
|
||||
if boundary == "" || boundary == "<nil>" {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched,
|
||||
"stage": "pagination",
|
||||
"error": "达到结果上限但无法生成可靠的 nextPage.time",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
nextPage = nil
|
||||
}
|
||||
if moreEligibleOnPage {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched,
|
||||
"stage": "pagination",
|
||||
"error": "达到 --max-results 时当前下层页仍有未返回消息,无法生成不跳项的安全续页游标",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
cursorKey, boundary, cursorErr := chatMessagesNextCursorBoundary(page["nextCursor"])
|
||||
if cursorErr != nil {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched,
|
||||
"stage": "pagination",
|
||||
"error": "达到结果上限但 nextCursor 无效,无法安全续页: " + cursorErr.Error(),
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
seenCursors[cursorKey] = true
|
||||
nextPage = map[string]any{
|
||||
"direction": request.direction,
|
||||
"time": boundary,
|
||||
"nextCursor": page["nextCursor"],
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -383,7 +601,7 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
|
||||
stopReason = "source_complete"
|
||||
break
|
||||
}
|
||||
if len(items) == 0 {
|
||||
if len(rawItems) == 0 {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched,
|
||||
"stage": "pagination",
|
||||
@@ -392,18 +610,31 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
nextPage = messageNextPage(items[len(items)-1], request.direction)
|
||||
boundary := strings.TrimSpace(fmt.Sprint(nextPage["time"]))
|
||||
if boundary == "" || boundary == "<nil>" || seenBoundaries[boundary] {
|
||||
cursorKey, boundary, cursorErr := chatMessagesNextCursorBoundary(page["nextCursor"])
|
||||
if cursorErr != nil {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched,
|
||||
"stage": "pagination",
|
||||
"error": "hasMore=true 但 nextPage.time 缺失或停滞",
|
||||
"error": "hasMore=true 但 nextCursor 无效,无法安全续页: " + cursorErr.Error(),
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
seenBoundaries[boundary] = true
|
||||
if seenCursors[cursorKey] {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched,
|
||||
"stage": "pagination",
|
||||
"error": "hasMore=true 但毫秒 nextCursor 停滞",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
seenCursors[cursorKey] = true
|
||||
nextPage = map[string]any{
|
||||
"direction": request.direction,
|
||||
"time": boundary,
|
||||
"nextCursor": page["nextCursor"],
|
||||
}
|
||||
request.params["time"] = boundary
|
||||
}
|
||||
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
|
||||
@@ -411,8 +642,12 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
|
||||
stopReason = "page_limit"
|
||||
}
|
||||
|
||||
sortMessagesByCreateTimeStable(allItems, request.timeRange.order)
|
||||
results := projectChatMessages(allItems, !rt.Bool("no-reactions"))
|
||||
payload := chatmsg.NewMessageListPayload(results)
|
||||
if metadata := request.timeRange.metadata(); metadata != nil {
|
||||
payload["queryRange"] = metadata
|
||||
}
|
||||
payload["pagesFetched"] = pagesFetched
|
||||
payload["paginationKnown"] = paginationKnown
|
||||
payload["complete"] = complete && len(failures) == 0
|
||||
@@ -430,6 +665,8 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
|
||||
failureStage := "pagination"
|
||||
if stopReason == "read_failure" {
|
||||
failureStage = "read"
|
||||
} else if stopReason == "time_filter_error" {
|
||||
failureStage = "time_filter"
|
||||
}
|
||||
return payload, allItems, apperrors.NewAPI(
|
||||
fmt.Sprintf("全量消息读取未完成:%d 页成功,%d 个页面失败", pagesFetched, len(failures)),
|
||||
@@ -460,11 +697,46 @@ func projectChatMessages(items []map[string]any, includeReactions bool) []map[st
|
||||
return results
|
||||
}
|
||||
|
||||
func messageNextPage(message map[string]any, direction string) map[string]any {
|
||||
return map[string]any{
|
||||
"time": chatmsg.CreateTime(message),
|
||||
"direction": direction,
|
||||
// chatMessagesNextCursorBoundary converts the authoritative millisecond
|
||||
// cursor returned by DingTalk message-list tools into the exact RFC3339Nano
|
||||
// boundary accepted by their time parameter. Projected createTime is only
|
||||
// second precision and must never drive pagination: doing so skips messages
|
||||
// when a page boundary splits several messages created within the same second.
|
||||
func chatMessagesNextCursorBoundary(value any) (string, string, error) {
|
||||
var millis int64
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
millis = int64(typed)
|
||||
case int32:
|
||||
millis = int64(typed)
|
||||
case int64:
|
||||
millis = typed
|
||||
case float32:
|
||||
asFloat := float64(typed)
|
||||
if math.IsNaN(asFloat) || math.IsInf(asFloat, 0) || asFloat <= 0 || math.Trunc(asFloat) != asFloat || asFloat > math.MaxInt64 {
|
||||
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
|
||||
}
|
||||
millis = int64(asFloat)
|
||||
case float64:
|
||||
if math.IsNaN(typed) || math.IsInf(typed, 0) || typed <= 0 || math.Trunc(typed) != typed || typed > math.MaxInt64 {
|
||||
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
|
||||
}
|
||||
millis = int64(typed)
|
||||
case string:
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
|
||||
}
|
||||
millis = parsed
|
||||
default:
|
||||
return "", "", fmt.Errorf("缺少毫秒级分页游标")
|
||||
}
|
||||
if millis <= 0 {
|
||||
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
|
||||
}
|
||||
cursorKey := strconv.FormatInt(millis, 10)
|
||||
boundary := time.UnixMilli(millis).UTC().Format(time.RFC3339Nano)
|
||||
return cursorKey, boundary, nil
|
||||
}
|
||||
|
||||
// chatMessageItems defensively unwraps the message list from the response,
|
||||
|
||||
@@ -18,11 +18,14 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"math"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"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/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -147,8 +150,9 @@ func (*chatMessagesPagingCaller) Fields() string { return "" }
|
||||
func (*chatMessagesPagingCaller) JQ() string { return "" }
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesPageAllUsesTypedBoundaryAndDeduplicates(t *testing.T) {
|
||||
const cursorMillis int64 = 1767225600123
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m2","createTime":"2026-01-02 00:00:00"},{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`,
|
||||
`{"result":{"hasMore":true,"nextCursor":1767225600123,"messages":[{"openMessageId":"m2","createTime":"2026-01-02 00:00:00"},{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`,
|
||||
`{"result":{"hasMore":false,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"},{"openMessageId":"m0","createTime":"2025-12-31 00:00:00"}]}}`,
|
||||
}}
|
||||
helpers.InitDeps(caller)
|
||||
@@ -162,7 +166,8 @@ func TestCrossPlatformCoverageChatMessagesPageAllUsesTypedBoundaryAndDeduplicate
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.args) != 2 || caller.args[1]["time"] != "2026-01-01 00:00:00" {
|
||||
wantBoundary := time.UnixMilli(cursorMillis).UTC().Format(time.RFC3339Nano)
|
||||
if len(caller.args) != 2 || caller.args[1]["time"] != wantBoundary {
|
||||
t.Fatalf("pagination calls = %#v", caller.args)
|
||||
}
|
||||
var payload map[string]any
|
||||
@@ -176,9 +181,158 @@ func TestCrossPlatformCoverageChatMessagesPageAllUsesTypedBoundaryAndDeduplicate
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesPageAllPublishesBoundedContinuation(t *testing.T) {
|
||||
func TestCrossPlatformCoverageChatMessagesMillisecondCursorDoesNotSkipSameSecondRows(t *testing.T) {
|
||||
const cursorMillis int64 = 1785919699136
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`,
|
||||
`{"result":{"hasMore":true,"nextCursor":1785919699136,"messages":[{"openMessageId":"m4","createTime":"2026-08-05 16:48:19"},{"openMessageId":"m3","createTime":"2026-08-05 16:48:19"},{"openMessageId":"m2","createTime":"2026-08-05 16:48:19"}]}}`,
|
||||
`{"result":{"hasMore":false,"messages":[{"openMessageId":"m1","createTime":"2026-08-05 16:48:19"}]}}`,
|
||||
}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-messages", "--conversation-id", "cid", "--time", "2026-08-05 16:49:00",
|
||||
"--page-size", "3", "--page-all", "--page-limit", "5",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantBoundary := time.UnixMilli(cursorMillis).UTC().Format(time.RFC3339Nano)
|
||||
if len(caller.args) != 2 || caller.args[1]["time"] != wantBoundary {
|
||||
t.Fatalf("pagination calls = %#v", caller.args)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(4) || payload["complete"] != true || payload["pagesFetched"] != float64(2) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesDescendingRangeStopsAtInclusiveStart(t *testing.T) {
|
||||
const cursorMillis int64 = 1767312000456
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":true,"nextCursor":1767312000456,"messages":[{"openMessageId":"m3","createTime":"2026-01-03 00:00:00"},{"openMessageId":"m2","createTime":"2026-01-02 00:00:00"}]}}`,
|
||||
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 12:00:00"},{"openMessageId":"m0","createTime":"2026-01-01 11:59:59"}]}}`,
|
||||
}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-messages", "--conversation-id", "cid",
|
||||
"--start", "2026-01-01 12:00:00", "--end", "2026-01-04 00:00:00",
|
||||
"--order", "desc", "--page-all", "--page-limit", "5",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantBoundary := time.UnixMilli(cursorMillis).UTC().Format(time.RFC3339Nano)
|
||||
if len(caller.args) != 2 || caller.args[0]["time"] != "2026-01-04 00:00:00" ||
|
||||
caller.args[0]["forward"] != false || caller.args[1]["time"] != wantBoundary {
|
||||
t.Fatalf("range calls = %#v", caller.args)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["complete"] != true || payload["hasMore"] != false ||
|
||||
payload["count"] != float64(3) || payload["stopReason"] != "range_start" {
|
||||
t.Fatalf("range payload = %#v", payload)
|
||||
}
|
||||
messages := payload["messages"].([]any)
|
||||
if messages[0].(map[string]any)["messageId"] != "m3" ||
|
||||
messages[2].(map[string]any)["messageId"] != "m1" {
|
||||
t.Fatalf("descending messages = %#v", messages)
|
||||
}
|
||||
rangeMeta := payload["queryRange"].(map[string]any)
|
||||
if rangeMeta["semantics"] != "[start,end)" || rangeMeta["order"] != "desc" {
|
||||
t.Fatalf("queryRange = %#v", rangeMeta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesAscendingRangeStopsAtExclusiveEnd(t *testing.T) {
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 12:00:00"},{"openMessageId":"m2","createTime":"2026-01-02 00:00:00"},{"openMessageId":"m-end","createTime":"2026-01-03 00:00:00"}]}}`,
|
||||
}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-messages", "--conversation-id", "cid",
|
||||
"--start-time", "2026-01-01 12:00:00", "--end-time", "2026-01-03 00:00:00",
|
||||
"--sort", "asc", "--page-all",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.args) != 1 || caller.args[0]["time"] != "2026-01-01 12:00:00" ||
|
||||
caller.args[0]["forward"] != true {
|
||||
t.Fatalf("ascending call = %#v", caller.args)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["complete"] != true || payload["hasMore"] != false ||
|
||||
payload["count"] != float64(2) || payload["stopReason"] != "range_end" {
|
||||
t.Fatalf("ascending payload = %#v", payload)
|
||||
}
|
||||
messages := payload["messages"].([]any)
|
||||
if messages[0].(map[string]any)["messageId"] != "m1" ||
|
||||
messages[1].(map[string]any)["messageId"] != "m2" {
|
||||
t.Fatalf("ascending messages = %#v", messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesFirstReadFailureSkipsOptionalSenderResolution(t *testing.T) {
|
||||
caller := &chatMessagesPagingCaller{
|
||||
responses: []string{`{"result":[]}`},
|
||||
failAt: 1,
|
||||
}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-messages", "--conversation-id", "cid", "--page-all",
|
||||
"--sender-query", "张三",
|
||||
})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("first-page failure unexpectedly succeeded")
|
||||
}
|
||||
if len(caller.args) != 1 {
|
||||
t.Fatalf("calls after first read failure = %#v, want only the message read", caller.args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesRangeValidationStopsBeforeRead(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"--start", "2026-01-02", "--end", "2026-01-01"},
|
||||
{"--end", "2026-01-02", "--order", "asc"},
|
||||
{"--time", "2026-01-02", "--start", "2026-01-01"},
|
||||
{"--direction", "newer", "--start", "2026-01-01"},
|
||||
{"--start", "2026-01-01", "--start-time", "2026-01-01"},
|
||||
} {
|
||||
caller := &chatMessagesPagingCaller{responses: []string{`{"result":{"hasMore":false,"messages":[]}}`}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs(append([]string{"chat", "+chat-messages", "--conversation-id", "cid"}, args...))
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Errorf("invalid range succeeded: %v", args)
|
||||
}
|
||||
if len(caller.args) != 0 {
|
||||
t.Errorf("invalid range reached message read: %v calls=%#v", args, caller.args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesPageAllPublishesBoundedContinuation(t *testing.T) {
|
||||
const cursorMillis int64 = 1767225600123
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":true,"nextCursor":1767225600123,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`,
|
||||
}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
@@ -196,9 +350,10 @@ func TestCrossPlatformCoverageChatMessagesPageAllPublishesBoundedContinuation(t
|
||||
t.Fatal(err)
|
||||
}
|
||||
next, _ := payload["nextPage"].(map[string]any)
|
||||
wantBoundary := time.UnixMilli(cursorMillis).UTC().Format(time.RFC3339Nano)
|
||||
if payload["complete"] != false || payload["hasMore"] != true ||
|
||||
payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" ||
|
||||
next["time"] != "2026-01-01 00:00:00" {
|
||||
next["time"] != wantBoundary || next["nextCursor"] != float64(cursorMillis) {
|
||||
t.Fatalf("bounded payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
@@ -233,7 +388,7 @@ func TestCrossPlatformCoverageChatMessagesFailedPageDoesNotExportPartialLedger(t
|
||||
t.Chdir(t.TempDir())
|
||||
caller := &chatMessagesPagingCaller{
|
||||
responses: []string{
|
||||
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`,
|
||||
`{"result":{"hasMore":true,"nextCursor":1767225600123,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`,
|
||||
},
|
||||
failAt: 2,
|
||||
}
|
||||
@@ -332,3 +487,176 @@ func TestCrossPlatformCoverageChatMessagesExportRejectsNonJSONPlaceholder(t *tes
|
||||
t.Fatalf("placeholder output was created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func chatMessagesRuntimeForTest(t *testing.T, values map[string]string) *shortcut.RuntimeContext {
|
||||
t.Helper()
|
||||
root := newPlatformCoverageRoot()
|
||||
cmd, _, err := root.Find([]string{"chat", "+chat-messages"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, value := range values {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s=%q: %v", name, value, err)
|
||||
}
|
||||
}
|
||||
return shortcut.RuntimeContextForTest(cmd, ChatMessages)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesAdditionalValidationAndHelpers(t *testing.T) {
|
||||
for _, values := range []map[string]string{
|
||||
{"time": "2026-01-01", "start": "2026-01-01"},
|
||||
{"direction": "older", "start": "2026-01-01"},
|
||||
} {
|
||||
if err := validateChatMessages(chatMessagesRuntimeForTest(t, values)); err == nil {
|
||||
t.Fatalf("direct validation unexpectedly accepted %#v", values)
|
||||
}
|
||||
}
|
||||
if _, err := resolveChatMessagesRequest(chatMessagesRuntimeForTest(t, map[string]string{
|
||||
"conversation-id": "cid", "start": "not-a-time",
|
||||
})); err == nil {
|
||||
t.Fatal("invalid direct request range unexpectedly succeeded")
|
||||
}
|
||||
|
||||
helpers.InitDeps(&platformCoverageCaller{
|
||||
contactSearchResult: `{"result":[{"name":"测试用户甲"}]}`,
|
||||
})
|
||||
filter := resolveOptionalChatMessagesSenderFilter(chatMessagesRuntimeForTest(t, map[string]string{
|
||||
"sender-query": "测试用户甲",
|
||||
}))
|
||||
if filter.applied || filter.failure == nil {
|
||||
t.Fatalf("identity-free sender resolution = %#v", filter)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
value any
|
||||
wantError bool
|
||||
}{
|
||||
{name: "int", value: int(1234)},
|
||||
{name: "int32", value: int32(1234)},
|
||||
{name: "int64", value: int64(1234)},
|
||||
{name: "float32", value: float32(1024)},
|
||||
{name: "float64", value: float64(1234)},
|
||||
{name: "string", value: "1234"},
|
||||
{name: "float32 nan", value: float32(math.NaN()), wantError: true},
|
||||
{name: "float32 infinity", value: float32(math.Inf(1)), wantError: true},
|
||||
{name: "float64 fractional", value: 1.5, wantError: true},
|
||||
{name: "bad string", value: "opaque", wantError: true},
|
||||
{name: "missing", value: nil, wantError: true},
|
||||
{name: "zero", value: 0, wantError: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _, err := chatMessagesNextCursorBoundary(tc.value)
|
||||
if (err != nil) != tc.wantError {
|
||||
t.Fatalf("value=%#v err=%v wantError=%v", tc.value, err, tc.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesAdditionalCollectionEdges(t *testing.T) {
|
||||
runtimeWith := func(t *testing.T, caller *chatMessagesPagingCaller, values map[string]string) *shortcut.RuntimeContext {
|
||||
t.Helper()
|
||||
helpers.InitDeps(caller)
|
||||
return chatMessagesRuntimeForTest(t, values)
|
||||
}
|
||||
start := time.Date(2026, 1, 2, 0, 0, 0, 0, dingTalkMessageLocation)
|
||||
configuredRange := chatMessageTimeRange{configured: true, start: &start, order: "desc"}
|
||||
|
||||
t.Run("single-page range failures and terminal", func(t *testing.T) {
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":true,"nextCursor":1234,"messages":[{"openMessageId":"bad","createTime":"invalid"}]}}`,
|
||||
}}
|
||||
payload, _, err := collectOneChatMessagesPage(
|
||||
runtimeWith(t, caller, nil),
|
||||
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older", timeRange: configuredRange},
|
||||
)
|
||||
if err != nil || payload["stopReason"] != "time_filter_error" || payload["queryRange"] == nil {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
|
||||
caller = &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":true,"nextCursor":1234,"messages":[{"openMessageId":"old","createTime":"2026-01-01 00:00:00"}]}}`,
|
||||
}}
|
||||
payload, _, err = collectOneChatMessagesPage(
|
||||
runtimeWith(t, caller, nil),
|
||||
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older", timeRange: configuredRange},
|
||||
)
|
||||
if err != nil || payload["complete"] != true || payload["stopReason"] != "range_start" {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default page size and time-filter failure", func(t *testing.T) {
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":false,"messages":[]}}`,
|
||||
}}
|
||||
payload, _, err := collectAllChatMessages(
|
||||
runtimeWith(t, caller, nil),
|
||||
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older"},
|
||||
)
|
||||
if err != nil || payload["complete"] != true || caller.args[0]["limit"] != chatMessagesAllPageSize {
|
||||
t.Fatalf("payload=%#v calls=%#v err=%v", payload, caller.args, err)
|
||||
}
|
||||
|
||||
caller = &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":false,"messages":[{"openMessageId":"bad","createTime":"invalid"}]}}`,
|
||||
}}
|
||||
payload, _, err = collectAllChatMessages(
|
||||
runtimeWith(t, caller, nil),
|
||||
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older", timeRange: configuredRange},
|
||||
)
|
||||
if err == nil || payload["stopReason"] != "time_filter_error" {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("terminal result limit and unsafe continuation", func(t *testing.T) {
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m2","createTime":"2026-01-03 00:00:00"},{"openMessageId":"m1","createTime":"2026-01-02 00:00:00"},{"openMessageId":"old","createTime":"2026-01-01 00:00:00"}]}}`,
|
||||
}}
|
||||
payload, _, err := collectAllChatMessages(
|
||||
runtimeWith(t, caller, map[string]string{"max-results": "1"}),
|
||||
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older", timeRange: configuredRange},
|
||||
)
|
||||
if err != nil || payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
|
||||
caller = &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-01-03 00:00:00"}]}}`,
|
||||
}}
|
||||
payload, _, err = collectAllChatMessages(
|
||||
runtimeWith(t, caller, map[string]string{"max-results": "1"}),
|
||||
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older"},
|
||||
)
|
||||
if err == nil || payload["stopReason"] != "pagination_error" {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stalled cursor", func(t *testing.T) {
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"hasMore":true,"nextCursor":1234,"messages":[{"openMessageId":"m2"}]}}`,
|
||||
`{"result":{"hasMore":true,"nextCursor":1234,"messages":[{"openMessageId":"m1"}]}}`,
|
||||
}}
|
||||
payload, _, err := collectAllChatMessages(
|
||||
runtimeWith(t, caller, nil),
|
||||
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older"},
|
||||
)
|
||||
if err == nil || payload["stopReason"] != "pagination_error" || len(caller.args) != 2 {
|
||||
t.Fatalf("payload=%#v calls=%#v err=%v", payload, caller.args, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("first failure ledger output error", func(t *testing.T) {
|
||||
helpers.InitDeps(&chatMessagesPagingCaller{failAt: 1})
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(chatMessagesFailWriter{})
|
||||
root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--page-all"})
|
||||
if err := root.Execute(); err == nil || err.Error() != "fixture output failure" {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -37,11 +37,13 @@ type platformCoverageCall struct {
|
||||
}
|
||||
|
||||
type platformCoverageCaller struct {
|
||||
calls []platformCoverageCall
|
||||
dry bool
|
||||
contactSearchResult string
|
||||
aisearchResult string
|
||||
failTool string
|
||||
calls []platformCoverageCall
|
||||
dry bool
|
||||
contactSearchResult string
|
||||
aisearchResult string
|
||||
chatMessagesResult string
|
||||
searchMessagesResult string
|
||||
failTool string
|
||||
}
|
||||
|
||||
func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
@@ -65,6 +67,14 @@ func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool strin
|
||||
text = `{"result":{"userId":"u1"}}`
|
||||
case "im/search_groups":
|
||||
text = `{"result":[{"openConversationId":"cid-1","title":"项目冲刺"}]}`
|
||||
case "chat/list_conversation_message_v2":
|
||||
if f.chatMessagesResult != "" {
|
||||
text = f.chatMessagesResult
|
||||
}
|
||||
case "im/search_messages":
|
||||
if f.searchMessagesResult != "" {
|
||||
text = f.searchMessagesResult
|
||||
}
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
@@ -153,6 +163,8 @@ func TestCrossPlatformCoverageIMObservedCompatibilityAliasesConflictWithCanonica
|
||||
for _, args := range [][]string{
|
||||
{"chat", "+chat-messages", "--limit", "5", "--page-size", "5", "--conversation-id", "cid"},
|
||||
{"chat", "+search-msg", "--query", "评测", "--text", "评测", "--no-enrich"},
|
||||
{"chat", "+search-msg", "--query", "评测", "--start", "2026-01-01T00:00:00+08:00", "--start-time", "2026-01-01T00:00:00+08:00", "--end", "2026-01-02T00:00:00+08:00", "--no-enrich"},
|
||||
{"chat", "+search-msg", "--query", "评测", "--order", "asc", "--sort", "asc", "--no-enrich"},
|
||||
} {
|
||||
fake := &platformCoverageCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
@@ -296,7 +308,7 @@ func TestCrossPlatformCoverageBroadcastUsesEnterpriseAliasAndUserIDFallback(t *t
|
||||
dry: true,
|
||||
aisearchResult: `{"result":[{
|
||||
"userId":"user-alias",
|
||||
"meta":{"name":"柏荣","nick":"大柚"}
|
||||
"meta":{"name":"测试用户甲","nick":"测试花名甲"}
|
||||
}]}`,
|
||||
}
|
||||
helpers.InitDeps(fake)
|
||||
|
||||
@@ -458,11 +458,11 @@ func TestCrossPlatformCoverageChatMessagesValidationAndFailureBoundaries(t *test
|
||||
}{
|
||||
{name: "single read failure", failAt: 1, args: []string{"--group", "cid123456789"}, wantError: true},
|
||||
{name: "all first read failure", failAt: 1, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true},
|
||||
{name: "all later read failure", responses: []string{`{"result":{"messages":[{"openMessageId":"m1","createTime":"2"}],"hasMore":true}}`}, failAt: 2, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true},
|
||||
{name: "all later read failure", responses: []string{`{"result":{"messages":[{"openMessageId":"m1","createTime":"2"}],"hasMore":true,"nextCursor":1767225600123}}`}, failAt: 2, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true},
|
||||
{name: "missing pagination", responses: []string{`{"result":{"messages":[]}}`}, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true},
|
||||
{name: "empty page with continuation", responses: []string{`{"result":{"messages":[],"hasMore":true}}`}, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true},
|
||||
{name: "result limit without boundary", responses: []string{`{"result":{"messages":[{"openMessageId":"m1"},{"openMessageId":"m2"}],"hasMore":true}}`}, args: []string{"--group", "cid123456789", "--page-all", "--max-results", "1"}, wantError: true},
|
||||
{name: "result limit with boundary", responses: []string{`{"result":{"messages":[{"openMessageId":"m1","createTime":"2"},{"openMessageId":"m2","createTime":"1"}],"hasMore":true}}`}, args: []string{"--group", "cid123456789", "--page-all", "--max-results", "1"}},
|
||||
{name: "result limit with boundary", responses: []string{`{"result":{"messages":[{"openMessageId":"m1","createTime":"2"}],"hasMore":true,"nextCursor":1767225600123}}`}, args: []string{"--group", "cid123456789", "--page-all", "--max-results", "1"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &smartCoverageCaller{
|
||||
|
||||
@@ -6,7 +6,6 @@ package smart
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
@@ -19,13 +18,8 @@ func defaultChatPageLimit(value, fallback int) int {
|
||||
}
|
||||
|
||||
func validChatTime(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} {
|
||||
if _, err := time.Parse(layout, value); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
_, err := parseDingTalkMessageTime(strings.TrimSpace(value))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func localChatOptionError(reason, message string, flags ...string) error {
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
// 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 smart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
)
|
||||
|
||||
const messageTimeRangeSemantics = "[start,end)"
|
||||
|
||||
type chatMessageTimeRange struct {
|
||||
configured bool
|
||||
start *time.Time
|
||||
end *time.Time
|
||||
order string
|
||||
}
|
||||
|
||||
func resolveChatMessageTimeRange(rt *shortcut.RuntimeContext, now time.Time) (chatMessageTimeRange, error) {
|
||||
startValue := strings.TrimSpace(rt.StrFirst("start", "start-time"))
|
||||
endValue := strings.TrimSpace(rt.StrFirst("end", "end-time"))
|
||||
order := strings.ToLower(strings.TrimSpace(rt.StrFirst("order", "sort")))
|
||||
configured := startValue != "" || endValue != "" || order != ""
|
||||
if order == "" {
|
||||
order = "desc"
|
||||
}
|
||||
|
||||
result := chatMessageTimeRange{configured: configured, order: order}
|
||||
if startValue != "" {
|
||||
parsed, err := parseDingTalkMessageTime(startValue)
|
||||
if err != nil {
|
||||
return chatMessageTimeRange{}, localChatOptionError(
|
||||
"invalid_start_time", "+chat-messages 的开始时间格式无效", "--start/--start-time")
|
||||
}
|
||||
result.start = &parsed
|
||||
}
|
||||
if endValue != "" {
|
||||
parsed, err := parseDingTalkMessageTime(endValue)
|
||||
if err != nil {
|
||||
return chatMessageTimeRange{}, localChatOptionError(
|
||||
"invalid_end_time", "+chat-messages 的结束时间格式无效", "--end/--end-time")
|
||||
}
|
||||
result.end = &parsed
|
||||
}
|
||||
if result.start != nil && result.end != nil && !result.end.After(*result.start) {
|
||||
return chatMessageTimeRange{}, apperrors.NewValidation("--end/--end-time 必须晚于 --start/--start-time")
|
||||
}
|
||||
if order == "asc" && result.start == nil {
|
||||
return chatMessageTimeRange{}, apperrors.NewValidation("升序读取必须指定 --start/--start-time,避免把最近一页倒序后误报为最早消息")
|
||||
}
|
||||
if result.start != nil && result.end == nil {
|
||||
effectiveEnd := now
|
||||
result.end = &effectiveEnd
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r chatMessageTimeRange) initialBoundary(now time.Time) string {
|
||||
if !r.configured {
|
||||
return formatDingTalkMessageBoundary(now.Truncate(time.Second))
|
||||
}
|
||||
if r.order == "asc" && r.start != nil {
|
||||
return formatDingTalkMessageBoundary(*r.start)
|
||||
}
|
||||
if r.end != nil {
|
||||
return formatDingTalkMessageBoundary(*r.end)
|
||||
}
|
||||
return formatDingTalkMessageBoundary(now)
|
||||
}
|
||||
|
||||
func (r chatMessageTimeRange) direction() string {
|
||||
if r.order == "asc" {
|
||||
return "newer"
|
||||
}
|
||||
return "older"
|
||||
}
|
||||
|
||||
func (r chatMessageTimeRange) stopReason() string {
|
||||
if r.order == "asc" {
|
||||
return "range_end"
|
||||
}
|
||||
return "range_start"
|
||||
}
|
||||
|
||||
func (r chatMessageTimeRange) metadata() map[string]any {
|
||||
if !r.configured {
|
||||
return nil
|
||||
}
|
||||
result := map[string]any{
|
||||
"order": r.order,
|
||||
"semantics": messageTimeRangeSemantics,
|
||||
}
|
||||
if r.start != nil {
|
||||
result["startTime"] = r.start.Format(time.RFC3339Nano)
|
||||
}
|
||||
if r.end != nil {
|
||||
result["endTime"] = r.end.Format(time.RFC3339Nano)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (r chatMessageTimeRange) filter(items []map[string]any) ([]map[string]any, bool, []map[string]any) {
|
||||
if r.start == nil && r.end == nil {
|
||||
return items, false, nil
|
||||
}
|
||||
filtered := make([]map[string]any, 0, len(items))
|
||||
unknown := make([]string, 0)
|
||||
terminalReached := false
|
||||
for _, item := range items {
|
||||
messageTime, ok := parseMessageTimestamp(chatmsg.CreateTime(item))
|
||||
if !ok {
|
||||
unknown = append(unknown, chatmsg.StableMessageID(item))
|
||||
continue
|
||||
}
|
||||
if r.start != nil && messageTime.Before(*r.start) {
|
||||
if r.order == "desc" {
|
||||
terminalReached = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if r.end != nil && !messageTime.Before(*r.end) {
|
||||
if r.order == "asc" {
|
||||
terminalReached = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
if len(unknown) == 0 {
|
||||
return filtered, terminalReached, nil
|
||||
}
|
||||
return filtered, terminalReached, []map[string]any{{
|
||||
"stage": "time_filter",
|
||||
"messageIds": unknown,
|
||||
"error": "消息缺少可解析的 createTime,无法证明时间范围结果完整",
|
||||
}}
|
||||
}
|
||||
|
||||
func parseDingTalkMessageTime(value string) (time.Time, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02"} {
|
||||
if parsed, err := time.ParseInLocation(layout, value, dingTalkMessageLocation); err == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("unsupported message time %q", value)
|
||||
}
|
||||
|
||||
func parseMessageTimestamp(value any) (time.Time, bool) {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(typed)
|
||||
if parsed, err := parseDingTalkMessageTime(trimmed); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
if numeric, err := strconv.ParseInt(trimmed, 10, 64); err == nil {
|
||||
return unixMessageTime(numeric), true
|
||||
}
|
||||
case json.Number:
|
||||
if numeric, err := typed.Int64(); err == nil {
|
||||
return unixMessageTime(numeric), true
|
||||
}
|
||||
case float64:
|
||||
return unixMessageTime(int64(typed)), true
|
||||
case float32:
|
||||
return unixMessageTime(int64(typed)), true
|
||||
case int:
|
||||
return unixMessageTime(int64(typed)), true
|
||||
case int64:
|
||||
return unixMessageTime(typed), true
|
||||
case int32:
|
||||
return unixMessageTime(int64(typed)), true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func unixMessageTime(value int64) time.Time {
|
||||
if value > -1_000_000_000_000 && value < 1_000_000_000_000 {
|
||||
return time.Unix(value, 0)
|
||||
}
|
||||
return time.UnixMilli(value)
|
||||
}
|
||||
|
||||
func sortMessagesByCreateTimeStable(messages []map[string]any, order string) {
|
||||
order = strings.ToLower(strings.TrimSpace(order))
|
||||
if order != "asc" && order != "desc" {
|
||||
return
|
||||
}
|
||||
sort.SliceStable(messages, func(i, j int) bool {
|
||||
left, leftOK := parseMessageTimestamp(chatmsg.CreateTime(messages[i]))
|
||||
right, rightOK := parseMessageTimestamp(chatmsg.CreateTime(messages[j]))
|
||||
if leftOK != rightOK {
|
||||
return leftOK
|
||||
}
|
||||
if !leftOK || left.Equal(right) {
|
||||
return false
|
||||
}
|
||||
if order == "asc" {
|
||||
return left.Before(right)
|
||||
}
|
||||
return left.After(right)
|
||||
})
|
||||
}
|
||||
|
||||
func searchMessageQueryRange(params map[string]any, order string) map[string]any {
|
||||
result := map[string]any{
|
||||
"order": order,
|
||||
"semantics": messageTimeRangeSemantics,
|
||||
}
|
||||
if start, ok := numericMillis(params["startTime"]); ok {
|
||||
result["startTime"] = time.UnixMilli(start).In(dingTalkMessageLocation).Format(time.RFC3339)
|
||||
}
|
||||
if end, ok := numericMillis(params["endTime"]); ok {
|
||||
result["endTime"] = time.UnixMilli(end).In(dingTalkMessageLocation).Format(time.RFC3339)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func numericMillis(value any) (int64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case int64:
|
||||
return typed, true
|
||||
case int:
|
||||
return int64(typed), true
|
||||
case float64:
|
||||
return int64(typed), true
|
||||
case json.Number:
|
||||
parsed, err := typed.Int64()
|
||||
return parsed, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// 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 smart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func messageTimeRangeRuntime(t *testing.T, values map[string]string) *shortcut.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
for _, name := range []string{"start", "start-time", "end", "end-time", "order", "sort"} {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
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, shortcut.Shortcut{})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveMessageTimeRange(t *testing.T) {
|
||||
now := time.Date(2026, 8, 7, 12, 0, 0, 0, dingTalkMessageLocation)
|
||||
|
||||
plain, err := resolveChatMessageTimeRange(messageTimeRangeRuntime(t, nil), now)
|
||||
if err != nil || plain.configured || plain.order != "desc" || plain.start != nil || plain.end != nil {
|
||||
t.Fatalf("plain range = %#v, %v", plain, err)
|
||||
}
|
||||
if plain.metadata() != nil || plain.initialBoundary(now) != formatDingTalkMessageBoundary(now) {
|
||||
t.Fatalf("plain metadata/boundary = %#v, %q", plain.metadata(), plain.initialBoundary(now))
|
||||
}
|
||||
orderOnly, err := resolveChatMessageTimeRange(messageTimeRangeRuntime(t, map[string]string{
|
||||
"order": "desc",
|
||||
}), now)
|
||||
if err != nil || !orderOnly.configured || orderOnly.start != nil || orderOnly.end != nil ||
|
||||
orderOnly.initialBoundary(now) != formatDingTalkMessageBoundary(now) {
|
||||
t.Fatalf("order-only range = %#v, %v", orderOnly, err)
|
||||
}
|
||||
|
||||
ascending, err := resolveChatMessageTimeRange(messageTimeRangeRuntime(t, map[string]string{
|
||||
"start-time": "2026-08-01",
|
||||
"end-time": "2026-08-03 00:00:00",
|
||||
"sort": " ASC ",
|
||||
}), now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ascending.direction() != "newer" || ascending.stopReason() != "range_end" {
|
||||
t.Fatalf("ascending direction/reason = %q, %q", ascending.direction(), ascending.stopReason())
|
||||
}
|
||||
if ascending.initialBoundary(now) != formatDingTalkMessageBoundary(*ascending.start) {
|
||||
t.Fatalf("ascending boundary = %q", ascending.initialBoundary(now))
|
||||
}
|
||||
metadata := ascending.metadata()
|
||||
if metadata["order"] != "asc" || metadata["semantics"] != messageTimeRangeSemantics || metadata["startTime"] == nil || metadata["endTime"] == nil {
|
||||
t.Fatalf("ascending metadata = %#v", metadata)
|
||||
}
|
||||
|
||||
startOnly, err := resolveChatMessageTimeRange(messageTimeRangeRuntime(t, map[string]string{
|
||||
"start": "2026-08-01T00:00:00+08:00",
|
||||
}), now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if startOnly.end == nil || !startOnly.end.Equal(now) || startOnly.direction() != "older" || startOnly.stopReason() != "range_start" {
|
||||
t.Fatalf("start-only range = %#v", startOnly)
|
||||
}
|
||||
if startOnly.initialBoundary(now) != formatDingTalkMessageBoundary(now) {
|
||||
t.Fatalf("start-only boundary = %q", startOnly.initialBoundary(now))
|
||||
}
|
||||
|
||||
fractional, err := resolveChatMessageTimeRange(messageTimeRangeRuntime(t, map[string]string{
|
||||
"start": "2026-08-02T00:00:00.125+08:00",
|
||||
"end": "2026-08-03T00:00:00.500+08:00",
|
||||
}), now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := fractional.initialBoundary(now); got != "2026-08-03T00:00:00.5+08:00" {
|
||||
t.Fatalf("fractional boundary = %q", got)
|
||||
}
|
||||
fractionalMetadata := fractional.metadata()
|
||||
if fractionalMetadata["startTime"] != "2026-08-02T00:00:00.125+08:00" ||
|
||||
fractionalMetadata["endTime"] != "2026-08-03T00:00:00.5+08:00" {
|
||||
t.Fatalf("fractional metadata = %#v", fractionalMetadata)
|
||||
}
|
||||
|
||||
for name, values := range map[string]map[string]string{
|
||||
"invalid start": {"start": "not-a-time"},
|
||||
"invalid end": {"end": "not-a-time"},
|
||||
"reversed": {"start": "2026-08-03", "end": "2026-08-02"},
|
||||
"asc no start": {"end": "2026-08-03", "order": "asc"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := resolveChatMessageTimeRange(messageTimeRangeRuntime(t, values), now); err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessageTimeRangeParsesLocalAndEpochTimes(t *testing.T) {
|
||||
local, err := parseDingTalkMessageTime("2026-01-02 03:04:05")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if local.Location().String() != "CST" || local.Format(time.RFC3339) != "2026-01-02T03:04:05+08:00" {
|
||||
t.Fatalf("local time = %s (%s)", local.Format(time.RFC3339), local.Location())
|
||||
}
|
||||
for _, value := range []any{
|
||||
local.Unix(), int(local.Unix()), int32(local.Unix()),
|
||||
local.UnixMilli(), float64(local.UnixMilli()), json.Number("1767294245000"), "1767294245000",
|
||||
} {
|
||||
parsed, ok := parseMessageTimestamp(value)
|
||||
if !ok || parsed.Unix() != local.Unix() {
|
||||
t.Fatalf("parse %v = %s, %v", value, parsed, ok)
|
||||
}
|
||||
}
|
||||
float32Seconds := float32(local.Unix())
|
||||
if parsed, ok := parseMessageTimestamp(float32Seconds); !ok || parsed.Unix() != int64(float32Seconds) {
|
||||
t.Fatalf("parse float32 %v = %s, %v", float32Seconds, parsed, ok)
|
||||
}
|
||||
if parsed, err := parseDingTalkMessageTime("2026-01-02T03:04:05+08:00"); err != nil || parsed.Unix() != local.Unix() {
|
||||
t.Fatalf("RFC3339 parse = %s, %v", parsed, err)
|
||||
}
|
||||
if _, err := parseDingTalkMessageTime("unsupported"); err == nil {
|
||||
t.Fatal("unsupported time unexpectedly parsed")
|
||||
}
|
||||
for _, value := range []any{json.Number("bad"), "bad", true, nil} {
|
||||
if parsed, ok := parseMessageTimestamp(value); ok || !parsed.IsZero() {
|
||||
t.Fatalf("invalid timestamp %v = %s, %v", value, parsed, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessageTimeRangeStableSortKeepsUnknownRows(t *testing.T) {
|
||||
messages := []map[string]any{
|
||||
{"openMessageId": "unknown"},
|
||||
{"openMessageId": "later", "createTime": "2026-01-02 00:00:00"},
|
||||
{"openMessageId": "same-a", "createTime": "2026-01-01 00:00:00"},
|
||||
{"openMessageId": "same-b", "createTime": "2026-01-01 00:00:00"},
|
||||
}
|
||||
sortMessagesByCreateTimeStable(messages, "asc")
|
||||
want := []string{"same-a", "same-b", "later", "unknown"}
|
||||
for index, id := range want {
|
||||
if messages[index]["openMessageId"] != id {
|
||||
t.Fatalf("sorted messages = %#v", messages)
|
||||
}
|
||||
}
|
||||
sortMessagesByCreateTimeStable(messages, "desc")
|
||||
if messages[0]["openMessageId"] != "later" || messages[len(messages)-1]["openMessageId"] != "unknown" {
|
||||
t.Fatalf("descending messages = %#v", messages)
|
||||
}
|
||||
before := append([]map[string]any(nil), messages...)
|
||||
sortMessagesByCreateTimeStable(messages, "sideways")
|
||||
for index := range before {
|
||||
if before[index]["openMessageId"] != messages[index]["openMessageId"] {
|
||||
t.Fatalf("invalid-order sort changed messages = %#v", messages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessageTimeRangeFiltering(t *testing.T) {
|
||||
start, _ := parseDingTalkMessageTime("2026-08-02 00:00:00")
|
||||
end, _ := parseDingTalkMessageTime("2026-08-04 00:00:00")
|
||||
items := []map[string]any{
|
||||
{"openMessageId": "before", "createTime": "2026-08-01 00:00:00"},
|
||||
{"openMessageId": "inside", "createTime": "2026-08-03 00:00:00"},
|
||||
{"openMessageId": "end", "createTime": "2026-08-04 00:00:00"},
|
||||
{"openMessageId": "unknown"},
|
||||
}
|
||||
|
||||
plain := chatMessageTimeRange{}
|
||||
plainItems, terminal, failures := plain.filter(items)
|
||||
if len(plainItems) != len(items) || terminal || failures != nil {
|
||||
t.Fatalf("plain filter = %#v, %v, %#v", plainItems, terminal, failures)
|
||||
}
|
||||
|
||||
desc := chatMessageTimeRange{start: &start, end: &end, order: "desc"}
|
||||
filtered, terminal, failures := desc.filter(items)
|
||||
if len(filtered) != 1 || filtered[0]["openMessageId"] != "inside" || !terminal || len(failures) != 1 {
|
||||
t.Fatalf("descending filter = %#v, %v, %#v", filtered, terminal, failures)
|
||||
}
|
||||
|
||||
asc := chatMessageTimeRange{start: &start, end: &end, order: "asc"}
|
||||
filtered, terminal, failures = asc.filter(items[1:3])
|
||||
if len(filtered) != 1 || filtered[0]["openMessageId"] != "inside" || !terminal || failures != nil {
|
||||
t.Fatalf("ascending filter = %#v, %v, %#v", filtered, terminal, failures)
|
||||
}
|
||||
|
||||
filtered, terminal, failures = desc.filter(items[1:2])
|
||||
if len(filtered) != 1 || terminal || failures != nil {
|
||||
t.Fatalf("clean filter = %#v, %v, %#v", filtered, terminal, failures)
|
||||
}
|
||||
|
||||
fractionalStart, _ := parseDingTalkMessageTime("2026-08-03T00:00:00+08:00")
|
||||
fractionalEnd, _ := parseDingTalkMessageTime("2026-08-03T00:00:00.500+08:00")
|
||||
fractionalRange := chatMessageTimeRange{start: &fractionalStart, end: &fractionalEnd, order: "desc"}
|
||||
fractionalItems := []map[string]any{
|
||||
{"openMessageId": "inside-fraction", "createTime": "2026-08-03T00:00:00.250+08:00"},
|
||||
{"openMessageId": "end-fraction", "createTime": "2026-08-03T00:00:00.500+08:00"},
|
||||
}
|
||||
filtered, terminal, failures = fractionalRange.filter(fractionalItems)
|
||||
if len(filtered) != 1 || filtered[0]["openMessageId"] != "inside-fraction" || terminal || failures != nil {
|
||||
t.Fatalf("fractional filter = %#v, %v, %#v", filtered, terminal, failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMessageQueryRangeNumericInputs(t *testing.T) {
|
||||
start := time.Date(2026, 8, 1, 0, 0, 0, 0, dingTalkMessageLocation).UnixMilli()
|
||||
end := time.Date(2026, 8, 2, 0, 0, 0, 0, dingTalkMessageLocation).UnixMilli()
|
||||
queryRange := searchMessageQueryRange(map[string]any{
|
||||
"startTime": int(start),
|
||||
"endTime": end,
|
||||
}, "asc")
|
||||
if queryRange["order"] != "asc" || queryRange["semantics"] != messageTimeRangeSemantics || queryRange["startTime"] == nil || queryRange["endTime"] == nil {
|
||||
t.Fatalf("query range = %#v", queryRange)
|
||||
}
|
||||
|
||||
for _, value := range []any{int64(start), int(start), float64(start), json.Number("1785513600000")} {
|
||||
if parsed, ok := numericMillis(value); !ok || parsed != start {
|
||||
t.Fatalf("numericMillis(%T) = %d, %v", value, parsed, ok)
|
||||
}
|
||||
}
|
||||
for _, value := range []any{json.Number("bad"), "1785513600000", nil} {
|
||||
if parsed, ok := numericMillis(value); ok || parsed != 0 {
|
||||
t.Fatalf("invalid numericMillis(%T) = %d, %v", value, parsed, ok)
|
||||
}
|
||||
}
|
||||
queryRange = searchMessageQueryRange(map[string]any{"startTime": "bad", "endTime": true}, "desc")
|
||||
if len(queryRange) != 2 {
|
||||
t.Fatalf("invalid numeric query range = %#v", queryRange)
|
||||
}
|
||||
}
|
||||
@@ -14,16 +14,23 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
)
|
||||
|
||||
const (
|
||||
myGroupsDefaultPageLimit = 50
|
||||
myGroupsHardPageLimit = 500
|
||||
)
|
||||
|
||||
// MyGroups: list the groups I've joined and project just the key fields
|
||||
// (会话id / 名称 / 群主 / 人数 / 类型) into a clean, composed payload — instead of
|
||||
// paging through `chat group list-all` and squinting at the raw MCP response.
|
||||
@@ -79,48 +86,204 @@ var MyGroups = shortcut.Shortcut{
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "type", Type: shortcut.FlagString, Desc: "按群类型过滤(可选,如返回中的 groupType/conversationType,大小写不敏感)", Required: false},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 200)", Default: "200"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 200);--limit 必须在 1-200 之间", Default: "200"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部已加入群;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-200 之间"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws chat +my-groups`,
|
||||
`dws chat +my-groups --type group`,
|
||||
`dws chat +my-groups --page-all --page-limit 50`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
// Step 1 — list my groups. `limit` mirrors chat.go's list_my_groups_pagination
|
||||
// call site (chat group list-all); pass a generous page size.
|
||||
params := map[string]any{"limit": rt.Int("limit")}
|
||||
if cursor := strings.TrimSpace(rt.Str("cursor")); cursor != "" && cursor != "0" {
|
||||
params["cursor"] = cursor
|
||||
Validate: validateMyGroups,
|
||||
Execute: executeMyGroups,
|
||||
}
|
||||
|
||||
func validateMyGroups(rt *shortcut.RuntimeContext) error {
|
||||
if limit := rt.Int("limit"); limit < 1 || limit > 200 {
|
||||
return apperrors.NewValidation("--limit 必须在 1-200 之间")
|
||||
}
|
||||
if !rt.Bool("page-all") && rt.Changed("page-limit") {
|
||||
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
|
||||
}
|
||||
if rt.Bool("page-all") {
|
||||
if limit := rt.Int("page-limit"); limit < 1 || limit > myGroupsHardPageLimit {
|
||||
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeMyGroups(rt *shortcut.RuntimeContext) error {
|
||||
// Step 1 — list my groups. `limit` mirrors chat.go's list_my_groups_pagination
|
||||
// call site (chat group list-all); pass a generous page size.
|
||||
params := map[string]any{"limit": rt.Int("limit")}
|
||||
if cursor := strings.TrimSpace(rt.Str("cursor")); cursor != "" && cursor != "0" {
|
||||
params["cursor"] = cursor
|
||||
}
|
||||
if rt.Bool("page-all") {
|
||||
payload, err := readAllMyGroups(rt, params)
|
||||
if outputErr := rt.Output(payload); outputErr != nil {
|
||||
return outputErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
data, err := rt.CallMCPData("im", "list_my_groups_pagination", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := myGroupsPayload(rt, myGroupsExtract(data))
|
||||
chatmsg.ApplyPagination(payload, data)
|
||||
payload["pagesFetched"] = 1
|
||||
if payload["complete"] == true {
|
||||
payload["stopReason"] = "source_complete"
|
||||
} else {
|
||||
payload["stopReason"] = "single_page"
|
||||
}
|
||||
return rt.Output(payload)
|
||||
}
|
||||
|
||||
func myGroupsPayload(rt *shortcut.RuntimeContext, groups []map[string]any) map[string]any {
|
||||
typeFilter := strings.TrimSpace(rt.Str("type"))
|
||||
projected := make([]map[string]any, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
row := myGroupsProject(group)
|
||||
if typeFilter != "" {
|
||||
groupType, _ := row["type"].(string)
|
||||
if !strings.EqualFold(strings.TrimSpace(groupType), typeFilter) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
projected = append(projected, row)
|
||||
}
|
||||
return map[string]any{"count": len(projected), "groups": projected}
|
||||
}
|
||||
|
||||
func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (map[string]any, error) {
|
||||
pageLimit := defaultChatPageLimit(rt.Int("page-limit"), myGroupsDefaultPageLimit)
|
||||
cursorValue := baseParams["cursor"]
|
||||
cursorKey := myGroupsCursorString(cursorValue)
|
||||
if cursorKey == "" {
|
||||
cursorKey = "0"
|
||||
}
|
||||
seenCursors := map[string]bool{cursorKey: true}
|
||||
seenGroups := map[string]bool{}
|
||||
allGroups := make([]map[string]any, 0)
|
||||
failures := make([]map[string]any, 0)
|
||||
pagesFetched := 0
|
||||
complete := false
|
||||
hasMore := false
|
||||
stopReason := "source_complete"
|
||||
truncatedByPageLimit := false
|
||||
var nextCursor any
|
||||
|
||||
for pagesFetched < pageLimit {
|
||||
params := map[string]any{"limit": baseParams["limit"]}
|
||||
if cursorKey != "0" {
|
||||
params["cursor"] = cursorValue
|
||||
}
|
||||
data, err := rt.CallMCPData("im", "list_my_groups_pagination", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 2 — project key fields defensively.
|
||||
groups := myGroupsExtract(data)
|
||||
typeFilter := strings.TrimSpace(rt.Str("type"))
|
||||
|
||||
projected := make([]map[string]any, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
row := myGroupsProject(g)
|
||||
if typeFilter != "" {
|
||||
gt, _ := row["type"].(string)
|
||||
if !strings.EqualFold(strings.TrimSpace(gt), typeFilter) {
|
||||
continue
|
||||
}
|
||||
if pagesFetched == 0 {
|
||||
return nil, err
|
||||
}
|
||||
projected = append(projected, row)
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched + 1, "stage": "read", "cursor": cursorKey, "error": err.Error(),
|
||||
})
|
||||
stopReason = "read_failure"
|
||||
break
|
||||
}
|
||||
pagesFetched++
|
||||
pageGroups := myGroupsExtract(data)
|
||||
for _, group := range pageGroups {
|
||||
id := myGroupsStr(group, "openConversationId", "openConversationID", "conversationId", "openCid", "cid", "id")
|
||||
if id != "" && seenGroups[id] {
|
||||
continue
|
||||
}
|
||||
if id != "" {
|
||||
seenGroups[id] = true
|
||||
}
|
||||
allGroups = append(allGroups, group)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"count": len(projected),
|
||||
"groups": projected,
|
||||
page := chatmsg.Pagination(data)
|
||||
pageHasMore, known := page["hasMore"].(bool)
|
||||
if !known {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "我的群列表下层未返回可靠的 hasMore,无法证明结果完整",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
chatmsg.ApplyPagination(payload, data)
|
||||
return rt.Output(payload)
|
||||
},
|
||||
hasMore = pageHasMore
|
||||
if !hasMore {
|
||||
complete = true
|
||||
nextCursor = nil
|
||||
stopReason = "source_complete"
|
||||
break
|
||||
}
|
||||
nextCursor = page["nextCursor"]
|
||||
nextKey := myGroupsCursorString(nextCursor)
|
||||
if nextKey == "" || seenCursors[nextKey] {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "我的群列表下层返回 hasMore=true,但 nextCursor 缺失、无效或未前进",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
seenCursors[nextKey] = true
|
||||
cursorKey = nextKey
|
||||
cursorValue = nextCursor
|
||||
}
|
||||
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
|
||||
truncatedByPageLimit = true
|
||||
stopReason = "page_limit"
|
||||
}
|
||||
|
||||
payload := myGroupsPayload(rt, allGroups)
|
||||
payload["pagesFetched"] = pagesFetched
|
||||
payload["paginationKnown"] = true
|
||||
payload["complete"] = complete && len(failures) == 0
|
||||
payload["hasMore"] = hasMore
|
||||
payload["stopReason"] = stopReason
|
||||
payload["truncatedByPageLimit"] = truncatedByPageLimit
|
||||
payload["failedCount"] = len(failures)
|
||||
payload["failures"] = failures
|
||||
payload["partial"] = len(failures) > 0 && len(allGroups) > 0
|
||||
if hasMore && nextCursor != nil {
|
||||
payload["nextCursor"] = nextCursor
|
||||
}
|
||||
if len(failures) == 0 {
|
||||
return payload, nil
|
||||
}
|
||||
return payload, apperrors.NewAPI(
|
||||
fmt.Sprintf("我的群列表分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)),
|
||||
apperrors.WithOperation("im/list_my_groups_pagination"),
|
||||
apperrors.WithReason("my_groups_incomplete"),
|
||||
apperrors.WithOrigin("mcp_gateway"),
|
||||
apperrors.WithFailureStage("pagination"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(true),
|
||||
apperrors.WithHint("请根据 failures 和 nextCursor 重试"),
|
||||
)
|
||||
}
|
||||
|
||||
func myGroupsCursorString(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprint(value))
|
||||
if text == "" || text == "<nil>" || text == "0" {
|
||||
return ""
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// myGroupsExtract walks a list_my_groups_pagination response and returns its
|
||||
|
||||
@@ -5,13 +5,82 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageNaturalSearchAndSenderFlagsStayPublicOptional(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flags []shortcut.Flag
|
||||
want []string
|
||||
}{
|
||||
{name: "search natural adapters", flags: SearchMsg.Flags, want: []string{"chat-query", "sender-query"}},
|
||||
{name: "chat messages natural adapters", flags: ChatMessages.Flags, want: []string{"chat-query", "sender-query"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, name := range tc.want {
|
||||
found := false
|
||||
for _, flag := range tc.flags {
|
||||
if flag.Name != name {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if flag.Hidden {
|
||||
t.Fatalf("--%s unexpectedly became hidden", name)
|
||||
}
|
||||
if flag.Required {
|
||||
t.Fatalf("--%s unexpectedly became required", name)
|
||||
}
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("public optional flag --%s is missing", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessageTimeAlignmentFlagsStayPublicOptional(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
flags []shortcut.Flag
|
||||
}{
|
||||
{name: "search message", flags: SearchMsg.Flags},
|
||||
{name: "conversation message", flags: ChatMessages.Flags},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for _, name := range []string{"start", "start-time", "end", "end-time", "order", "sort"} {
|
||||
found := false
|
||||
for _, flag := range tc.flags {
|
||||
if flag.Name != name {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if flag.Hidden || flag.Required {
|
||||
t.Fatalf("--%s must stay public and optional: %#v", name, flag)
|
||||
}
|
||||
if (name == "order" || name == "sort") && !reflect.DeepEqual(flag.Enum, []string{"asc", "desc"}) {
|
||||
t.Fatalf("--%s enum = %#v", name, flag.Enum)
|
||||
}
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("public optional flag --%s is missing", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesResolvesNaturalChatAndUserTargets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -76,6 +145,103 @@ func TestCrossPlatformCoverageChatMessagesStableGroupBypassesNaturalResolution(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesOptionallyFiltersResolvedSenderByEitherStableID(t *testing.T) {
|
||||
fake := &platformCoverageCaller{
|
||||
contactSearchResult: `{"result":[{"name":"测试用户甲","userId":"uid-fixture-sender-1","openDingTalkId":"D1"}]}`,
|
||||
chatMessagesResult: `{"result":{"hasMore":false,"messages":[
|
||||
{"openMessageId":"m-open","sender":"群昵称甲","senderOpenDingTalkId":"D1","content":"open id match"},
|
||||
{"openMessageId":"m-user","sender":"群昵称甲","senderUserId":"uid-fixture-sender-1","content":"user id match"},
|
||||
{"openMessageId":"m-other","sender":"其他人","senderOpenDingTalkId":"D2","content":"other"}
|
||||
]}}`,
|
||||
}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-messages", "--group", "cid-fixture-chat-0001",
|
||||
"--sender-query", "测试用户甲", "--page-all",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 2 || fake.calls[0].tool != "list_conversation_message_v2" ||
|
||||
fake.calls[1].tool != "search_contact_by_key_word" {
|
||||
t.Fatalf("calls = %#v, want message read followed by optional sender resolve", fake.calls)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode output: %v\n%s", err, output.String())
|
||||
}
|
||||
if payload["complete"] != true || payload["count"] != float64(2) {
|
||||
t.Fatalf("filtered payload = %#v", payload)
|
||||
}
|
||||
filters, ok := payload["resolvedFilters"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("resolvedFilters missing: %#v", payload)
|
||||
}
|
||||
senders, ok := filters["senders"].([]any)
|
||||
if !ok || len(senders) != 1 {
|
||||
t.Fatalf("resolved senders = %#v", filters["senders"])
|
||||
}
|
||||
selected := senders[0].(map[string]any)["selected"].(map[string]any)
|
||||
if selected["userId"] != "uid-fixture-sender-1" || selected["openDingTalkId"] != "D1" {
|
||||
t.Fatalf("selected identity = %#v", selected)
|
||||
}
|
||||
messages, ok := payload["messages"].([]any)
|
||||
if !ok || len(messages) != 2 {
|
||||
t.Fatalf("messages = %#v", payload["messages"])
|
||||
}
|
||||
for _, raw := range messages {
|
||||
message := raw.(map[string]any)
|
||||
if message["sender"] != "群昵称甲" || message["senderId"] == "D2" {
|
||||
t.Fatalf("unexpected filtered message = %#v", message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesSenderResolutionFailureKeepsUnfilteredMessages(t *testing.T) {
|
||||
fake := &platformCoverageCaller{
|
||||
contactSearchResult: `{"result":[]}`,
|
||||
chatMessagesResult: `{"result":{"hasMore":false,"messages":[
|
||||
{"openMessageId":"m1","sender":"群昵称甲","senderOpenDingTalkId":"D1","content":"仍然返回"}
|
||||
]}}`,
|
||||
}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+chat-messages", "--group", "cid-fixture-chat-0001",
|
||||
"--sender-query", "不存在的人", "--page-all",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("optional sender failure stopped message read: %v", err)
|
||||
}
|
||||
if len(fake.calls) != 2 || fake.calls[0].tool != "list_conversation_message_v2" ||
|
||||
fake.calls[1].tool != "search_contact_by_key_word" {
|
||||
t.Fatalf("calls = %#v, want message read followed by non-blocking failed resolve", fake.calls)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode output: %v\n%s", err, output.String())
|
||||
}
|
||||
if payload["count"] != float64(1) || payload["complete"] != false ||
|
||||
payload["partial"] != true || payload["failedCount"] != float64(1) ||
|
||||
payload["stopReason"] != "sender_resolution_failed" {
|
||||
t.Fatalf("unfiltered fallback payload = %#v", payload)
|
||||
}
|
||||
if _, exists := payload["resolvedFilters"]; exists {
|
||||
t.Fatalf("failed resolution unexpectedly published resolvedFilters: %#v", payload)
|
||||
}
|
||||
failures, ok := payload["failures"].([]any)
|
||||
if !ok || len(failures) != 1 || failures[0].(map[string]any)["stage"] != "sender_resolution" {
|
||||
t.Fatalf("resolution failures = %#v", payload["failures"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessagesNaturalUserAmbiguityStopsBeforeMessageRead(t *testing.T) {
|
||||
fake := &platformCoverageCaller{contactSearchResult: `{"result":[{"name":"张三","userId":"u1","openDingTalkId":"D1"},{"name":"张三","userId":"u2","openDingTalkId":"D2"}]}`}
|
||||
helpers.InitDeps(fake)
|
||||
@@ -172,6 +338,47 @@ func TestCrossPlatformCoverageSearchMsgResolvesNaturalChatAndSenderBeforeSearch(
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgPreservesResolvedSenderWhenDisplayNameDiffers(t *testing.T) {
|
||||
fake := &platformCoverageCaller{
|
||||
contactSearchResult: `{"result":[{"name":"测试用户甲","userId":"uid-fixture-sender-1","openDingTalkId":"D1"}]}`,
|
||||
searchMessagesResult: `{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-1","sender":"群昵称甲","senderOpenDingTalkId":"D1","content":"评测消息"}],"hasMore":false}}`,
|
||||
}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+search-msg", "--sender-query", "测试用户甲", "--no-enrich"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode output: %v\n%s", err, output.String())
|
||||
}
|
||||
filters, ok := payload["resolvedFilters"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("resolvedFilters missing: %#v", payload)
|
||||
}
|
||||
senders, ok := filters["senders"].([]any)
|
||||
if !ok || len(senders) != 1 {
|
||||
t.Fatalf("resolved senders = %#v", filters["senders"])
|
||||
}
|
||||
resolution := senders[0].(map[string]any)
|
||||
selected := resolution["selected"].(map[string]any)
|
||||
if resolution["query"] != "测试用户甲" || selected["openDingTalkId"] != "D1" {
|
||||
t.Fatalf("sender resolution = %#v", resolution)
|
||||
}
|
||||
messages, ok := payload["messages"].([]any)
|
||||
if !ok || len(messages) != 1 {
|
||||
t.Fatalf("messages = %#v", payload["messages"])
|
||||
}
|
||||
message := messages[0].(map[string]any)
|
||||
if message["sender"] != "群昵称甲" || message["senderId"] != "D1" {
|
||||
t.Fatalf("projected message = %#v", message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgAcceptsStableIDInChatQuery(t *testing.T) {
|
||||
fake := &platformCoverageCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package smart
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageAtMePageAllUsesOpaqueCursorAndDeduplicates(t *testing.T) {
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"conversationMessagesList":[{"openConversationId":"cid","title":"群","messages":[{"openMessageId":"m2"},{"openMessageId":"m1"}]}],"hasMore":true,"nextCursor":"cursor-2"}}`,
|
||||
`{"result":{"conversationMessagesList":[{"openConversationId":"cid","title":"群","messages":[{"openMessageId":"m1"},{"openMessageId":"m0"}]}],"hasMore":false}}`,
|
||||
}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+at-me", "--limit", "2", "--page-all", "--page-limit", "5"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.args) != 2 || caller.args[0]["cursor"] != "0" || caller.args[1]["cursor"] != "cursor-2" {
|
||||
t.Fatalf("calls = %#v", caller.args)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(3) || payload["pagesFetched"] != float64(2) ||
|
||||
payload["complete"] != true || payload["hasMore"] != false || payload["failedCount"] != float64(0) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if len(payload["items"].([]any)) != 3 {
|
||||
t.Fatalf("compatibility items = %#v", payload["items"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAtMePageAllFailsClosedWithoutContinuation(t *testing.T) {
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"}]}],"hasMore":true}}`,
|
||||
}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+at-me", "--page-all"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("missing @me continuation unexpectedly succeeded")
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["complete"] != false || payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAtMePageAllContinuesAcrossEmptyIntermediatePage(t *testing.T) {
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"conversationMessagesList":[],"hasMore":true,"nextCursor":"cursor-2"}}`,
|
||||
`{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"}]}],"hasMore":false}}`,
|
||||
}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+at-me", "--page-all", "--page-limit", "5"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(1) || payload["pagesFetched"] != float64(2) || payload["complete"] != true {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMyGroupsPageAllUsesNumericCursorAndFiltersAfterMerge(t *testing.T) {
|
||||
caller := &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"groups":[{"openConversationId":"g1","title":"群一","groupType":"group"}],"hasMore":true,"nextCursor":88}}`,
|
||||
`{"result":{"groups":[{"openConversationId":"g1","title":"重复群","groupType":"group"},{"openConversationId":"g2","title":"单聊","groupType":"p2p"}],"hasMore":false}}`,
|
||||
}}
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+my-groups", "--type", "group", "--page-all", "--page-limit", "5"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.args) != 2 || caller.args[1]["cursor"] != float64(88) {
|
||||
t.Fatalf("calls = %#v", caller.args)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["count"] != float64(1) || payload["pagesFetched"] != float64(2) ||
|
||||
payload["complete"] != true || payload["failedCount"] != float64(0) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRemainingReadPaginationValidation(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"chat", "+at-me", "--page-limit", "2"},
|
||||
{"chat", "+at-me", "--page-all", "--page-limit", "501"},
|
||||
{"chat", "+my-groups", "--limit", "201"},
|
||||
{"chat", "+my-groups", "--page-limit", "2"},
|
||||
{"chat", "+my-groups", "--page-all", "--page-limit", "501"},
|
||||
} {
|
||||
helpers.InitDeps(&chatMessagesPagingCaller{})
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatalf("invalid args succeeded: %v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMyGroupsAdditionalEdges(t *testing.T) {
|
||||
run := func(t *testing.T, caller *chatMessagesPagingCaller, args ...string) (map[string]any, error) {
|
||||
t.Helper()
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs(append([]string{"chat", "+my-groups"}, args...))
|
||||
err := root.Execute()
|
||||
if output.Len() == 0 {
|
||||
return nil, err
|
||||
}
|
||||
var payload map[string]any
|
||||
if decodeErr := json.Unmarshal(output.Bytes(), &payload); decodeErr != nil {
|
||||
t.Fatal(decodeErr)
|
||||
}
|
||||
return payload, err
|
||||
}
|
||||
|
||||
t.Run("single-page outcomes", func(t *testing.T) {
|
||||
payload, err := run(t, &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"groups":[],"hasMore":false}}`,
|
||||
}}, "--cursor", "cursor-1")
|
||||
if err != nil || payload["complete"] != true || payload["stopReason"] != "source_complete" {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
payload, err = run(t, &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":2}}`,
|
||||
}})
|
||||
if err != nil || payload["complete"] != false || payload["stopReason"] != "single_page" {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
if _, err = run(t, &chatMessagesPagingCaller{failAt: 1}); err == nil {
|
||||
t.Fatal("single-page read failure unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all-page failures", func(t *testing.T) {
|
||||
if _, err := run(t, &chatMessagesPagingCaller{failAt: 1}, "--page-all"); err == nil {
|
||||
t.Fatal("first-page failure unexpectedly succeeded")
|
||||
}
|
||||
payload, err := run(t, &chatMessagesPagingCaller{
|
||||
responses: []string{`{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":2}}`},
|
||||
failAt: 2,
|
||||
}, "--page-all")
|
||||
if err == nil || payload["partial"] != true || payload["stopReason"] != "read_failure" {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
responses []string
|
||||
args []string
|
||||
stop string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "unknown pagination", responses: []string{`{"result":{"groups":[]}}`}, args: []string{"--page-all"}, stop: "pagination_error", wantErr: true},
|
||||
{name: "missing continuation", responses: []string{`{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true}}`}, args: []string{"--page-all"}, stop: "pagination_error", wantErr: true},
|
||||
{name: "stalled continuation", responses: []string{
|
||||
`{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":2}}`,
|
||||
`{"result":{"groups":[{"openConversationId":"g2"}],"hasMore":true,"nextCursor":2}}`,
|
||||
}, args: []string{"--page-all"}, stop: "pagination_error", wantErr: true},
|
||||
{name: "page limit", responses: []string{`{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":2}}`}, args: []string{"--page-all", "--page-limit", "1"}, stop: "page_limit"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload, err := run(t, &chatMessagesPagingCaller{responses: tc.responses}, tc.args...)
|
||||
if (err != nil) != tc.wantErr || payload["stopReason"] != tc.stop {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("output failures", func(t *testing.T) {
|
||||
helpers.InitDeps(&chatMessagesPagingCaller{responses: []string{`{"result":{"groups":[],"hasMore":false}}`}})
|
||||
for _, args := range [][]string{{"chat", "+my-groups"}, {"chat", "+my-groups", "--page-all"}} {
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(chatMessagesFailWriter{})
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatalf("output failure swallowed for %v", args)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for _, value := range []any{nil, "", "0", "<nil>", 0} {
|
||||
if got := myGroupsCursorString(value); got != "" {
|
||||
t.Fatalf("cursor %v = %q", value, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAtMeAdditionalEdges(t *testing.T) {
|
||||
run := func(t *testing.T, caller *chatMessagesPagingCaller, args ...string) (map[string]any, error) {
|
||||
t.Helper()
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs(append([]string{"chat", "+at-me"}, args...))
|
||||
err := root.Execute()
|
||||
if output.Len() == 0 {
|
||||
return nil, err
|
||||
}
|
||||
var payload map[string]any
|
||||
if decodeErr := json.Unmarshal(output.Bytes(), &payload); decodeErr != nil {
|
||||
t.Fatal(decodeErr)
|
||||
}
|
||||
return payload, err
|
||||
}
|
||||
|
||||
t.Run("single page terminal and read failure", func(t *testing.T) {
|
||||
payload, err := run(t, &chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"conversationMessagesList":[],"hasMore":false}}`,
|
||||
}})
|
||||
if err != nil || payload["complete"] != true || payload["stopReason"] != "source_complete" {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
_, err = run(t, &chatMessagesPagingCaller{failAt: 1})
|
||||
if err == nil {
|
||||
t.Fatal("single-page read failure unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all-page read failures", func(t *testing.T) {
|
||||
_, err := run(t, &chatMessagesPagingCaller{failAt: 1}, "--page-all")
|
||||
if err == nil {
|
||||
t.Fatal("first all-page read failure unexpectedly succeeded")
|
||||
}
|
||||
payload, err := run(t, &chatMessagesPagingCaller{
|
||||
responses: []string{`{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"}]}],"hasMore":true,"nextCursor":"next"}}`},
|
||||
failAt: 2,
|
||||
}, "--page-all")
|
||||
if err == nil || payload["partial"] != true || payload["stopReason"] != "read_failure" {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
responses []string
|
||||
args []string
|
||||
wantErr bool
|
||||
stop string
|
||||
}{
|
||||
{name: "unknown pagination", responses: []string{`{"result":{"conversationMessagesList":[]}}`}, args: []string{"--page-all"}, wantErr: true, stop: "pagination_error"},
|
||||
{name: "page limit", responses: []string{`{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"}]}],"hasMore":true,"nextCursor":"next"}}`}, args: []string{"--page-all", "--page-limit", "1"}, stop: "page_limit"},
|
||||
{name: "empty cursor defaults", responses: []string{`{"result":{"conversationMessagesList":[],"hasMore":false}}`}, args: []string{"--cursor=", "--page-all"}, stop: "source_complete"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload, err := run(t, &chatMessagesPagingCaller{responses: tc.responses}, tc.args...)
|
||||
if (err != nil) != tc.wantErr || payload["stopReason"] != tc.stop {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
if tc.stop == "page_limit" && payload["nextCursor"] != "next" {
|
||||
t.Fatalf("continuation payload=%#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("output errors propagate", func(t *testing.T) {
|
||||
helpers.InitDeps(&chatMessagesPagingCaller{responses: []string{
|
||||
`{"result":{"conversationMessagesList":[],"hasMore":false}}`,
|
||||
}})
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetOut(chatMessagesFailWriter{})
|
||||
root.SetArgs([]string{"chat", "+at-me"})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatal("output error was swallowed")
|
||||
}
|
||||
})
|
||||
|
||||
for _, value := range []any{nil, "", "0", "<nil>"} {
|
||||
if got := atMeCursorString(value); got != "" {
|
||||
t.Fatalf("cursor %v = %q", value, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,8 @@ var SearchMsg = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+search-msg",
|
||||
Product: "im",
|
||||
Description: "按稳定 ID 和内容等条件跨会话搜索消息,可全量翻页并批量富化",
|
||||
Intent: "当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;会话与发送者过滤使用稳定 ID。默认查询近 7 天,也可指定精确起止时间。" +
|
||||
Description: "按发送者、关键词、@对象、会话或消息类型等条件搜索消息,可限定单个、多个或全部会话",
|
||||
Intent: "当你要按发送者、关键词、@对象、消息类型、机器人来源或会话范围等条件搜索 IM 消息时使用;搜索范围可为单个、多个或全部会话,会话与发送者的公开过滤参数使用稳定 ID。默认查询近 7 天,也可指定精确起止时间及输出顺序。" +
|
||||
"--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" +
|
||||
"--download-resources 使用安全本地路径、默认不覆盖和原子落盘。",
|
||||
Risk: shortcut.RiskRead,
|
||||
@@ -55,16 +55,16 @@ var SearchMsg = shortcut.Shortcut{
|
||||
CLIPath: "chat +search-msg",
|
||||
PrimaryCLIPath: "chat +search-msg",
|
||||
},
|
||||
Description: "按稳定 ID 和内容等条件跨会话搜索消息,可全量翻页并批量富化",
|
||||
Description: "按发送者、关键词、@对象、会话或消息类型等条件搜索消息,可限定单个、多个或全部会话",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed search adapter: it combines filters, cursor pagination, batched mget enrichment, stable projection, completeness accounting, and optional safe resource downloads.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按稳定 ID 和内容等条件跨会话搜索消息,可全量翻页并批量富化",
|
||||
UseWhen: []string{"当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;会话与发送者过滤使用稳定 ID。默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用安全本地路径、默认不覆盖和原子落盘。"},
|
||||
AvoidWhen: []string{"只想读取一个已知会话的连续历史时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"},
|
||||
AgentSummary: "按发送者、关键词、@对象、会话或消息类型等条件搜索消息,可限定单个、多个或全部会话",
|
||||
UseWhen: []string{"当你要按发送者、关键词、@对象、消息类型、机器人来源或会话范围等条件搜索 IM 消息时使用;搜索范围可为单个、多个或全部会话,会话与发送者的公开过滤参数使用稳定 ID。默认查询近 7 天,也可指定精确起止时间及输出顺序。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用安全本地路径、默认不覆盖和原子落盘。"},
|
||||
AvoidWhen: []string{"只想查看或导出一个指定会话的消息记录、且没有发送者、关键词、@对象或消息类型等主要筛选条件时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"},
|
||||
Examples: []string{
|
||||
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all",
|
||||
"dws chat +search-msg --group <openConversationId> --message-type file --download-resources --output-dir ./downloads",
|
||||
@@ -81,10 +81,10 @@ var SearchMsg = shortcut.Shortcut{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
|
||||
{Name: "groups", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId"},
|
||||
{Name: "chat-id", Type: shortcut.FlagStringSlice, Desc: "--groups 的 lark-cli 对齐别名"},
|
||||
{Name: "chat-query", Type: shortcut.FlagStringSlice, Desc: "按群名解析会话的兼容入口", Hidden: true},
|
||||
{Name: "chat-query", Type: shortcut.FlagStringSlice, Desc: "按群名唯一解析会话过滤条件(可选,可重复或逗号分隔)"},
|
||||
{Name: "senders", Type: shortcut.FlagStringSlice, Desc: "发送者 userId/openDingTalkId 列表"},
|
||||
{Name: "sender", Type: shortcut.FlagStringSlice, Desc: "--senders 的 lark-cli 对齐别名"},
|
||||
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "按姓名解析发送者的兼容入口", Hidden: true},
|
||||
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "按姓名唯一解析发送者过滤条件(可选,可重复或逗号分隔)"},
|
||||
{Name: "at-me", Type: shortcut.FlagBool, Desc: "只搜索 @我 的消息"},
|
||||
{Name: "is-at-me", Type: shortcut.FlagBool, Desc: "--at-me 的 lark-cli 对齐别名"},
|
||||
{Name: "at-ids", Type: shortcut.FlagStringSlice, Desc: "@对象 userId/openDingTalkId 列表"},
|
||||
@@ -93,8 +93,12 @@ var SearchMsg = shortcut.Shortcut{
|
||||
{Name: "conversation-type", Type: shortcut.FlagString, Desc: "下层会话类型过滤值(以当前 IM Schema 为准)"},
|
||||
{Name: "chat-type", Type: shortcut.FlagString, Desc: "--conversation-type 的 lark-cli 对齐别名"},
|
||||
{Name: "days", Type: shortcut.FlagInt, Desc: "默认时间窗的回溯天数", Default: "7"},
|
||||
{Name: "start", Type: shortcut.FlagString, Desc: "精确开始时间(RFC3339,需与 --end 一起传)"},
|
||||
{Name: "end", Type: shortcut.FlagString, Desc: "精确结束时间(RFC3339,需与 --start 一起传)"},
|
||||
{Name: "start", Type: shortcut.FlagString, Desc: "精确开始时间(RFC3339,需与 --end/--end-time 一起传)"},
|
||||
{Name: "start-time", Type: shortcut.FlagString, Desc: "--start 的 lark-cli 对齐别名(RFC3339,需与 --end/--end-time 一起传)"},
|
||||
{Name: "end", Type: shortcut.FlagString, Desc: "精确结束时间(RFC3339,需与 --start/--start-time 一起传)"},
|
||||
{Name: "end-time", Type: shortcut.FlagString, Desc: "--end 的 lark-cli 对齐别名(RFC3339,需与 --start/--start-time 一起传)"},
|
||||
{Name: "order", Type: shortcut.FlagString, Enum: []string{"asc", "desc"}, Desc: "按消息创建时间稳定排列输出 asc/desc(可选,默认 desc)"},
|
||||
{Name: "sort", Type: shortcut.FlagString, Enum: []string{"asc", "desc"}, Desc: "--order 的 lark-cli 对齐别名(可选)"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(1-100)", Default: "100"},
|
||||
{Name: "page-size", Type: shortcut.FlagInt, Desc: "--limit 的 lark-cli 对齐别名(1-100)"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor", Default: "0"},
|
||||
@@ -112,14 +116,17 @@ var SearchMsg = shortcut.Shortcut{
|
||||
},
|
||||
{
|
||||
Kind: shortcut.ConstraintCustom,
|
||||
Flags: []string{"start"},
|
||||
Description: "需与 --end 一起传",
|
||||
Flags: []string{"start", "start-time"},
|
||||
Description: "需与 --end/--end-time 一起传",
|
||||
},
|
||||
{
|
||||
Kind: shortcut.ConstraintCustom,
|
||||
Flags: []string{"end"},
|
||||
Description: "需与 --start 一起传",
|
||||
Flags: []string{"end", "end-time"},
|
||||
Description: "需与 --start/--start-time 一起传",
|
||||
},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"start", "start-time"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"end", "end-time"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"order", "sort"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"groups", "chat-id"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"senders", "sender"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"at-me", "is-at-me"}},
|
||||
@@ -134,7 +141,7 @@ var SearchMsg = shortcut.Shortcut{
|
||||
},
|
||||
Validate: validateSearchMsgWithResources,
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params, err := searchMsgParams(rt)
|
||||
params, resolvedFilters, err := searchMsgParams(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -229,6 +236,11 @@ var SearchMsg = shortcut.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
order := strings.ToLower(strings.TrimSpace(rt.StrFirst("order", "sort")))
|
||||
if order == "" {
|
||||
order = "desc"
|
||||
}
|
||||
sortMessagesByCreateTimeStable(messages, order)
|
||||
results := make([]map[string]any, 0, len(messages))
|
||||
for _, m := range messages {
|
||||
results = append(results, searchMsgProjectWithReactions(m, !rt.Bool("no-reactions")))
|
||||
@@ -245,6 +257,10 @@ var SearchMsg = shortcut.Shortcut{
|
||||
"paginationKnown": paginationKnown,
|
||||
"failedCount": len(failures),
|
||||
"failures": failures,
|
||||
"queryRange": searchMessageQueryRange(params, order),
|
||||
}
|
||||
if len(resolvedFilters.Senders) > 0 {
|
||||
payload["resolvedFilters"] = resolvedFilters
|
||||
}
|
||||
if hasMore && nextCursor != "" && nextCursor != "<nil>" {
|
||||
payload["nextCursor"] = nextCursor
|
||||
@@ -281,8 +297,10 @@ func validateSearchMsg(rt *shortcut.RuntimeContext) error {
|
||||
if !hasFilter {
|
||||
return apperrors.NewValidation("至少指定一个过滤条件,例如 --query、--group、--senders、--at-me 或 --message-type")
|
||||
}
|
||||
if rt.Changed("start") != rt.Changed("end") {
|
||||
return apperrors.NewValidation("--start 与 --end 必须同时指定")
|
||||
startChanged := rt.Changed("start") || rt.Changed("start-time")
|
||||
endChanged := rt.Changed("end") || rt.Changed("end-time")
|
||||
if startChanged != endChanged {
|
||||
return apperrors.NewValidation("--start/--start-time 与 --end/--end-time 必须同时指定")
|
||||
}
|
||||
if days := rt.Int("days"); days < 1 || days > 3650 {
|
||||
return apperrors.NewValidation("--days 必须在 1-3650 之间")
|
||||
@@ -297,8 +315,17 @@ func validateSearchMsg(rt *shortcut.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, error) {
|
||||
// searchResolvedFilters preserves the natural sender facts applied to the
|
||||
// lower search. Message sender names are display labels and may differ from
|
||||
// the directory name, so consumers must join the selected stable identity to
|
||||
// each projected message's senderId instead of comparing those two names.
|
||||
type searchResolvedFilters struct {
|
||||
Senders []targetresolver.UserResolution `json:"senders,omitempty"`
|
||||
}
|
||||
|
||||
func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, searchResolvedFilters, error) {
|
||||
params := map[string]any{"limit": rt.IntFirst("limit", "page-size")}
|
||||
resolvedFilters := searchResolvedFilters{}
|
||||
if value := rt.StrFirst("query", "keyword", "text", "text-query"); value != "" {
|
||||
params["keyword"] = value
|
||||
}
|
||||
@@ -311,7 +338,7 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, error) {
|
||||
for _, query := range queries {
|
||||
resolved, err := targetresolver.ResolveChatTarget(rt, "", query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, searchResolvedFilters{}, err
|
||||
}
|
||||
conversationIDs = append(conversationIDs, resolved.Selected.OpenConversationID)
|
||||
}
|
||||
@@ -324,8 +351,9 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, error) {
|
||||
if queries := rt.StrSlice("sender-query"); len(queries) > 0 {
|
||||
resolvedUsers, err := targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, searchResolvedFilters{}, err
|
||||
}
|
||||
resolvedFilters.Senders = append(resolvedFilters.Senders, resolvedUsers...)
|
||||
for _, resolved := range resolvedUsers {
|
||||
identity := resolved.Selected.OpenDingTalkID
|
||||
if identity == "" {
|
||||
@@ -349,17 +377,19 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, error) {
|
||||
params["searchConvType"] = value
|
||||
}
|
||||
|
||||
if rt.Changed("start") && rt.Changed("end") {
|
||||
start, err := time.Parse(time.RFC3339, rt.Str("start"))
|
||||
startValue := rt.StrFirst("start", "start-time")
|
||||
endValue := rt.StrFirst("end", "end-time")
|
||||
if startValue != "" && endValue != "" {
|
||||
start, err := time.Parse(time.RFC3339, startValue)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--start 必须是 RFC3339 时间: %v", err))
|
||||
return nil, searchResolvedFilters{}, apperrors.NewValidation(fmt.Sprintf("--start/--start-time 必须是 RFC3339 时间: %v", err))
|
||||
}
|
||||
end, err := time.Parse(time.RFC3339, rt.Str("end"))
|
||||
end, err := time.Parse(time.RFC3339, endValue)
|
||||
if err != nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--end 必须是 RFC3339 时间: %v", err))
|
||||
return nil, searchResolvedFilters{}, apperrors.NewValidation(fmt.Sprintf("--end/--end-time 必须是 RFC3339 时间: %v", err))
|
||||
}
|
||||
if !end.After(start) {
|
||||
return nil, apperrors.NewValidation("--end 必须晚于 --start")
|
||||
return nil, searchResolvedFilters{}, apperrors.NewValidation("--end 必须晚于 --start")
|
||||
}
|
||||
params["startTime"] = start.UnixMilli()
|
||||
params["endTime"] = end.UnixMilli()
|
||||
@@ -368,7 +398,7 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, error) {
|
||||
params["startTime"] = now.AddDate(0, 0, -rt.Int("days")).UnixMilli()
|
||||
params["endTime"] = now.UnixMilli()
|
||||
}
|
||||
return params, nil
|
||||
return params, resolvedFilters, nil
|
||||
}
|
||||
|
||||
func appendSearchActorIDs(params map[string]any, values []string, userKey, openIDKey string) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -22,6 +23,8 @@ type searchMsgExecutionCaller struct {
|
||||
failEnrichment bool
|
||||
omitPagination bool
|
||||
omitMgetItem bool
|
||||
firstResponse string
|
||||
mgetResponse string
|
||||
}
|
||||
|
||||
func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
@@ -40,6 +43,9 @@ func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool str
|
||||
}
|
||||
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m2","content":"sparse-2"}],"hasMore":false}}`), nil
|
||||
}
|
||||
if f.firstResponse != "" {
|
||||
return searchMsgToolResult(f.firstResponse), nil
|
||||
}
|
||||
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","content":"sparse-1"}],"hasMore":true,"nextCursor":"c2"}}`), nil
|
||||
case "list_messages_by_ids":
|
||||
if f.failEnrichment {
|
||||
@@ -48,6 +54,9 @@ func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool str
|
||||
if f.omitMgetItem {
|
||||
return searchMsgToolResult(`{"result":[{"openMessageId":"m1","content":"detail-1"}]}`), nil
|
||||
}
|
||||
if f.mgetResponse != "" {
|
||||
return searchMsgToolResult(f.mgetResponse), nil
|
||||
}
|
||||
return searchMsgToolResult(`{"result":[{"openMessageId":"m1","content":"detail-1"},{"openMessageId":"m2","content":"detail-2"}]}`), nil
|
||||
default:
|
||||
return nil, errors.New("unexpected tool")
|
||||
@@ -84,7 +93,7 @@ func executeSearchMsg(t *testing.T, caller *searchMsgExecutionCaller, args ...st
|
||||
return payload
|
||||
}
|
||||
|
||||
func TestSearchMsgPagesAndEnrichesWithAdvancedFilters(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSearchMsgPagesAndEnrichesWithAdvancedFilters(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{}
|
||||
payload := executeSearchMsg(t, caller,
|
||||
"--query", "周报",
|
||||
@@ -142,7 +151,7 @@ func TestSearchMsgPagesAndEnrichesWithAdvancedFilters(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMsgLaterPageFailurePublishesPartialLedger(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSearchMsgLaterPageFailurePublishesPartialLedger(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{failSecondPage: true}
|
||||
payload := executeSearchMsg(t, caller,
|
||||
"--query", "周报",
|
||||
@@ -160,7 +169,7 @@ func TestSearchMsgLaterPageFailurePublishesPartialLedger(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMsgEnrichmentFailureKeepsSearchHits(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSearchMsgEnrichmentFailureKeepsSearchHits(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{failEnrichment: true}
|
||||
payload := executeSearchMsg(t, caller, "--query", "周报")
|
||||
if payload["complete"] != false || payload["count"] != float64(1) ||
|
||||
@@ -169,7 +178,7 @@ func TestSearchMsgEnrichmentFailureKeepsSearchHits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMsgMissingPaginationCannotClaimComplete(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSearchMsgMissingPaginationCannotClaimComplete(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{omitPagination: true}
|
||||
payload := executeSearchMsg(t, caller, "--query", "周报", "--no-enrich")
|
||||
if payload["complete"] != false || payload["count"] != float64(1) ||
|
||||
@@ -183,7 +192,7 @@ func TestSearchMsgMissingPaginationCannotClaimComplete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMsgMissingMgetItemPublishesFailureLedger(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSearchMsgMissingMgetItemPublishesFailureLedger(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{omitMgetItem: true}
|
||||
payload := executeSearchMsg(t, caller, "--query", "周报", "--page-all")
|
||||
if payload["complete"] != false || payload["count"] != float64(2) ||
|
||||
@@ -199,3 +208,34 @@ func TestSearchMsgMissingMgetItemPublishesFailureLedger(t *testing.T) {
|
||||
t.Fatalf("failure = %#v", failure)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgLarkTimeAliasesAndAscendingOrder(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{
|
||||
firstResponse: `{"result":{"messages":[{"openMessageId":"m2","createTime":1782892800000,"content":"later"},{"openMessageId":"m1","createTime":1782806400000,"content":"earlier"}],"hasMore":false}}`,
|
||||
}
|
||||
payload := executeSearchMsg(t, caller,
|
||||
"--query", "周报",
|
||||
"--start-time", "2026-07-01T00:00:00+08:00",
|
||||
"--end-time", "2026-07-03T00:00:00+08:00",
|
||||
"--sort", "asc",
|
||||
"--no-enrich",
|
||||
)
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
wantStart, _ := time.Parse(time.RFC3339, "2026-07-01T00:00:00+08:00")
|
||||
wantEnd, _ := time.Parse(time.RFC3339, "2026-07-03T00:00:00+08:00")
|
||||
if caller.calls[0].args["startTime"] != wantStart.UnixMilli() ||
|
||||
caller.calls[0].args["endTime"] != wantEnd.UnixMilli() {
|
||||
t.Fatalf("time params = %#v", caller.calls[0].args)
|
||||
}
|
||||
messages := payload["messages"].([]any)
|
||||
if messages[0].(map[string]any)["messageId"] != "m1" ||
|
||||
messages[1].(map[string]any)["messageId"] != "m2" {
|
||||
t.Fatalf("ascending messages = %#v", messages)
|
||||
}
|
||||
rangeMeta := payload["queryRange"].(map[string]any)
|
||||
if rangeMeta["order"] != "asc" || rangeMeta["semantics"] != "[start,end)" {
|
||||
t.Fatalf("queryRange = %#v", rangeMeta)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,25 +14,36 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"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/shortcut"
|
||||
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
)
|
||||
|
||||
const (
|
||||
threadRepliesDefaultPageLimit = 50
|
||||
threadRepliesHardPageLimit = 500
|
||||
)
|
||||
|
||||
// ThreadReplies: fetch every reply under one topic ("话题") message and print a
|
||||
// clean projected list (speaker / text / time) instead of a raw dump.
|
||||
//
|
||||
// Steps:
|
||||
// 1. call list_topic_replies (chat server) with openconversationId=--group and
|
||||
// topicId=--topic-id, optionally startTime=--time and pageSize=--limit —
|
||||
// param keys copied verbatim from chat.go's list-topic-replies call site;
|
||||
// 2. defensively unwrap the reply list (multiple candidate container keys) and
|
||||
// 1. accept either --group plus --thread-id/--topic-id, or resolve a root
|
||||
// --message-id to its conversation/thread context through list_messages_by_ids;
|
||||
// 2. call list_topic_replies (chat server), optionally with startTime=--time
|
||||
// and pageSize=--limit;
|
||||
// 3. defensively unwrap the reply list (multiple candidate container keys) and
|
||||
// project each reply to {sender, text, createTime} tolerating field aliases;
|
||||
// 3. print via rt.Output as {replies, count} so it honours --format/--jq/--fields.
|
||||
// 4. print via rt.Output as {replies, count} so it honours --format/--jq/--fields.
|
||||
//
|
||||
// The default path only reads and reshapes topic replies;
|
||||
// --download-resources additionally writes resource files locally.
|
||||
@@ -42,10 +53,11 @@ var ThreadReplies = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+thread-replies",
|
||||
Product: "chat",
|
||||
Description: "拉取某条话题消息的全部回复并投影出发言人/文本/时间",
|
||||
Intent: "当你已经拿到某个群里一条「话题消息」的 threadId/topicId、想快速看这条话题下的全部回复(谁在什么时间回复了什么)," +
|
||||
"而不想拿到一大坨原始消息字段时使用;内部按 --group(群会话 ID)和 --thread-id(兼容 --topic-id)拉取该话题的回复列表," +
|
||||
"可选 --time 指定起始时间、--limit 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。" +
|
||||
Description: "按主消息 ID 或 thread/topic ID 分页读取话题回复,支持完整排序与有界自动翻页",
|
||||
Intent: "当你已经拿到某个群里一条「话题消息」的主消息 ID 或 threadId/topicId、想快速看这条话题下的回复(谁在什么时间回复了什么)," +
|
||||
"而不想拿到一大坨原始消息字段时使用;可直接传 --message-id 自动只读解析会话和 thread,也可传 --group 配合 --thread-id(兼容 --topic-id)," +
|
||||
"可选 --time 指定手工续页边界、--limit/--page-size 指定每页条数;--page-all 沿下层毫秒级 nextCursor 自动续页,--page-limit 保持有界;--order/--sort 支持 desc,asc 需与 --page-all 一起使用," +
|
||||
"结果用 complete、hasMore、nextPage、stopReason 和 failures 明确证明是否完整,再在本地投影出每条回复的发言人、文本和回复时间。" +
|
||||
"默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
@@ -60,101 +72,483 @@ var ThreadReplies = shortcut.Shortcut{
|
||||
CLIPath: "chat +thread-replies",
|
||||
PrimaryCLIPath: "chat +thread-replies",
|
||||
},
|
||||
Description: "拉取某条话题消息的全部回复并投影出发言人/文本/时间",
|
||||
Description: "按主消息 ID 或 thread/topic ID 分页读取话题回复,支持完整排序与有界自动翻页",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed thread reader adapter: it accepts threadId/topicId, reads lower topic replies, projects stable message fields, and optionally downloads reply resources safely.",
|
||||
Reason: "Reviewed thread reader adapter: it accepts a root message ID or threadId/topicId, reads lower topic replies, projects stable message fields, orders complete results, and optionally downloads reply resources safely.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "拉取某条话题消息的全部回复并投影出发言人/文本/时间",
|
||||
UseWhen: []string{"当你已经拿到某个群里一条「话题消息」的 threadId/topicId、想快速看这条话题下的全部回复(谁在什么时间回复了什么),而不想拿到一大坨原始消息字段时使用;内部按 --group(群会话 ID)和 --thread-id(兼容 --topic-id)拉取该话题的回复列表,可选 --time 指定起始时间、--limit 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"},
|
||||
AvoidWhen: []string{"要回复 Thread 或发送新回复时不要使用此读取入口;当前没有经过验证的 thread writer Shortcut"},
|
||||
AgentSummary: "按主消息 ID 或 thread/topic ID 分页读取话题回复,支持完整排序与有界自动翻页",
|
||||
UseWhen: []string{"当你已经拿到某个群里一条「话题消息」的主消息 ID 或 threadId/topicId、想快速看这条话题下的回复(谁在什么时间回复了什么)," +
|
||||
"而不想拿到一大坨原始消息字段时使用;可直接传 --message-id 自动只读解析会话和 thread,也可传 --group 配合 --thread-id(兼容 --topic-id)," +
|
||||
"可选 --time 指定手工续页边界、--limit/--page-size 指定每页条数;--page-all 沿下层毫秒级 nextCursor 自动续页,--page-limit 保持有界;--order/--sort 支持 desc,asc 需与 --page-all 一起使用," +
|
||||
"结果用 complete、hasMore、nextPage、stopReason 和 failures 明确证明是否完整,再在本地投影出每条回复的发言人、文本和回复时间。" +
|
||||
"默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"},
|
||||
AvoidWhen: []string{"要回复 Thread 或发送新回复时不要使用此读取入口;当前没有经过验证的 thread writer Shortcut"},
|
||||
Examples: []string{
|
||||
"dws chat +thread-replies --message-id <rootOpenMessageId> --page-all --order asc",
|
||||
"dws chat +thread-replies --group <openConversationId> --thread-id <threadId>",
|
||||
"dws chat +thread-replies --group <openConversationId> --topic-id <topicId> --download-resources --output-dir ./downloads",
|
||||
},
|
||||
},
|
||||
},
|
||||
Flags: append([]shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群会话 ID(openConversationId,必填)", Required: true},
|
||||
{Name: "thread-id", Type: shortcut.FlagString, Desc: "话题/线程 ID(可直接使用消息列表返回的 threadId)"},
|
||||
{Name: "topic-id", Type: shortcut.FlagString, Desc: "--thread-id 的兼容别名"},
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群会话 ID;--thread-id/--topic-id 必须同时提供 --group;--group 与 --message-id 解析出的 conversationId 必须匹配"},
|
||||
{Name: "message-id", Type: shortcut.FlagString, Desc: "话题主消息 openMessageId;自动只读解析 conversationId 和 threadId;--group 与 --message-id 解析出的 conversationId 必须匹配"},
|
||||
{Name: "thread-id", Type: shortcut.FlagString, Desc: "话题/线程 ID(可直接使用消息列表返回的 threadId);--thread-id/--topic-id 必须同时提供 --group"},
|
||||
{Name: "topic-id", Type: shortcut.FlagString, Desc: "--thread-id 的兼容别名;--thread-id/--topic-id 必须同时提供 --group"},
|
||||
{Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\";--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD(可选)"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的回复条数;--limit 必须大于 0"},
|
||||
{Name: "page-size", Type: shortcut.FlagInt, Desc: "--limit 的公开兼容别名;必须大于 0"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿下层毫秒级 nextCursor 自动读取后续页;--page-limit 仅与 --page-all 一起使用且范围 1-500;asc 必须与 --page-all 一起使用"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Name: "order", Type: shortcut.FlagString, Enum: []string{"asc", "desc"}, Desc: "回复输出顺序 asc/desc(可选,默认 desc;asc 必须与 --page-all 一起使用)"},
|
||||
{Name: "sort", Type: shortcut.FlagString, Enum: []string{"asc", "desc"}, Desc: "--order 的 lark-cli 对齐别名(可选;asc 必须与 --page-all 一起使用)"},
|
||||
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出回复 reaction(默认输出)"},
|
||||
}, chatshortcut.MessageResourceDownloadFlags()...),
|
||||
Constraints: append([]shortcut.Constraint{
|
||||
{
|
||||
Kind: shortcut.ConstraintExactlyOne,
|
||||
Flags: []string{"thread-id", "topic-id"},
|
||||
Description: "--thread-id 与兼容参数 --topic-id 必须且只能指定一个",
|
||||
Flags: []string{"message-id", "thread-id", "topic-id"},
|
||||
Description: "--message-id、--thread-id 与兼容参数 --topic-id 必须且只能指定一个",
|
||||
},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"limit", "page-size"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"order", "sort"}},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"group", "thread-id", "topic-id"}, Description: "--thread-id/--topic-id 必须同时提供 --group"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"group", "message-id"}, Description: "--group 与 --message-id 解析出的 conversationId 必须匹配"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"time"}, Description: "--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit", "page-size"}, Description: "显式页大小必须大于 0"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"order", "sort", "page-all"}, Description: "asc 必须与 --page-all 一起使用"},
|
||||
}, chatshortcut.MessageResourceDownloadConstraints()...),
|
||||
Tips: []string{
|
||||
`dws chat +thread-replies --message-id <rootOpenMessageId> --page-all --order asc`,
|
||||
`dws chat +thread-replies --group <openconversationId> --thread-id <threadId>`,
|
||||
`dws chat +thread-replies --group <openconversationId> --thread-id <threadId> --time "2025-03-01 00:00:00" --limit 20`,
|
||||
`dws chat +thread-replies --group <openconversationId> --thread-id <threadId> --page-size 50 --page-all --page-limit 20`,
|
||||
},
|
||||
Validate: validateThreadReplies,
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
// Step 1 — fetch the topic replies. Param keys (openconversationId /
|
||||
// topicId / startTime / pageSize) are copied verbatim from chat.go's
|
||||
// list-topic-replies call site.
|
||||
params := map[string]any{
|
||||
"openconversationId": rt.Str("group"),
|
||||
"topicId": rt.StrFirst("thread-id", "topic-id"),
|
||||
"forward": false,
|
||||
}
|
||||
if rt.Changed("time") && rt.Str("time") != "" {
|
||||
params["startTime"] = rt.Str("time")
|
||||
}
|
||||
if rt.Changed("limit") && rt.Int("limit") > 0 {
|
||||
params["pageSize"] = rt.Int("limit")
|
||||
}
|
||||
|
||||
data, err := rt.CallMCPData("chat", "list_topic_replies", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 2 — defensively unwrap and project. Response shape has no
|
||||
// contract, so probe multiple candidate container/field keys.
|
||||
items := threadReplyItems(data)
|
||||
results := make([]map[string]any, 0, len(items))
|
||||
for _, m := range items {
|
||||
results = append(results, projectChatMessageWithReactions(m, !rt.Bool("no-reactions")))
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"replies": results,
|
||||
"count": len(results),
|
||||
}
|
||||
chatmsg.ApplyMessagePagination(payload, data, items, "older")
|
||||
if rt.Bool("download-resources") {
|
||||
chatshortcut.AttachMessageResourceDownloads(
|
||||
payload,
|
||||
chatshortcut.DownloadMessageResources(rt, items, rt.Str("group")),
|
||||
)
|
||||
}
|
||||
return rt.Output(payload)
|
||||
},
|
||||
Execute: executeThreadReplies,
|
||||
}
|
||||
|
||||
func validateThreadReplies(rt *shortcut.RuntimeContext) error {
|
||||
if err := chatshortcut.ValidateMessageResourceDownload(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
if rt.Changed("limit") && rt.Int("limit") <= 0 {
|
||||
return localChatOptionError("invalid_page_size", "+thread-replies 的 --limit 必须大于 0", "--limit")
|
||||
for _, name := range []string{"limit", "page-size"} {
|
||||
if rt.Changed(name) && rt.Int(name) <= 0 {
|
||||
return localChatOptionError("invalid_page_size", "+thread-replies 的 --"+name+" 必须大于 0", "--"+name)
|
||||
}
|
||||
}
|
||||
if value := strings.TrimSpace(rt.Str("time")); value != "" && !validChatTime(value) {
|
||||
return localChatOptionError("invalid_time_boundary", "+thread-replies 的 --time 格式无效", "--time")
|
||||
}
|
||||
if strings.TrimSpace(rt.Str("message-id")) == "" && strings.TrimSpace(rt.Str("group")) == "" {
|
||||
return apperrors.NewValidation("--thread-id/--topic-id 必须同时提供 --group")
|
||||
}
|
||||
if threadRepliesOrder(rt) == "asc" && !rt.Bool("page-all") {
|
||||
return apperrors.NewValidation("asc 必须与 --page-all 一起使用")
|
||||
}
|
||||
if !rt.Bool("page-all") && rt.Changed("page-limit") {
|
||||
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
|
||||
}
|
||||
if rt.Bool("page-all") {
|
||||
if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > threadRepliesHardPageLimit {
|
||||
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeThreadReplies(rt *shortcut.RuntimeContext) error {
|
||||
target, err := resolveThreadRepliesTarget(rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"openconversationId": target.conversationID,
|
||||
"topicId": target.threadID,
|
||||
"forward": false,
|
||||
}
|
||||
if value := strings.TrimSpace(rt.Str("time")); value != "" {
|
||||
params["startTime"] = value
|
||||
}
|
||||
if pageSize := rt.IntFirst("limit", "page-size"); pageSize > 0 {
|
||||
params["pageSize"] = pageSize
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
var items []map[string]any
|
||||
err = nil
|
||||
if rt.Bool("page-all") {
|
||||
payload, items, err = collectAllThreadReplies(rt, params)
|
||||
} else {
|
||||
payload, items, err = collectOneThreadRepliesPage(rt, params)
|
||||
}
|
||||
applyThreadRepliesResultContract(payload, items, target, threadRepliesOrder(rt))
|
||||
if err != nil {
|
||||
if payload != nil {
|
||||
if outputErr := rt.Output(payload); outputErr != nil {
|
||||
return outputErr
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
if rt.Bool("download-resources") {
|
||||
chatshortcut.AttachMessageResourceDownloads(
|
||||
payload,
|
||||
chatshortcut.DownloadMessageResources(rt, items, target.conversationID),
|
||||
)
|
||||
}
|
||||
return rt.Output(payload)
|
||||
}
|
||||
|
||||
type threadRepliesTarget struct {
|
||||
conversationID string
|
||||
threadID string
|
||||
resolvedFromMessageID string
|
||||
}
|
||||
|
||||
func resolveThreadRepliesTarget(rt *shortcut.RuntimeContext) (threadRepliesTarget, error) {
|
||||
messageID := strings.TrimSpace(rt.Str("message-id"))
|
||||
if messageID == "" {
|
||||
return threadRepliesTarget{
|
||||
conversationID: strings.TrimSpace(rt.Str("group")),
|
||||
threadID: strings.TrimSpace(rt.StrFirst("thread-id", "topic-id")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
data, err := rt.CallMCPData("im", "list_messages_by_ids", map[string]any{
|
||||
"openMsgIds": []string{messageID},
|
||||
})
|
||||
if err != nil {
|
||||
return threadRepliesTarget{}, err
|
||||
}
|
||||
var matched map[string]any
|
||||
for _, item := range threadReplyItems(data) {
|
||||
if threadRepliesString(chatmsg.MessageID(item)) == messageID {
|
||||
matched = item
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched == nil {
|
||||
return threadRepliesTarget{}, apperrors.NewAPI(
|
||||
"未找到指定的话题主消息,无法解析 thread",
|
||||
apperrors.WithOperation("im/list_messages_by_ids"),
|
||||
apperrors.WithReason("thread_root_message_not_found"),
|
||||
apperrors.WithOrigin("mcp_gateway"),
|
||||
apperrors.WithHint("请确认 --message-id 是可读取的话题主消息 openMessageId"),
|
||||
)
|
||||
}
|
||||
conversationID := threadRepliesString(chatmsg.ConversationID(matched))
|
||||
threadID := threadRepliesString(chatmsg.ThreadID(matched))
|
||||
if conversationID == "" || threadID == "" {
|
||||
return threadRepliesTarget{}, apperrors.NewAPI(
|
||||
"消息详情未返回 conversationId/threadId,无法读取话题回复",
|
||||
apperrors.WithOperation("im/list_messages_by_ids"),
|
||||
apperrors.WithReason("thread_context_missing"),
|
||||
apperrors.WithOrigin("mcp_gateway"),
|
||||
apperrors.WithHint("请确认 --message-id 指向话题主消息,或改用 --group 与 --thread-id"),
|
||||
)
|
||||
}
|
||||
if explicitGroup := strings.TrimSpace(rt.Str("group")); explicitGroup != "" && explicitGroup != conversationID {
|
||||
return threadRepliesTarget{}, apperrors.NewValidation("--group 与 --message-id 解析出的 conversationId 不匹配")
|
||||
}
|
||||
return threadRepliesTarget{
|
||||
conversationID: conversationID,
|
||||
threadID: threadID,
|
||||
resolvedFromMessageID: messageID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func threadRepliesOrder(rt *shortcut.RuntimeContext) string {
|
||||
if order := strings.ToLower(strings.TrimSpace(rt.StrFirst("order", "sort"))); order != "" {
|
||||
return order
|
||||
}
|
||||
return "desc"
|
||||
}
|
||||
|
||||
func applyThreadRepliesResultContract(payload map[string]any, items []map[string]any, target threadRepliesTarget, order string) {
|
||||
if payload == nil {
|
||||
return
|
||||
}
|
||||
if order == "asc" {
|
||||
reverseThreadReplyMaps(items)
|
||||
if replies, ok := payload["replies"].([]map[string]any); ok {
|
||||
reverseThreadReplyMaps(replies)
|
||||
}
|
||||
}
|
||||
payload["order"] = order
|
||||
if payload["complete"] == true {
|
||||
payload["orderScope"] = "complete_result"
|
||||
} else {
|
||||
payload["orderScope"] = "fetched_pages"
|
||||
}
|
||||
payload["conversationId"] = target.conversationID
|
||||
payload["threadId"] = target.threadID
|
||||
if target.resolvedFromMessageID != "" {
|
||||
payload["resolvedFromMessageId"] = target.resolvedFromMessageID
|
||||
}
|
||||
}
|
||||
|
||||
func reverseThreadReplyMaps(items []map[string]any) {
|
||||
for left, right := 0, len(items)-1; left < right; left, right = left+1, right-1 {
|
||||
items[left], items[right] = items[right], items[left]
|
||||
}
|
||||
}
|
||||
|
||||
func threadRepliesString(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprint(value))
|
||||
if text == "<nil>" {
|
||||
return ""
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func collectOneThreadRepliesPage(rt *shortcut.RuntimeContext, params map[string]any) (map[string]any, []map[string]any, error) {
|
||||
data, err := rt.CallMCPData("chat", "list_topic_replies", params)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
items := threadReplyItems(data)
|
||||
payload := newThreadRepliesPayload(items, !rt.Bool("no-reactions"))
|
||||
applyOneThreadRepliesPagination(payload, data)
|
||||
if payload["complete"] == true {
|
||||
payload["stopReason"] = "source_complete"
|
||||
} else if payload["failedCount"] != 0 {
|
||||
payload["stopReason"] = "pagination_error"
|
||||
} else {
|
||||
payload["stopReason"] = "single_page"
|
||||
}
|
||||
return payload, items, nil
|
||||
}
|
||||
|
||||
func applyOneThreadRepliesPagination(payload, data map[string]any) {
|
||||
payload["pagesFetched"] = 1
|
||||
page := chatmsg.Pagination(data)
|
||||
hasMore, hasMoreKnown := page["hasMore"].(bool)
|
||||
if !hasMoreKnown {
|
||||
payload["paginationKnown"] = false
|
||||
payload["failedCount"] = 1
|
||||
payload["failures"] = []map[string]any{{
|
||||
"stage": "pagination",
|
||||
"error": "下层未返回可靠的 hasMore,无法证明话题回复完整",
|
||||
}}
|
||||
return
|
||||
}
|
||||
payload["paginationKnown"] = true
|
||||
payload["hasMore"] = hasMore
|
||||
payload["complete"] = !hasMore
|
||||
if !hasMore {
|
||||
return
|
||||
}
|
||||
_, boundary, err := threadRepliesNextCursorBoundary(page["nextCursor"])
|
||||
if err != nil {
|
||||
payload["paginationKnown"] = false
|
||||
payload["failedCount"] = 1
|
||||
payload["failures"] = []map[string]any{{
|
||||
"stage": "pagination",
|
||||
"error": "hasMore=true 但 nextCursor 无效,无法安全续页: " + err.Error(),
|
||||
}}
|
||||
return
|
||||
}
|
||||
payload["nextPage"] = map[string]any{
|
||||
"direction": "older",
|
||||
"time": boundary,
|
||||
"nextCursor": page["nextCursor"],
|
||||
}
|
||||
}
|
||||
|
||||
func collectAllThreadReplies(rt *shortcut.RuntimeContext, params map[string]any) (map[string]any, []map[string]any, error) {
|
||||
pageLimit := defaultChatPageLimit(rt.Int("page-limit"), threadRepliesDefaultPageLimit)
|
||||
seenIDs := map[string]bool{}
|
||||
seenCursors := map[string]bool{}
|
||||
allItems := make([]map[string]any, 0)
|
||||
failures := make([]map[string]any, 0)
|
||||
pagesFetched := 0
|
||||
paginationKnown := true
|
||||
complete := false
|
||||
hasMore := false
|
||||
stopReason := "source_complete"
|
||||
truncatedByPageLimit := false
|
||||
var nextPage map[string]any
|
||||
|
||||
for pagesFetched < pageLimit {
|
||||
data, err := rt.CallMCPData("chat", "list_topic_replies", params)
|
||||
if err != nil {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched + 1, "stage": "read", "error": err.Error(),
|
||||
})
|
||||
stopReason = "read_failure"
|
||||
break
|
||||
}
|
||||
pagesFetched++
|
||||
rawItems := threadReplyItems(data)
|
||||
for _, item := range rawItems {
|
||||
stableID := chatmsg.StableMessageID(item)
|
||||
if stableID != "" && seenIDs[stableID] {
|
||||
continue
|
||||
}
|
||||
if stableID != "" {
|
||||
seenIDs[stableID] = true
|
||||
}
|
||||
allItems = append(allItems, item)
|
||||
}
|
||||
|
||||
page := chatmsg.Pagination(data)
|
||||
pageHasMore, hasMoreKnown := page["hasMore"].(bool)
|
||||
if !hasMoreKnown {
|
||||
paginationKnown = false
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "下层未返回可靠的 hasMore,无法证明话题回复完整",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
hasMore = pageHasMore
|
||||
if !pageHasMore {
|
||||
complete = true
|
||||
hasMore = false
|
||||
nextPage = nil
|
||||
stopReason = "source_complete"
|
||||
break
|
||||
}
|
||||
if len(rawItems) == 0 {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "下层返回 hasMore=true 但当前页没有回复,无法证明分页可以安全推进",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
|
||||
cursorKey, boundary, cursorErr := threadRepliesNextCursorBoundary(page["nextCursor"])
|
||||
if cursorErr != nil {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "hasMore=true 但 nextCursor 无效,无法安全续页: " + cursorErr.Error(),
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
if seenCursors[cursorKey] {
|
||||
failures = append(failures, map[string]any{
|
||||
"page": pagesFetched, "stage": "pagination",
|
||||
"error": "hasMore=true 但 nextCursor 停滞",
|
||||
})
|
||||
stopReason = "pagination_error"
|
||||
break
|
||||
}
|
||||
seenCursors[cursorKey] = true
|
||||
nextPage = map[string]any{
|
||||
"direction": "older",
|
||||
"time": boundary,
|
||||
"nextCursor": page["nextCursor"],
|
||||
}
|
||||
params["startTime"] = boundary
|
||||
}
|
||||
|
||||
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
|
||||
truncatedByPageLimit = true
|
||||
stopReason = "page_limit"
|
||||
}
|
||||
payload := newThreadRepliesPayload(allItems, !rt.Bool("no-reactions"))
|
||||
payload["pagesFetched"] = pagesFetched
|
||||
payload["paginationKnown"] = paginationKnown
|
||||
payload["complete"] = complete && len(failures) == 0
|
||||
payload["hasMore"] = hasMore
|
||||
payload["stopReason"] = stopReason
|
||||
payload["truncatedByPageLimit"] = truncatedByPageLimit
|
||||
payload["failedCount"] = len(failures)
|
||||
payload["failures"] = failures
|
||||
payload["partial"] = len(failures) > 0 && len(allItems) > 0
|
||||
if hasMore && nextPage != nil {
|
||||
payload["nextPage"] = nextPage
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
failureStage := "pagination"
|
||||
if stopReason == "read_failure" {
|
||||
failureStage = "read"
|
||||
}
|
||||
return payload, allItems, apperrors.NewAPI(
|
||||
fmt.Sprintf("话题回复全量读取未完成:%d 页成功,%d 个失败项", pagesFetched, len(failures)),
|
||||
apperrors.WithOperation("chat/list_topic_replies"),
|
||||
apperrors.WithReason("thread_replies_incomplete"),
|
||||
apperrors.WithOrigin("mcp_gateway"),
|
||||
apperrors.WithFailureStage(failureStage),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(true),
|
||||
apperrors.WithHint("请根据 failures 和 nextPage 重试"),
|
||||
)
|
||||
}
|
||||
return payload, allItems, nil
|
||||
}
|
||||
|
||||
// threadRepliesNextCursorBoundary converts the authoritative millisecond
|
||||
// cursor returned by list_topic_replies into the exact RFC3339 boundary that
|
||||
// the same lower tool accepts as startTime. Message createTime is deliberately
|
||||
// not used here: it is only second-precision and can skip replies when a page
|
||||
// boundary splits several messages created within the same second.
|
||||
func threadRepliesNextCursorBoundary(value any) (string, string, error) {
|
||||
var millis int64
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
millis = int64(typed)
|
||||
case int32:
|
||||
millis = int64(typed)
|
||||
case int64:
|
||||
millis = typed
|
||||
case float32:
|
||||
asFloat := float64(typed)
|
||||
if math.IsNaN(asFloat) || math.IsInf(asFloat, 0) || asFloat <= 0 || math.Trunc(asFloat) != asFloat || asFloat > math.MaxInt64 {
|
||||
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
|
||||
}
|
||||
millis = int64(asFloat)
|
||||
case float64:
|
||||
if math.IsNaN(typed) || math.IsInf(typed, 0) || typed <= 0 || math.Trunc(typed) != typed || typed > math.MaxInt64 {
|
||||
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
|
||||
}
|
||||
millis = int64(typed)
|
||||
case string:
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
|
||||
}
|
||||
millis = parsed
|
||||
default:
|
||||
return "", "", fmt.Errorf("缺少毫秒级分页游标")
|
||||
}
|
||||
if millis <= 0 {
|
||||
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
|
||||
}
|
||||
cursorKey := strconv.FormatInt(millis, 10)
|
||||
boundary := time.UnixMilli(millis).UTC().Format(time.RFC3339Nano)
|
||||
return cursorKey, boundary, nil
|
||||
}
|
||||
|
||||
func newThreadRepliesPayload(items []map[string]any, includeReactions bool) map[string]any {
|
||||
results := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
results = append(results, projectChatMessageWithReactions(item, includeReactions))
|
||||
}
|
||||
return map[string]any{
|
||||
"contractVersion": chatmsg.MessageListContractVersion,
|
||||
"replies": results,
|
||||
"count": len(results),
|
||||
"pagesFetched": 0,
|
||||
"paginationKnown": false,
|
||||
"complete": false,
|
||||
"hasMore": false,
|
||||
"failedCount": 0,
|
||||
"failures": []map[string]any{},
|
||||
"partial": false,
|
||||
}
|
||||
}
|
||||
|
||||
// threadReplyItems defensively unwraps the reply list from the response,
|
||||
// tolerating the common container keys and one level of nesting under a
|
||||
// "result"/"data" wrapper.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user