Compare commits

..
Author SHA1 Message Date
chichuan 6297b6b0c8 Merge pull request #915 from DingTalk-Real-AI/codex/changelog-v1.0.58-beta.1
docs: seal v1.0.58-beta.1 changelog
2026-08-07 23:46:16 +08:00
chichuan e8905a1984 docs: seal v1.0.58-beta.1 changelog 2026-08-07 23:37:52 +08:00
github-actions[bot] 2662f87ad0 Merge pull request #908 from liyuan333/feat/html-import-hints
feat(doc): fall back to upload chain for non-importable import formats
2026-08-07 19:58:57 +08:00
liyuan 5a5b567eb0 Merge remote-tracking branch 'upstream/main' into feat/html-import-hints
# Conflicts:
#	CHANGELOG.md
2026-08-07 19:27:49 +08:00
liyuan c2db909bd2 Merge branch 'feat/html-import-hints' of github.com:liyuan333/dingtalk-workspace-cli into feat/html-import-hints 2026-08-07 19:17:33 +08:00
liyuan 871542ef0c 评审意见修改 2026-08-07 19:17:25 +08:00
github-actions[bot] 2c7d0f3ac4 Merge pull request #907 from dxb121/codex/multi-im-shortcuts-hardening
feat(chat): harden multi-IM shortcuts and pagination
2026-08-07 18:43:01 +08:00
栩朝 5a345228eb fix(chat): address pagination and audit review feedback 2026-08-07 18:26:39 +08:00
栩朝 b2b6153424 fix(chat): preserve flag-list size schema default 2026-08-07 17:19:36 +08:00
栩朝 59efb4facb feat(chat): harden multi-IM shortcuts and pagination 2026-08-07 17:19:36 +08:00
liyuan333 3605d4f450 Merge branch 'main' into feat/html-import-hints 2026-08-07 17:17:16 +08:00
liyuan f34b7741d4 Merge branch 'feat/html-import-hints' of github.com:liyuan333/dingtalk-workspace-cli into feat/html-import-hints 2026-08-07 17:09:21 +08:00
liyuan 2c573ec892 评审意见修改 2026-08-07 17:07:28 +08:00
github-actions[bot] 3027337a34 Merge pull request #901 from DingTalk-Real-AI/codex/ai-table-shortcut
feat(aitable): expose and verify complete AI Table shortcut surface
2026-08-07 16:41:11 +08:00
liyuan333 d0ad33034e Merge branch 'main' into feat/html-import-hints 2026-08-07 16:36:33 +08:00
liyuan 5f13876e6e fix(doc): address import fallback review — shared prechecks, clean upload primitive, marked JSON envelope 2026-08-07 16:25:19 +08:00
Dennis4477 4bb8c8b586 Merge branch 'main' into codex/ai-table-shortcut 2026-08-07 15:55:46 +08:00
Dennis 48e522ec00 fix(aitable): harden pagination and upload inputs 2026-08-07 15:50:35 +08:00
github-actions[bot] 0e0007d7b7 Merge pull request #903 from DingTalk-Real-AI/codex/update-reviewer-pool
chore: 更新 Reviewer Router 评审人池
2026-08-07 15:36:41 +08:00
Dennis 176b217139 fix(aitable): require bootstrap confirmation 2026-08-07 15:32:01 +08:00
chichuan 5c8fd0a48c fix: preserve reviewer routing fallback 2026-08-07 15:20:41 +08:00
Dennis 7490bb95c5 fix(aitable): scope strict write response checks 2026-08-07 15:09:58 +08:00
chichuan b186e59a01 feat: route reviewers by module ownership 2026-08-07 14:42:56 +08:00
Dennis a92f54df3d fix(paging): restore zero-value safety limit 2026-08-07 14:42:27 +08:00
Dennis 307c9e797b fix(aitable): reject empty bulk patch selectors 2026-08-07 14:08:59 +08:00
liyuan 116117e987 feat(doc): fall back to upload chain for non-importable import formats 2026-08-07 14:07:35 +08:00
Dennis 07ce090eeb test(aitable): close shortcut coverage gaps 2026-08-07 12:01:36 +08:00
Dennis 201949aec1 feat(aitable): add verified shortcut workflows 2026-08-07 12:01:33 +08:00
chichuan 8eeabd1419 chore: update reviewer pool 2026-08-07 10:45:59 +08:00
github-actions[bot] 6035d43899 Merge pull request #877 from xlb1130/feat/chat-toolbar-commands
feat(chat): add toolbar commands for conversation shortcut bar
2026-08-07 10:45:32 +08:00
xlb1130 ba375b40fa Merge branch 'main' into feat/chat-toolbar-commands 2026-08-06 22:25:56 +08:00
长真 0a063662a0 test(chat): use testseam for toolbar deps 2026-08-06 22:24:27 +08:00
github-actions[bot] 4148a90bf5 Merge pull request #889 from DingTalk-Real-AI/codex/ci-pr-release-compatibility
ci: run release compatibility in PR admission
2026-08-06 13:53:32 +00:00
长真 6572010922 fix(chat): preserve chmod yes shorthand 2026-08-06 20:27:18 +08:00
xlb1130 6738d0f29e Merge branch 'main' into feat/chat-toolbar-commands 2026-08-06 20:11:24 +08:00
xlb1130 384b067ead Merge branch 'main' into feat/chat-toolbar-commands 2026-08-06 20:00:42 +08:00
长真 c32d10bd43 fix(chat): gate chmod confirmation 2026-08-06 19:51:41 +08:00
xlb1130 2a7ab22e85 Merge branch 'main' into feat/chat-toolbar-commands 2026-08-06 18:52:26 +08:00
长真 757f45df31 fix(chat): align toolbar mcp contracts 2026-08-06 18:20:18 +08:00
长真 5d25a10223 fix(chat): cover toolbar command mappings 2026-08-06 16:46:39 +08:00
长真 0d5c6d92e3 feat(chat): add toolbar command contracts to #85129657 2026-08-06 16:07:50 +08:00
长真 88f7ea5679 Merge remote-tracking branch 'upstream/main' into feat/chat-toolbar-commands 2026-08-06 11:43:56 +08:00
xlb1130 c0b562cc07 fix(chat): add package-level MCP call seam for toolbar remove-custom
- introduce removeChatToolbarCustomShortcutFn in toolbar_remove_custom.go
  as a package-level injection seam; default impl routes through
  callMCPToolOnServer against the im server so production behavior is
  unchanged
- update RunE to dispatch via the seam instead of calling
  callMCPToolOnServer inline
- add two TestCrossPlatformCoverage* tests that swap the seam via
  testseam.Swap and verify: (1) without --yes the seam is never called
  and a typed confirmation_required error is returned, (2) with --yes
  the seam is called exactly once with openCid and shortcutId. The
  stub forwards to deps.Caller.CallTool so the user_required contract
  gate (leaf.go) still sees the CallTool channel.
2026-08-06 11:20:52 +08:00
长真 70d58648c8 fix(chat): address CR P1 for toolbar remove-custom
- Drop --yes and shell-comment example lines from the Cobra Example
  field in chat toolbar remove-custom; keep only the single
  non-bypassing command line. Aligns with AGENTS.md "no --yes in
  stored examples" and "No shell comments in examples" rules.
- Mirror the change in skills/mono/references/products/chat.md
  toolbar remove-custom block: remove the duplicated --yes and
  shell-comment lines; keep Flags block and prose note untouched.
- Add two end-to-end confirmation gate tests under
  internal/helpers/toolbar_helpers_test.go using the existing
  toolbarTestCaller seam (extended with a calls []toolbarCall
  slice so the new tests can assert call counts as well as the
  most recent call):
  * TestCrossPlatformCoverageToolbarRemoveCustomRejectsWithoutYes
    asserts confirmation_required and zero MCP calls when --yes
    is omitted.
  * TestCrossPlatformCoverageToolbarRemoveCustomCallsMCPWithExactArgsWhenYes
    asserts exactly one im/remove_chat_toolbar_custom_shortcut
    call with openCid=<cid> and shortcutId=<id> when --yes is
    set.
- No changes to Contract.Selection.Examples (already compliant),
  Long prose, or any other toolbar file. Helper field addition is
  additive: legacy single-call fields stay so all prior tests
  remain green.

Fixes: PR #877 CR P1 (remove-custom confirmation gate).
Risk tier: Standard.
Verification: see PR description.
2026-08-06 10:35:04 +08:00
长真 868d9ff2b0 Merge remote-tracking branch 'upstream/main' into feat/chat-toolbar-commands 2026-08-05 19:56:11 +08:00
长真 cb2f240c0c chore(ci): refresh pr merge ref 2026-08-05 19:53:16 +08:00
长真 d800060e06 test(chat): cover toolbar command edges 2026-08-05 19:47:02 +08:00
长真 8d8206f791 Merge remote-tracking branch 'upstream/main' into feat/chat-toolbar-commands 2026-08-05 19:20:34 +08:00
长真 15c2bd50b8 test(schema): derive chat shortcut counts 2026-08-05 19:16:25 +08:00
长真 711d557b93 fix(chat): resolve schema policy BLOCK in toolbar remove-custom/create-custom
- Remove --yes from Selection.Examples in toolbar_remove_custom.go
  (schema_agent_examples.go forbids --yes in stored examples)
- Fix Confirmation "required" -> "user_required" in toolbar_remove_custom.go
  (schema catalog requires enum value from {not_required, user_required})
- Fix Idempotency "not_idempotent" -> "non_idempotent" in toolbar_create_custom.go
  (schema catalog requires enum value from {idempotent, non_idempotent, unknown})

Fixes: F1 BLOCK from stability-release-engineer round 5 review
2026-08-05 18:43:00 +08:00
长真 6102e9fc68 fix(chat): resolve code review BLOCK and WARNINGs for toolbar commands
- B1: Fix --sort-index 0 silent drop by using cmd.Flags().Changed()
  instead of value comparison in create-custom and update-custom
- W1: Add MarkFlagRequired("shortcut-id") in remove-custom and
  update-custom for consistent error messages
- W2: Extend SYSTEM_BUSY error handling to all write commands
  (add/hide/create-custom/remove-custom/update-custom)
- W3: Add duplicate key detection in parseExtension to prevent
  silent data loss on repeated --extension keys
2026-08-05 18:09:35 +08:00
长真 ae426f37de feat(chat): add toolbar custom CRUD commands
Add 3 custom shortcut bar CRUD subcommands and update skill docs.

- toolbar_create_custom.go: create custom entry with extension parsing
  and org-id-list support (write/medium, not_idempotent)
- toolbar_remove_custom.go: delete custom entry with --yes confirmation
  gate (write/medium, confirmation required)
- toolbar_update_custom.go: update custom entry with same parameter set
  as create-custom plus shortcut-id (write/medium)
- chat.md: add toolbar command group documentation with all 7 subcommands
2026-08-05 17:58:23 +08:00
长真 4dbfaa4cef feat(chat): add toolbar commands (list/add/hide/sort)
Add `dws chat toolbar` command group with shared helpers and 4 basic
subcommands for managing conversation shortcut bar visibility and order.

- toolbar_helpers.go: shared utilities (hasIntersection, isSystemBusy,
  parseExtension, toolbarConversationID, toolbarNewSystemBusyError)
- toolbar_helpers_test.go: unit tests for shared helpers
- toolbar.go: command group entry assembling 7 subcommands
- toolbar_list.go: list shortcut entries (read/low)
- toolbar_add.go: add entries to visible area (write/low)
- toolbar_hide.go: hide entries from visible area (write/low)
- toolbar_sort.go: sort entries with intersection validation and
  SYSTEM_BUSY error handling (write/low)
- chat.go: mount newChatToolbarCommand() to chat root
2026-08-05 17:58:09 +08:00
133 changed files with 19454 additions and 1028 deletions
+285
View File
@@ -0,0 +1,285 @@
'use strict';
// 评审归属是受保护分支上的声明式规则;未知路径不猜测,交给工作流负载均衡兜底。
const REVIEWER_POOL = ['wxianfeng', 'typefield', 'haofeng0705', 'hlzjsong'];
const PRODUCT_GROUPS = [
{
primary: 'wxianfeng',
backup: 'typefield',
products: ['chat', 'contact', 'ding', 'event', 'mail', 'live', 'conference', 'dev', 'devapp', 'mcp', 'aiapp'],
},
{
primary: 'typefield',
backup: 'wxianfeng',
products: ['doc', 'drive', 'wiki', 'markdown', 'docparse', 'aidesign', 'devdoc', 'blackboard', 'finance', 'law', 'credit'],
},
{
primary: 'haofeng0705',
backup: 'typefield',
products: ['minutes', 'sheet', 'aitable', 'calendar', 'todo', 'oa', 'attendance', 'report', 'agoal', 'aisearch', 'yida', 'hrbrain'],
},
];
const pathStartsWith = (prefixes) => (path) => prefixes.some((prefix) => path.startsWith(prefix));
const MODULES = [
{
id: 'security',
label: '登录、认证、权限、安全',
primary: 'hlzjsong',
backup: 'typefield',
requiresSecondary: true,
matches: pathStartsWith([
'internal/auth/',
'internal/keychain/',
'internal/audit/',
'internal/pat/',
'internal/security/',
'internal/safety/',
'pkg/edition/',
]),
},
{
id: 'delivery',
label: 'CI、测试、发布、安装',
primary: 'haofeng0705',
backup: 'wxianfeng',
requiresSecondary: true,
matches: (path) =>
path.startsWith('.github/') ||
path.startsWith('scripts/release/') ||
path.startsWith('scripts/policy/') ||
path.startsWith('scripts/dev/') ||
path.startsWith('scripts/install') ||
path.startsWith('Formula/') ||
path.startsWith('build/') ||
path.startsWith('internal/upgrade/') ||
path.startsWith('internal/app/upgrade') ||
path.startsWith('test/') ||
path.startsWith('verify/') ||
path.startsWith('.workflow/') ||
path === 'coverage.txt' ||
path === 'coverage-base.txt' ||
path === '.goreleaser.yaml' ||
path === 'package.json' ||
path === 'package-lock.json' ||
path === 'docs/releasing.md',
},
{
id: 'architecture',
label: 'DWS 架构、公共内核',
primary: 'wxianfeng',
backup: 'typefield',
requiresSecondary: true,
matches: pathStartsWith([
'cmd/',
'internal/apiclient/',
'internal/app/',
'internal/cli/',
'internal/cobracmd/',
'internal/corecmd/',
'internal/errors/',
'internal/executor/',
'internal/generator/',
'internal/i18n/',
'internal/interfacesnapshot/',
'internal/jsonutil/',
'internal/localio/',
'internal/logging/',
'internal/output/',
'internal/pipeline/',
'internal/plugin/',
'internal/profilectx/',
'internal/registry/',
'internal/syncdata/',
'internal/testseam/',
'internal/transport/',
'pkg/',
]),
},
{
id: 'compatibility',
label: '兼容性',
primary: 'wxianfeng',
backup: 'typefield',
requiresSecondary: true,
matches: (path) =>
/(?:^|[/_.-])compat(?:ibility)?(?=$|[/_.-])/.test(path) ||
path.includes('schema_compat'),
},
];
function productMatches(path, product) {
const aliases = product === 'blackboard' ? ['blackboard', 'whiteboard'] : [product];
return aliases.some((alias) => new RegExp(`(?:^|[/_.-])${alias}(?=$|[/_.-])`).test(path));
}
const PRODUCT_MODULES = PRODUCT_GROUPS.flatMap((group) =>
group.products.map((product) => ({
id: `product:${product}`,
label: `产品:${product}`,
primary: group.primary,
backup: group.backup,
requiresSecondary: false,
matches: (path) => productMatches(path, product),
})),
);
const ALL_MODULES = [MODULES[0], MODULES[1], ...PRODUCT_MODULES, MODULES[2], MODULES[3]];
function normalizedPaths(file) {
return [file?.filename, file?.previous_filename]
.filter((path) => typeof path === 'string' && path !== '')
.map((path) => path.toLowerCase());
}
function compareStats(left, right) {
return right.files - left.files || left.module.order - right.module.order || left.module.id.localeCompare(right.module.id);
}
function classifyFiles(files) {
const counts = new Map();
for (const file of files || []) {
const matchingModules = new Set();
for (const path of normalizedPaths(file)) {
const matches = ALL_MODULES.filter((module) => module.matches(path));
const securityOrDelivery = matches.filter(
(module) => module.id === 'security' || module.id === 'delivery',
);
const effectiveMatches = securityOrDelivery.length > 0
? [...securityOrDelivery, ...matches.filter((module) => module.id === 'compatibility')]
: matches;
for (const match of effectiveMatches) {
matchingModules.add(match.id);
}
if (
effectiveMatches.length === 0 &&
(path.startsWith('internal/helpers/') || path.startsWith('internal/shortcut/'))
) {
matchingModules.add('architecture');
}
}
for (const moduleID of matchingModules) {
counts.set(moduleID, (counts.get(moduleID) || 0) + 1);
}
}
return [...counts.entries()]
.map(([id, files]) => {
const index = ALL_MODULES.findIndex((module) => module.id === id);
return {module: {...ALL_MODULES[index], order: index}, files};
})
.sort(compareStats);
}
function chooseModuleReviewer(module, unavailable) {
return [module.primary, module.backup].find(
(reviewer) => REVIEWER_POOL.includes(reviewer) && !unavailable.has(reviewer),
);
}
function addReviewer(reviewers, reviewer) {
if (reviewer && !reviewers.includes(reviewer)) {
reviewers.push(reviewer);
}
}
function reviewerCandidates({preferredReviewers, fallbackReviewers, eligibleReviewers}) {
const eligible = new Set(eligibleReviewers.map((reviewer) => reviewer.toLowerCase()));
const candidates = [];
for (const reviewer of [...preferredReviewers, ...fallbackReviewers]) {
if (
eligible.has(reviewer.toLowerCase()) &&
!candidates.some((candidate) => candidate.toLowerCase() === reviewer.toLowerCase())
) {
candidates.push(reviewer);
}
}
return candidates;
}
async function requestReviewersWithFallback({
candidates,
requiredReviewers,
satisfiedReviewers = [],
requestReviewer,
onFailure = () => {},
}) {
const alreadySatisfied = new Set(
satisfiedReviewers.map((reviewer) => reviewer.toLowerCase()),
);
const satisfied = new Set();
const requested = [];
for (const reviewer of candidates) {
if (satisfied.size >= requiredReviewers) {
break;
}
const normalizedReviewer = reviewer.toLowerCase();
if (alreadySatisfied.has(normalizedReviewer)) {
satisfied.add(normalizedReviewer);
continue;
}
try {
const shouldContinue = await requestReviewer(reviewer);
if (shouldContinue === false) {
return {requested, satisfiedReviewers: [...satisfied], aborted: true};
}
requested.push(reviewer);
satisfied.add(normalizedReviewer);
} catch (error) {
onFailure(reviewer, error);
}
}
return {requested, satisfiedReviewers: [...satisfied], aborted: false};
}
function resolveReviewRouting({files, author, latestPusher, fallbackReviewers = REVIEWER_POOL}) {
const modules = classifyFiles(files);
const unavailable = new Set([author, latestPusher].filter(Boolean).map((login) => login.toLowerCase()));
const reviewers = [];
const primaryModule = modules[0];
if (!primaryModule) {
return {modules: [], reviewers, requiredReviewers: 1, reason: 'unknown_paths'};
}
addReviewer(reviewers, chooseModuleReviewer(primaryModule.module, unavailable));
const requiresSecondary =
modules.length > 1 || modules.some(({module}) => module.requiresSecondary);
const secondaryModule = modules.find(({module}) => module.id !== primaryModule.module.id) || primaryModule;
if (requiresSecondary) {
addReviewer(
reviewers,
chooseModuleReviewer(secondaryModule.module, new Set([...unavailable, ...reviewers])),
);
}
for (const reviewer of fallbackReviewers) {
if (reviewers.length >= (requiresSecondary ? 2 : 1)) {
break;
}
if (REVIEWER_POOL.includes(reviewer) && !unavailable.has(reviewer)) {
addReviewer(reviewers, reviewer);
}
}
return {
modules: modules.map(({module, files}) => ({id: module.id, label: module.label, files})),
reviewers,
requiredReviewers: requiresSecondary ? 2 : 1,
reason: requiresSecondary ? 'cross_or_sensitive' : 'single_module',
};
}
module.exports = {
REVIEWER_POOL,
classifyFiles,
requestReviewersWithFallback,
resolveReviewRouting,
reviewerCandidates,
};
+148
View File
@@ -0,0 +1,148 @@
'use strict';
const assert = require('node:assert/strict');
const {
requestReviewersWithFallback,
resolveReviewRouting,
reviewerCandidates,
} = require('./reviewer-routing');
function route(files, author = 'author', latestPusher = author) {
return resolveReviewRouting({files: files.map((filename) => ({filename})), author, latestPusher});
}
{
const result = route(['internal/helpers/chat_toolbar.go']);
assert.deepEqual(result.reviewers, ['wxianfeng']);
assert.equal(result.requiredReviewers, 1);
assert.deepEqual(result.modules.map((module) => module.id), ['product:chat']);
}
{
const result = route(['internal/helpers/chat_toolbar.go', 'internal/helpers/doc_style.go']);
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['.github/workflows/ci.yml']);
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
assert.equal(result.requiredReviewers, 2);
assert.equal(result.reason, 'cross_or_sensitive');
}
{
const result = route(['internal/auth/login.go'], 'hlzjsong');
assert.deepEqual(result.reviewers, ['typefield', 'wxianfeng']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['internal/upgrade/downloader.go']);
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['internal/app/upgrade.go', 'scripts/dev/test-release.sh']);
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['pkg/edition/edition.go']);
assert.deepEqual(result.reviewers, ['hlzjsong', 'typefield']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['internal/shortcut/chat/compatibility_coverage_test.go']);
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
assert.equal(result.requiredReviewers, 2);
assert.deepEqual(result.modules.map((module) => module.id), ['product:chat', 'compatibility']);
}
{
const result = route(['internal/helpers/leaf_dispatch.go']);
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
assert.equal(result.requiredReviewers, 2);
}
{
const result = route(['docs/unknown-area.md']);
assert.deepEqual(result.reviewers, []);
assert.equal(result.reason, 'unknown_paths');
}
async function testSingleReviewerFallback() {
const candidates = reviewerCandidates({
preferredReviewers: ['wxianfeng'],
fallbackReviewers: ['wxianfeng', 'typefield', 'haofeng0705'],
eligibleReviewers: ['wxianfeng', 'typefield', 'haofeng0705'],
});
const attempts = [];
const result = await requestReviewersWithFallback({
candidates,
requiredReviewers: 1,
requestReviewer: async (reviewer) => {
attempts.push(reviewer);
if (reviewer === 'wxianfeng') {
throw Object.assign(new Error('cannot request primary'), {status: 422});
}
return true;
},
});
assert.deepEqual(attempts, ['wxianfeng', 'typefield']);
assert.deepEqual(result.requested, ['typefield']);
assert.equal(result.satisfiedReviewers.length, 1);
}
async function testTwoReviewerFallback() {
const candidates = reviewerCandidates({
preferredReviewers: ['haofeng0705', 'wxianfeng'],
fallbackReviewers: ['haofeng0705', 'wxianfeng', 'typefield', 'hlzjsong'],
eligibleReviewers: ['haofeng0705', 'wxianfeng', 'typefield', 'hlzjsong'],
});
const attempts = [];
const result = await requestReviewersWithFallback({
candidates,
requiredReviewers: 2,
requestReviewer: async (reviewer) => {
attempts.push(reviewer);
if (reviewer === 'wxianfeng') {
throw Object.assign(new Error('temporary failure'), {status: 503});
}
return true;
},
});
assert.deepEqual(attempts, ['haofeng0705', 'wxianfeng', 'typefield']);
assert.deepEqual(result.requested, ['haofeng0705', 'typefield']);
assert.equal(result.satisfiedReviewers.length, 2);
}
async function testLowerPriorityExistingRequestDoesNotReplaceOwner() {
const attempts = [];
const result = await requestReviewersWithFallback({
candidates: ['wxianfeng', 'typefield'],
requiredReviewers: 1,
satisfiedReviewers: ['typefield'],
requestReviewer: async (reviewer) => {
attempts.push(reviewer);
return true;
},
});
assert.deepEqual(attempts, ['wxianfeng']);
assert.deepEqual(result.requested, ['wxianfeng']);
assert.deepEqual(result.satisfiedReviewers, ['wxianfeng']);
}
Promise.all([
testSingleReviewerFallback(),
testTwoReviewerFallback(),
testLowerPriorityExistingRequestDoesNotReplaceOwner(),
])
.then(() => console.log('reviewer routing policy tests passed'))
.catch((error) => {
console.error(error);
process.exitCode = 1;
});
+4
View File
@@ -433,6 +433,10 @@ jobs:
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
- name: Test reviewer routing policy
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
run: node .github/reviewer-routing.test.js
test-focused:
name: Test (changed packages)
needs: lint
+87 -44
View File
@@ -21,6 +21,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out trusted routing policy
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- name: Route review and enable auto-merge
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
with:
@@ -29,12 +34,13 @@ jobs:
const repo = context.repo.repo;
const pullNumber = context.payload.pull_request.number;
const eventHeadSha = context.payload.pull_request.head.sha;
const reviewerPool = [
'sczheng189',
'shangguanxuan633-lab',
'audanye-sudo',
'wxianfeng',
];
const {
REVIEWER_POOL,
requestReviewersWithFallback,
resolveReviewRouting,
reviewerCandidates,
} = require('./.github/reviewer-routing.js');
const reviewerPool = REVIEWER_POOL;
async function getReadyEventPull(phase) {
const {data: currentPull} = await github.rest.pulls.get({
@@ -67,6 +73,21 @@ jobs:
: author;
async function routeReview() {
let changedFiles;
try {
changedFiles = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: pullNumber,
per_page: 100,
});
} catch (error) {
core.warning(
`Could not inspect changed files for PR #${pullNumber}; using load-balanced fallback (${error.status || 'unknown status'}).`,
);
changedFiles = [];
}
const eligible = reviewerPool.filter(
reviewer =>
reviewer.toLowerCase() !== author &&
@@ -77,13 +98,9 @@ jobs:
return;
}
const alreadyRequested =
(pullRequest.requested_reviewers || []).length > 0 ||
(pullRequest.requested_teams || []).length > 0;
if (alreadyRequested) {
core.info(`PR #${pullNumber} already has a requested reviewer; leaving it unchanged.`);
return;
}
const existingRequestedReviewers = new Set(
(pullRequest.requested_reviewers || []).map(({login}) => login.toLowerCase()),
);
let reviews;
try {
@@ -116,22 +133,18 @@ jobs:
latestDecisionByLogin.set(login, review);
}
}
const currentHeadDecision = [...latestDecisionByLogin.values()].find(
review =>
review.commit_id === headSha &&
eligible.some(
reviewer =>
reviewer.toLowerCase() ===
review.user.login.toLowerCase(),
) &&
['APPROVED', 'CHANGES_REQUESTED'].includes(review.state),
const currentHeadReviewers = new Set(
[...latestDecisionByLogin.values()]
.filter(
review =>
review.commit_id === headSha &&
eligible.some(
reviewer => reviewer.toLowerCase() === review.user.login.toLowerCase(),
) &&
['APPROVED', 'CHANGES_REQUESTED'].includes(review.state),
)
.map(review => review.user.login.toLowerCase()),
);
if (currentHeadDecision) {
core.info(
`PR #${pullNumber} already has a ${currentHeadDecision.state} review on its current head; leaving review ownership unchanged.`,
);
return;
}
const loads = new Map(eligible.map(reviewer => [reviewer, 0]));
try {
@@ -178,20 +191,42 @@ jobs:
tieOrder.get(left) - tieOrder.get(right),
);
for (const reviewer of ranked) {
try {
const routing = resolveReviewRouting({
files: changedFiles,
author,
latestPusher,
fallbackReviewers: ranked,
});
const candidates = reviewerCandidates({
preferredReviewers: routing.reviewers,
fallbackReviewers: ranked,
eligibleReviewers: eligible,
});
const desiredReviewers = candidates.slice(0, routing.requiredReviewers);
if (routing.reason === 'unknown_paths' && currentHeadReviewers.size > 0) {
core.info(
`PR #${pullNumber} has a current-head review for unknown paths; leaving manual ownership unchanged.`,
);
return;
}
core.info(
`PR #${pullNumber} routing: ${routing.reason}; modules=${routing.modules.map(module => module.id).join(',') || 'unknown'}; reviewers=${desiredReviewers.join(',') || 'load-balanced fallback'}.`,
);
const satisfiedReviewers = new Set([
...currentHeadReviewers,
...[...existingRequestedReviewers].filter((reviewer) =>
candidates.some((candidate) => candidate.toLowerCase() === reviewer),
),
]);
const requestResult = await requestReviewersWithFallback({
candidates,
requiredReviewers: routing.requiredReviewers,
satisfiedReviewers: [...satisfiedReviewers],
requestReviewer: async (reviewer) => {
const currentPull = await getReadyEventPull('review request');
if (!currentPull) {
return;
}
if (
(currentPull.requested_reviewers || []).length > 0 ||
(currentPull.requested_teams || []).length > 0
) {
core.info(
`PR #${pullNumber} received a reviewer while routing; leaving it unchanged.`,
);
return;
return false;
}
await github.rest.pulls.requestReviewers({
owner,
@@ -202,15 +237,23 @@ jobs:
core.info(
`Requested @${reviewer} for PR #${pullNumber} (open request load: ${loads.get(reviewer)}).`,
);
return;
} catch (error) {
return true;
},
onFailure: (reviewer, error) => {
core.warning(
`Could not request @${reviewer} for PR #${pullNumber}; trying the next candidate (${error.status || 'unknown status'}).`,
);
}
},
});
if (requestResult.aborted) {
return;
}
core.warning(`No reviewer request could be created for PR #${pullNumber}.`);
if (requestResult.satisfiedReviewers.length < routing.requiredReviewers) {
core.warning(
`Only ${requestResult.satisfiedReviewers.length} of ${routing.requiredReviewers} required reviewers could be satisfied for PR #${pullNumber}.`,
);
}
}
async function enableAutoMerge() {
+44
View File
@@ -6,15 +6,58 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [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 +192,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.
+1
View File
@@ -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
+7 -6
View File
@@ -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)
+778 -61
View File
@@ -1,209 +1,926 @@
{
"generated_at": "2026-08-05T22:43:52.497190",
"count": 294,
"generated_at": "2026-08-06T21:08:26.697858",
"count": 357,
"results": [
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+advperm-disable",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "关闭高级权限会使自定义角色失效,显式发布高风险事实。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+advperm-enable",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "开启 Base 高级权限总开关的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+attachment-put",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "完整执行 prepare、HTTP PUT、attachment cell 覆盖和读回验证;空写回包只有最终附件可证明时成功。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+attachment-remove",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "清空或按文件名移除附件;若读回缺剩余 fileToken,会在写前明确停止而不破坏其他附件。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+attachment-upload",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "申请附件 OSS 直传地址和 fileToken;只负责上传准备,不伪造文件已上传。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+base-bootstrap",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "声明式创建 Base、表和字段,字段自动分片,逐层读回验证并报告部分副作用。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+base-copy",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一完整复制与仅结构复制意图,并把目标目录映射为可执行参数。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+base-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "不可逆删除整个 Base 的兼容入口,显式发布高风险确认事实。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+base-get",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "保留 Base 目录读取的 Shortcut 兼容入口;底层 leaf 已提供同等读取能力。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+base-get-primary-doc-id",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "已知 base/table/record 时读取主键文档 dentryUuid 的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+base-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "把最近访问 Base 响应稳定投影为 baseId/baseName 列表,作为未知 baseId 时的浏览入口。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+base-schema-snapshot",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "完整读取 Base、tables、fields、views,逐层区分合法空集合与缺失响应契约。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+base-search",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按名称关键词定位 Base,并稳定投影可继续操作的 baseId/baseName。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+base-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "更新 Base 名称和备注的一对一写入,复用统一确认门。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+chart-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "删除图表及布局项,显式发布不可逆高风险事实。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+chart-get",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取指定图表完整配置的一对一入口。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+chart-share-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取图表分享配置的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+chart-share-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一开启、关闭和设置图表分享类型。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+chart-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一更新图表配置或布局,并要求显式 config 输入。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+chart-widgets-example",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "返回所有图表类型的 widget config 示例,供构造合法图表配置。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+dashboard-arrange",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "触发服务端智能布局重排并保留统一写入确认。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+dashboard-config-example",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "返回 dashboard config 的服务端结构示例,供后续写入构造合法参数。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+dashboard-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "级联删除仪表盘及图表,显式发布不可逆高风险事实。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+dashboard-get",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "读取仪表盘详情及 charts summary,作为图表操作导航入口。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+dashboard-share-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取仪表盘分享配置的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+dashboard-share-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一开启、关闭和设置仪表盘分享类型。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+dashboard-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "更新仪表盘配置的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+export-data",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "统一创建导出任务与按 taskId 续等,直到得到可下载的导出结果或明确失败。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+field-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "不可逆删除字段的一对一入口,显式发布数据丢失风险。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+field-get",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "批量获取字段完整类型配置,避免仅返回名称的损失投影。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+field-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一字段名称、类型配置和 AI 配置的增量更新输入。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+find-record",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "将关键词查询投影为稳定的 recordId/cells 结果,适合先定位再写入。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+form-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "不可逆删除表单视图的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+form-field-hide",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "用隐藏/显示布尔语义切换表单字段可见性。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+form-field-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "列出表单当前字段顺序、可见性、必填和描述配置。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+form-field-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一更新表单字段必填状态与描述。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+form-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "从表的视图中稳定筛出表单视图并返回表单目录。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+form-share-get",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取当前视图分享表单配置的一对一入口。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+form-share-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一开启或关闭指定视图的分享表单。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+form-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一更新表单标题和描述。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+import-data",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一将已上传文件导入新表或追加到已有表的目标语义。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+import-upload",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "申请导入 OSS 直传地址和 importId;只负责上传准备,不伪造导入完成。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+list-tables",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "把 Base 目录稳定投影为 tableId/tableName 列表,隐藏非表节点。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+record-bulk-patch",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "先完整解析显式选择范围和 max-matches,再将同一 cells patch 自动分片写入并逐条读回验证。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+record-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "自动按 100 条分片删除;每批仅在所有目标 recordId 读回均不存在时成功,失败提供 nextOffset。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+record-history-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "按 recordId 查询变更历史的一对一只读入口。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+record-primary-doc-create",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "以幂等意图为 primaryDoc 字段创建关联文档。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+record-primary-doc-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取记录主键文档 nodeId 的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+record-query",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一 ID、筛选、关键词和分页查询,并投影稳定的 records/count/cursor 结果。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+record-query-empty",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "扫描记录并按用户字段为空的语义过滤空行,避免调用方手工判断 cells。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+record-share-links",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "对超过 20 条 recordId 去重分片调用并合并分享链接及逐批结果。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+record-share-url",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "将最多 20 个 recordId 统一映射为可分享链接列表。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+record-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "自动按 100 条分片更新并逐条读回验证;失败返回可续跑的 nextOffset。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+record-upsert",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按 recordId 拆分 create/update,自动按 100 条分片;创建必须返回新 recordId,全部结果读回验证。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+record-upsert-by-key",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "先按唯一 fieldId/value 完整查询,0 条创建、1 条更新、2 条以上停止,并读回验证最终 cells。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+resolve-base",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按名称搜索并要求唯一匹配,0 或多结果时显式失败而不猜 baseId。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+resolve-table",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "在 Base 内按名称要求唯一匹配,0 或多结果时显式失败而不猜 tableId。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+role-create",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一角色名称、成员与权限配置的创建输入。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+role-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "不可逆删除自定义角色的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+role-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取单个角色完整权限配置的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+role-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "列出 Base 全部自定义角色的一对一入口。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+role-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按 PATCH 语义增量更新角色配置,未提供字段保持不变。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+section-create",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "在 Base 目录树创建用于组织表和仪表盘的文件夹。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+section-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "不可逆删除目录文件夹的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+section-list-empty",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "扫描目录树并只返回没有子节点的空文件夹。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+section-list-nodes",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "列出当前版本全部 nsheet 节点,提供表、仪表盘和文件夹统一目录。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+section-move-node",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一移动任意 nsheet 节点到文件夹或 Base 根目录,并可指定位置。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+section-rename",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "重命名目录文件夹的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+section-reorder",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按当前父目录语义调整文件夹展示顺序。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+table-copy",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "在没有服务端 table-copy task 的情况下,本地重建安全字段、映射 fieldId,并可分片复制及读回验证全部记录。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+table-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "不可逆删除数据表的一对一入口,显式发布高风险确认事实。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+table-get",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "批量读取表级信息、字段和视图目录,为后续 Schema 与记录操作提供 ID。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+table-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "统一表名、备注和行命名规则的 PATCH 风格更新。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+template-search",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按名称关键词搜索 AI 表格模板并返回可用于创建的 templateId。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+url-resolve",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "严格解析 alidocs AI 表格 URL 的 base/table/view/record ID,并可通过最深层只读接口验证目标存在。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+view-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "不可逆删除视图的一对一入口,显式发布高风险确认事实。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+view-duplicate",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "复制现有视图配置并生成新视图的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+view-get",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "读取视图完整列顺序、筛选、排序和分组配置。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+view-get-frozen-cols",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取 Grid 视图冻结列数的一对一入口。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+view-get-lock",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取视图锁定状态的一对一入口。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "aitable",
"command": "+view-get-row-height",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取视图行高像素值的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+view-lock",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "用默认锁定与 --off 解锁统一表达视图锁状态切换。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+view-preset-apply",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按精确名称幂等创建或更新视图预设,拒绝同名歧义和类型漂移,并读回校验 config。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+view-set-fill-color-rule",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "以全量覆盖语义设置条件填色规则,并用空数组明确清空。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+view-set-frozen-cols",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "设置冻结列数并以 0 明确表达取消冻结。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+view-set-row-height",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "把行高限制为服务支持的 32/56/88/128 像素档位后写入。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+view-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一视图名称、描述、可见列、筛选、排序和分组配置更新。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+workflow-deploy",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "创建或更新完整 workflow-dsl/v1,强制 valid=true/flowId,并可启用后从 list 验证 RUNNING。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+workflow-disable",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "禁用工作流会影响业务自动化,显式发布高风险确认事实。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+workflow-enable",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "启用指定自动化工作流的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+workflow-get",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "读取单个自动化工作流详情的一对一入口。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+workflow-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "分页列出 Base 自动化工作流的一对一入口。",
"availability": "available"
},
{
"suite": "read",
@@ -1964,7 +2681,7 @@
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "一体化创建会话、上传、确认转换并轮询导入结果。",
"semantic_delta": "一体化创建会话、上传、确认转换并轮询导入结果;白名单外格式自动改走文件上传原样入库(fallback=upload、converted=false)。",
"availability": "available"
},
{
+18
View File
@@ -173,6 +173,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"},
@@ -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"},
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 294
publicShortcutCount = 357
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including hidden leaves such as minutes.shortcut_minutes_search.
schemaPublishedShortcutCount = 295
schemaPublishedShortcutCount = 358
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 294
publiclyDeliveredShortcutCount = 357
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -114,7 +114,7 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
product := executeShortcutSchemaQuery(t, "chat")
productPayload, _ := product["product"].(map[string]any)
if got, want := int(product["count"].(float64)), 180; got != want {
if got, want := int(product["count"].(float64)), 187; got != want {
t.Fatalf("schema chat count = %d, want %d", got, want)
}
summaries := schemaContractObjectSlice(productPayload["tools"])
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -148,7 +148,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 +361,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"},
+179 -94
View File
@@ -5,12 +5,15 @@ import (
"encoding/json"
"fmt"
"io"
"math"
"os"
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/paging"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
@@ -123,113 +126,195 @@ func resolveWorkflowDSL(cmd *cobra.Command) (map[string]any, error) {
}
// recordQueryFetchAll implements --all auto-pagination for record query.
// - pageLimit controls max pages (default 50, 0 = unlimited)
// - Mid-loop errors break gracefully, outputting already-fetched data
// - Merged result preserves last page's cursor so caller can resume
// It prints only a complete result. A page limit, empty/invalid response,
// transport failure, or cursor cycle returns a non-zero structured error whose
// details retain the incomplete records and retry cursor.
func recordQueryFetchAll(toolArgs map[string]any, pageLimit int) error {
const pageDelayMs = 200
const serverID = "aitable"
ctx := context.Background()
var allRecords []any
page := 0
lastCursor := ""
for {
page++
if page > 1 {
helperSleep(time.Duration(pageDelayMs) * time.Millisecond)
}
fmt.Fprintf(os.Stderr, "[page %d] fetching...\n", page)
result, err := deps.Caller.CallTool(ctx, serverID, "query_records", toolArgs)
if err != nil {
if page == 1 {
return WrapError(err)
}
fmt.Fprintf(os.Stderr, "[page %d] error, stopping pagination: %v\n", page, err)
break
}
// Extract text content from tool result
var text string
for _, c := range result.Content {
if c.Type == "text" && c.Text != "" {
text = c.Text
break
}
}
if text == "" {
break
}
var resp map[string]any
if err := json.Unmarshal([]byte(text), &resp); err != nil {
if page == 1 {
deps.Out.PrintRaw(text)
return nil
}
break
}
// Extract records from response
var pageRecords []any
if data, ok := resp["data"].(map[string]any); ok {
if recs, ok := data["records"].([]any); ok {
pageRecords = recs
}
} else if recs, ok := resp["records"].([]any); ok {
pageRecords = recs
}
allRecords = append(allRecords, pageRecords...)
// Extract cursor for next page (MCP returns "nextCursor" or "cursor")
cursor := ""
if data, ok := resp["data"].(map[string]any); ok {
if c, ok := data["nextCursor"].(string); ok && c != "" {
cursor = c
} else if c, ok := data["cursor"].(string); ok && c != "" {
cursor = c
}
} else if c, ok := resp["nextCursor"].(string); ok && c != "" {
cursor = c
} else if c, ok := resp["cursor"].(string); ok && c != "" {
cursor = c
}
requestArgs := make(map[string]any, len(toolArgs))
for key, value := range toolArgs {
requestArgs[key] = value
}
initialCursor, _ := requestArgs["cursor"].(string)
effectivePageLimit := pageLimit
if pageLimit == 0 {
effectivePageLimit = paging.UnlimitedPageLimit
}
result := paging.FetchAll(ctx, func(ctx context.Context, cursor string) (paging.Page, error) {
if cursor == "" {
lastCursor = ""
break
delete(requestArgs, "cursor")
} else {
requestArgs["cursor"] = cursor
}
// Check page limit (0 = unlimited)
if pageLimit > 0 && page >= pageLimit {
lastCursor = cursor
fmt.Fprintf(os.Stderr, "[pagination] reached page limit (%d), stopping. Use --page-limit 0 to fetch all.\n", pageLimit)
break
toolResult, err := deps.Caller.CallTool(ctx, serverID, "query_records", requestArgs)
if err != nil {
return paging.Page{}, err
}
if toolResult == nil {
return paging.Page{}, fmt.Errorf("query_records returned a nil result")
}
for _, content := range toolResult.Content {
if content.Type == "text" && strings.TrimSpace(content.Text) != "" {
return parseRecordQueryPage(content.Text)
}
}
return paging.Page{}, fmt.Errorf("query_records returned no non-empty text content")
}, paging.Options{
PageLimit: effectivePageLimit,
InterPageDelay: paging.DefaultInterPageDelay,
InitialCursor: initialCursor,
})
lastCursor = ""
toolArgs["cursor"] = cursor
if !result.Complete {
return recordQueryIncompleteError(result, pageLimit)
}
fmt.Fprintf(os.Stderr, "[pagination] done: %d pages, %d total records\n", page, len(allRecords))
// Build merged output — preserve cursor & has_more for resume capability
fmt.Fprintf(os.Stderr, "[pagination] complete: %d pages, %d fetched records\n", result.Pages, len(result.Records))
mergedData := map[string]any{
"records": allRecords,
"totalCount": len(allRecords),
"records": result.Records,
"fetchedCount": len(result.Records),
"hasMore": false,
"complete": true,
"pages": result.Pages,
}
if lastCursor != "" {
mergedData["cursor"] = lastCursor
mergedData["hasMore"] = true
} else {
mergedData["hasMore"] = false
if result.TotalCount != nil {
mergedData["totalCount"] = *result.TotalCount
}
return deps.Out.PrintJSON(map[string]any{"data": mergedData})
}
func parseRecordQueryPage(text string) (paging.Page, error) {
decoder := json.NewDecoder(strings.NewReader(text))
decoder.UseNumber()
var response map[string]any
if err := decoder.Decode(&response); err != nil {
return paging.Page{}, fmt.Errorf("query_records returned invalid JSON: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
return paging.Page{}, fmt.Errorf("query_records returned more than one JSON value")
}
return paging.Page{}, fmt.Errorf("query_records returned trailing invalid JSON: %w", err)
}
if response == nil {
return paging.Page{}, fmt.Errorf("query_records returned null instead of an object")
}
return deps.Out.PrintJSON(map[string]any{"data": mergedData})
payload := response
if rawData, exists := response["data"]; exists {
data, ok := rawData.(map[string]any)
if !ok {
return paging.Page{}, fmt.Errorf("query_records data must be an object, got %T", rawData)
}
payload = data
}
rawRecords, exists := payload["records"]
if !exists {
return paging.Page{}, fmt.Errorf("query_records response is missing records")
}
records, ok := rawRecords.([]any)
if !ok {
return paging.Page{}, fmt.Errorf("query_records records must be an array, got %T", rawRecords)
}
for index, record := range records {
if _, ok := record.(map[string]any); !ok {
return paging.Page{}, fmt.Errorf("query_records records[%d] must be an object, got %T", index, record)
}
}
nextCursor := firstNonEmptyString(payload, "nextCursor", "cursor")
if hasMore, ok := payload["hasMore"].(bool); ok && hasMore && nextCursor == "" {
return paging.Page{}, fmt.Errorf("query_records reported hasMore=true without a next cursor")
}
totalCount, err := parseOptionalNonNegativeInt(payload["totalCount"])
if err != nil {
return paging.Page{}, fmt.Errorf("query_records totalCount: %w", err)
}
return paging.Page{Records: records, NextCursor: nextCursor, TotalCount: totalCount}, nil
}
func firstNonEmptyString(values map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := values[key].(string); ok && strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func parseOptionalNonNegativeInt(value any) (*int, error) {
if value == nil {
return nil, nil
}
var parsed int64
switch typed := value.(type) {
case json.Number:
integer, err := typed.Int64()
if err != nil {
return nil, fmt.Errorf("must be an integer, got %q", typed)
}
parsed = integer
case float64:
if math.Trunc(typed) != typed {
return nil, fmt.Errorf("must be an integer, got %v", typed)
}
parsed = int64(typed)
case int:
parsed = int64(typed)
case int64:
parsed = typed
default:
return nil, fmt.Errorf("must be a non-negative integer, got %T", value)
}
if parsed < 0 || int64(int(parsed)) != parsed {
return nil, fmt.Errorf("must fit a non-negative int, got %d", parsed)
}
result := int(parsed)
return &result, nil
}
func recordQueryIncompleteError(result paging.Result, pageLimit int) error {
incomplete := map[string]any{
"records": result.Records,
"fetchedCount": len(result.Records),
"pages": result.Pages,
"attempts": result.Attempts,
"complete": false,
"hasMore": result.HasMore,
"stopReason": result.StopReason,
}
if result.LastCursor != "" {
incomplete["cursor"] = result.LastCursor
}
if result.TotalCount != nil {
incomplete["totalCount"] = *result.TotalCount
}
hint := "retry the command; the structured error details preserve fetched records and the retry cursor"
if result.StopReason == paging.StopPageLimit {
hint = "rerun with --page-limit 0 to require a complete result, or resume from details.cursor"
}
message := fmt.Sprintf("record pagination incomplete after %d successful page(s) and %d fetched record(s)", result.Pages, len(result.Records))
return apperrors.NewAPI(message,
apperrors.WithOperation("aitable.query_records.all"),
apperrors.WithServerKey("aitable"),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("pagination"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(true),
apperrors.WithReason("pagination_"+string(result.StopReason)),
apperrors.WithHint(hint),
apperrors.WithDetails(map[string]any{
"page_limit": pageLimit,
"incomplete_result": incomplete,
}),
apperrors.WithCause(result.Err),
)
}
// ─── filters 格式校验 ──────────────────────────────────────────────────────
@@ -6966,8 +7051,8 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
recordQueryCmd.Flags().Int("page-size", 0, "--limit 的别名(兼容 LLM 常见误用)")
_ = recordQueryCmd.Flags().MarkHidden("page-size")
recordQueryCmd.Flags().String("cursor", "", "分页游标,首次查询不传;cursor 为空表示已取完全部记录")
recordQueryCmd.Flags().Bool("all", false, "自动翻页获取全部记录。传入时自动循环直到无更多数据或达到 --page-limit 上限")
recordQueryCmd.Flags().Int("page-limit", 50, "自动翻页最大页数(仅 --all 时生效)。默认 50 页(5000 条),设为 0 表示无限制")
recordQueryCmd.Flags().Bool("all", false, "自动翻页获取完整记录集;达到 --page-limit 且仍有更多页时返回非零结构化错误,不把不完整结果作为成功输出")
recordQueryCmd.Flags().Int("page-limit", 50, "自动翻页最大页数(仅 --all 时生效)。默认 50 页(约 5000 条);设为 0 表示显式不限页数;超限时错误详情保留已取记录和续传 cursor")
recordQueryCmd.Flags().String("view-id", "", "视图 ID(record query 不支持按视图过滤,此参数会被忽略并给出提示)")
_ = recordQueryCmd.Flags().MarkHidden("view-id")
recordCreateCmd.Flags().String("base-id", "", "Base ID,可通过 base list 或 base search 获取 (必填)")
+4 -4
View File
@@ -292,15 +292,15 @@ func TestCrossPlatformCoverageAitableToolResponseAndPaginationHelpers(t *testing
t.Fatalf("update view top-level: %v", err)
}
caller = &aitableTestCaller{responses: []string{`{"data":{"records":[{"id":1}],"nextCursor":"next"}}`}}
caller = &aitableTestCaller{responses: []string{`{"data":{"records":[{"id":1}],"totalCount":17}}`}}
out = installAitableDeps(t, caller)
if err := recordQueryFetchAll(map[string]any{}, 1); err != nil || !strings.Contains(out.String(), "totalCount") {
if err := recordQueryFetchAll(map[string]any{}, 1); err != nil || !strings.Contains(out.String(), `"totalCount": 17`) || !strings.Contains(out.String(), `"fetchedCount": 1`) {
t.Fatalf("paginated records = %q, %v", out.String(), err)
}
caller = &aitableTestCaller{responses: []string{"not-json"}}
out = installAitableDeps(t, caller)
if err := recordQueryFetchAll(map[string]any{}, 1); err != nil || !strings.Contains(out.String(), "not-json") {
t.Fatalf("raw first page = %q, %v", out.String(), err)
if err := recordQueryFetchAll(map[string]any{}, 1); err == nil || out.Len() != 0 {
t.Fatalf("invalid first page must fail without success output = %q, %v", out.String(), err)
}
caller = &aitableTestCaller{responses: []string{`{"records":[{"id":1}]}`}}
installAitableDeps(t, caller)
@@ -0,0 +1,93 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package helpers
import (
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/paging"
)
func TestCrossPlatformCoverageAITablePageParsingRemainingEdges(t *testing.T) {
for name, raw := range map[string]string{
"trailing invalid JSON": `{"records":[]} {`,
"data not object": `{"data":[]}`,
} {
t.Run(name, func(t *testing.T) {
if _, err := parseRecordQueryPage(raw); err == nil {
t.Fatalf("parseRecordQueryPage(%q) succeeded", raw)
}
})
}
}
func TestCrossPlatformCoverageAITableOptionalCountNumericTypes(t *testing.T) {
cases := []struct {
name string
value any
want int
wantErr bool
}{
{name: "bad number", value: json.Number("1.5"), wantErr: true},
{name: "fractional float", value: float64(1.5), wantErr: true},
{name: "whole float", value: float64(2), want: 2},
{name: "int", value: int(3), want: 3},
{name: "int64", value: int64(4), want: 4},
{name: "wrong type", value: "5", wantErr: true},
{name: "negative", value: int64(-1), wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parseOptionalNonNegativeInt(tc.value)
if tc.wantErr {
if err == nil {
t.Fatalf("value %#v succeeded: %#v", tc.value, got)
}
return
}
if err != nil || got == nil || *got != tc.want {
t.Fatalf("value %#v = %#v, %v", tc.value, got, err)
}
})
}
}
func TestCrossPlatformCoverageAITableIncompleteResultMetadata(t *testing.T) {
total := 9
err := recordQueryIncompleteError(paging.Result{
Records: []any{map[string]any{"id": "r"}}, Pages: 1, Attempts: 1,
HasMore: true, LastCursor: "next", StopReason: paging.StopPageLimit,
TotalCount: &total,
}, 1)
if err == nil || !strings.Contains(err.Error(), "pagination incomplete") {
t.Fatalf("incomplete result error = %v", err)
}
}
func TestCrossPlatformCoverageAITableExplicitUnlimitedPaginationE2E(t *testing.T) {
responses := make([]string, paging.DefaultPageLimit+1)
for index := range responses {
next := ""
if index < len(responses)-1 {
next = fmt.Sprintf(`,"nextCursor":"cursor-%d"`, index+1)
}
responses[index] = fmt.Sprintf(`{"records":[{"recordId":"record-%d"}]%s}`, index, next)
}
caller := &aitableTestCaller{responses: responses}
out := installAitableDeps(t, caller)
if err := recordQueryFetchAll(map[string]any{}, 0); err != nil {
t.Fatalf("explicit unlimited pagination failed: %v", err)
}
if len(caller.calls) != len(responses) {
t.Fatalf("explicit unlimited pagination calls = %d, want %d", len(caller.calls), len(responses))
}
for _, want := range []string{`"pages": 51`, `"fetchedCount": 51`, `"complete": true`} {
if !strings.Contains(out.String(), want) {
t.Fatalf("explicit unlimited output missing %s: %s", want, out.String())
}
}
}
@@ -0,0 +1,192 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package helpers
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"reflect"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type recordQueryE2EStep struct {
result *edition.ToolResult
err error
}
type recordQueryE2ECaller struct {
steps []recordQueryE2EStep
calls []aitableTestCall
}
func (c *recordQueryE2ECaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
cloned := make(map[string]any, len(args))
for key, value := range args {
cloned[key] = value
}
c.calls = append(c.calls, aitableTestCall{server: server, tool: tool, args: cloned})
index := len(c.calls) - 1
if index >= len(c.steps) {
return nil, fmt.Errorf("unexpected tool call %d", index+1)
}
return c.steps[index].result, c.steps[index].err
}
func (*recordQueryE2ECaller) Format() string { return "json" }
func (*recordQueryE2ECaller) DryRun() bool { return false }
func (*recordQueryE2ECaller) Fields() string { return "" }
func (*recordQueryE2ECaller) JQ() string { return "" }
func runRecordQueryCLI(t *testing.T, caller *recordQueryE2ECaller, extraArgs ...string) (string, error) {
t.Helper()
testseam.Protect(t, &deps)
oldArgs := os.Args
t.Cleanup(func() { os.Args = oldArgs })
InitDeps(caller)
out := &bytes.Buffer{}
deps.Out.w = out
deps.Out.errW = out
args := []string{"record", "query", "--base-id", "base-e2e", "--table-id", "table-e2e", "--all"}
args = append(args, extraArgs...)
os.Args = append([]string{"dws", "aitable"}, args...)
command := newAitableCommand()
command.SilenceErrors = true
command.SilenceUsage = true
command.SetArgs(args)
err := command.Execute()
return out.String(), err
}
func recordQueryTextStep(text string) recordQueryE2EStep {
return recordQueryE2EStep{result: textToolResult(text)}
}
func TestCrossPlatformCoverageRecordQueryCLICompleteE2E(t *testing.T) {
caller := &recordQueryE2ECaller{steps: []recordQueryE2EStep{
recordQueryTextStep(`{"data":{"records":[{"id":"r1"},{"id":"r2"}],"nextCursor":"cursor-2","totalCount":3}}`),
recordQueryTextStep(`{"data":{"records":[{"id":"r3"}],"nextCursor":"","totalCount":3}}`),
}}
out, err := runRecordQueryCLI(t, caller, "--page-limit", "0")
if err != nil {
t.Fatalf("record query CLI returned error: %v", err)
}
for _, want := range []string{`"complete": true`, `"fetchedCount": 3`, `"totalCount": 3`, `"id": "r3"`} {
if !bytes.Contains([]byte(out), []byte(want)) {
t.Fatalf("record query CLI output missing %s:\n%s", want, out)
}
}
if len(caller.calls) != 2 {
t.Fatalf("tool calls = %d, want 2", len(caller.calls))
}
if got := caller.calls[0].args["cursor"]; got != nil {
t.Fatalf("first-page cursor = %#v, want absent", got)
}
if got := caller.calls[1].args["cursor"]; got != "cursor-2" {
t.Fatalf("second-page cursor = %#v, want cursor-2", got)
}
}
func TestCrossPlatformCoverageRecordQueryCLIFailsClosedE2E(t *testing.T) {
tests := []struct {
name string
steps []recordQueryE2EStep
args []string
}{
{name: "nil result", steps: []recordQueryE2EStep{{result: nil}}},
{name: "empty content", steps: []recordQueryE2EStep{{result: &edition.ToolResult{}}}},
{name: "empty text", steps: []recordQueryE2EStep{recordQueryTextStep("")}},
{name: "invalid json", steps: []recordQueryE2EStep{recordQueryTextStep("{")}},
{name: "null payload", steps: []recordQueryE2EStep{recordQueryTextStep("null")}},
{name: "missing records", steps: []recordQueryE2EStep{recordQueryTextStep(`{"data":{"nextCursor":"c"}}`)}},
{name: "records wrong type", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":{}}`)}},
{name: "records null", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":null}`)}},
{name: "record item wrong type", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":["bad"]}`)}},
{name: "trailing json", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":[]} {}`)}},
{name: "has more without cursor", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":[],"hasMore":true}`)}},
{name: "invalid total", steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":[],"totalCount":"many"}`)}},
{name: "first transport error", steps: []recordQueryE2EStep{{err: errors.New("offline")}}},
{
name: "second page error",
steps: []recordQueryE2EStep{
recordQueryTextStep(`{"records":[{"id":"kept"}],"nextCursor":"retry-me"}`),
{err: errors.New("upstream reset")},
},
args: []string{"--page-limit", "0"},
},
{
name: "page limit",
steps: []recordQueryE2EStep{recordQueryTextStep(`{"records":[{"id":"kept"}],"nextCursor":"resume-me"}`)},
args: []string{"--page-limit", "1"},
},
{
name: "cursor cycle",
steps: []recordQueryE2EStep{
recordQueryTextStep(`{"records":[{"id":"r1"}],"nextCursor":"same"}`),
recordQueryTextStep(`{"records":[{"id":"r2"}],"nextCursor":"same"}`),
},
args: []string{"--page-limit", "0"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &recordQueryE2ECaller{steps: test.steps}
out, err := runRecordQueryCLI(t, caller, test.args...)
if err == nil {
t.Fatalf("CLI treated %s as success; output=%s", test.name, out)
}
if out != "" {
t.Fatalf("CLI emitted success output for %s: %q", test.name, out)
}
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error type = %T, want *errors.Error", err)
}
if typed.Reason == "" || typed.Details["incomplete_result"] == nil {
t.Fatalf("error lacks recovery metadata: %#v", typed)
}
})
}
}
func TestCrossPlatformCoverageRecordQueryCLIInitialCursorE2E(t *testing.T) {
caller := &recordQueryE2ECaller{steps: []recordQueryE2EStep{
recordQueryTextStep(`{"records":[]}`),
}}
if out, err := runRecordQueryCLI(t, caller, "--cursor", "resume-from-here", "--page-limit", "0"); err != nil || out == "" {
t.Fatalf("resume query = %q, %v", out, err)
}
want := map[string]any{"baseId": "base-e2e", "tableId": "table-e2e", "cursor": "resume-from-here"}
if !reflect.DeepEqual(caller.calls[0].args, want) {
t.Fatalf("resume args = %#v, want %#v", caller.calls[0].args, want)
}
}
func TestCrossPlatformCoverageRecordQueryCLIPageLimitHelpE2E(t *testing.T) {
command := newAitableCommand()
command.SilenceErrors = true
command.SilenceUsage = true
out := &bytes.Buffer{}
command.SetOut(out)
command.SetErr(out)
command.SetArgs([]string{"record", "query", "--help"})
if err := command.Execute(); err != nil {
t.Fatalf("record query help failed: %v", err)
}
for _, want := range []string{"返回非零结构化错误", "不完整结果", "错误详情保留已取记录和续传 cursor"} {
if !strings.Contains(out.String(), want) {
t.Fatalf("record query help missing %q:\n%s", want, out.String())
}
}
}
File diff suppressed because it is too large Load Diff
+144
View File
@@ -0,0 +1,144 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package chat
import (
"fmt"
"strconv"
"strings"
"github.com/spf13/cobra"
"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"
)
type LeafContract = corecmd.ContractDecl
type LeafSpec struct {
Safety contract.SafetySpec
Contract LeafContract
}
type Deps struct {
GroupRunE func(*cobra.Command, []string) error
CallMCPToolOnServer func(string, string, map[string]any) error
DeclareLeafMetadata func(*cobra.Command, LeafSpec) *cobra.Command
ValidateRequiredFlag func(*cobra.Command, ...string) error
}
var deps = Deps{
GroupRunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
CallMCPToolOnServer: func(serverID, toolName string, args map[string]any) error {
return fmt.Errorf("im helpers dependency CallMCPToolOnServer is not initialized")
},
DeclareLeafMetadata: func(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
return cmd
},
ValidateRequiredFlag: validateRequiredFlagsLocal,
}
func SetDeps(next Deps) {
if next.GroupRunE != nil {
deps.GroupRunE = next.GroupRunE
}
if next.CallMCPToolOnServer != nil {
deps.CallMCPToolOnServer = next.CallMCPToolOnServer
}
if next.DeclareLeafMetadata != nil {
deps.DeclareLeafMetadata = next.DeclareLeafMetadata
}
if next.ValidateRequiredFlag != nil {
deps.ValidateRequiredFlag = next.ValidateRequiredFlag
}
}
func NewChatToolbarCommand() *cobra.Command {
return newChatToolbarCommand()
}
func groupRunE(cmd *cobra.Command, args []string) error {
return deps.GroupRunE(cmd, args)
}
func callMCPToolOnServer(serverID, toolName string, args map[string]any) error {
return deps.CallMCPToolOnServer(serverID, toolName, args)
}
func DeclareLeafMetadata(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
return deps.DeclareLeafMetadata(cmd, spec)
}
func validateRequiredFlags(cmd *cobra.Command, names ...string) error {
return deps.ValidateRequiredFlag(cmd, names...)
}
func mustGetFlag(cmd *cobra.Command, name string) string {
val, _ := cmd.Flags().GetString(name)
if val == "" {
val, _ = cmd.InheritedFlags().GetString(name)
}
return strings.TrimSpace(val)
}
func parseCSVInt64(raw string) ([]int64, error) {
parts := strings.Split(raw, ",")
result := make([]int64, 0, len(parts))
for _, p := range parts {
v := strings.TrimSpace(p)
if v == "" {
continue
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid integer %q", v)
}
result = append(result, n)
}
if len(result) == 0 {
return nil, fmt.Errorf("at least one ID is required")
}
return result, nil
}
func validateRequiredFlagsLocal(cmd *cobra.Command, names ...string) error {
for _, name := range names {
if strings.TrimSpace(mustGetFlag(cmd, name)) == "" {
return apperrors.NewValidation(
fmt.Sprintf("flag --%s is required", name),
apperrors.WithReason("missing_required_flag"),
apperrors.WithHint(fmt.Sprintf("使用 --%s 指定必填参数", name)),
)
}
}
return nil
}
func commandBoolFlag(cmd *cobra.Command, name string) bool {
if cmd == nil {
return false
}
value, _ := cmd.Flags().GetBool(name)
if !value {
value, _ = cmd.InheritedFlags().GetBool(name)
}
return value
}
func boolPtr(v bool) *bool {
return &v
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package chat
import "github.com/spf13/cobra"
func newChatToolbarCommand() *cobra.Command {
toolbarCmd := &cobra.Command{
Use: "toolbar",
Short: "快捷栏管理",
Long: "管理会话快捷栏入口:查询、添加、隐藏、排序及自定义入口 CRUD。",
RunE: groupRunE,
}
toolbarCmd.DisableAutoGenTag = true
toolbarCmd.AddCommand(
newToolbarListCommand(),
newToolbarAddCommand(),
newToolbarHideCommand(),
newToolbarSortCommand(),
newToolbarCreateCustomCommand(),
newToolbarRemoveCustomCommand(),
newToolbarUpdateCustomCommand(),
)
return toolbarCmd
}
+92
View File
@@ -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 chat
import (
"fmt"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func newToolbarAddCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "add",
Short: "将入口添加到快捷栏可见区",
Long: "将一个或多个快捷入口从隐藏区移至可见区。入口 ID 使用逗号分隔。",
Example: ` dws chat toolbar add --conversation-id <cid> --shortcut-ids 101,102
# 查询入口 ID: dws chat toolbar list --conversation-id <cid>`,
RunE: func(cmd *cobra.Command, args []string) error {
cid, err := toolbarConversationID(cmd)
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "shortcut-ids"); err != nil {
return err
}
ids, err := parseCSVInt64(mustGetFlag(cmd, "shortcut-ids"))
if err != nil {
return fmt.Errorf("--shortcut-ids: %w", err)
}
err = callMCPToolOnServer("im", "add_shortcut_to_bar", map[string]any{
"openConversationId": cid,
"shortcutIds": ids,
})
if isSystemBusy(err) {
return toolbarNewSystemBusyError()
}
return err
},
}
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
cmd.Flags().String("shortcut-ids", "", "入口 ID 列表(逗号分隔)")
_ = cmd.MarkFlagRequired("conversation-id")
_ = cmd.MarkFlagRequired("shortcut-ids")
cmd.DisableAutoGenTag = true
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat",
Name: "add_shortcut_to_bar",
CanonicalPath: "chat.add_shortcut_to_bar",
CLIPath: "chat toolbar add",
PrimaryCLIPath: "chat toolbar add",
},
Description: "将快捷入口添加到会话快捷栏可见区",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "add_shortcut_to_bar"},
},
Selection: contract.SelectionSpec{
AgentSummary: "将快捷入口添加到会话快捷栏可见区",
UseWhen: []string{"需要将隐藏的快捷入口重新显示在快捷栏中"},
AvoidWhen: []string{"创建新的自定义入口应使用 chat toolbar create-custom"},
Examples: []string{"dws chat toolbar add --conversation-id <cid> --shortcut-ids 101,102"},
},
Parameters: []contract.ParamDecl{
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
{Name: "shortcut-ids", Property: "shortcutIds", Required: boolPtr(true)},
},
},
})
return cmd
}
@@ -0,0 +1,143 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package chat
import (
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func newToolbarCreateCustomCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "create-custom",
Short: "创建自定义快捷栏入口",
Long: `创建一个新的自定义快捷栏入口。
必填参数:
--conversation-id 会话 openConversationId
--title 入口标题
--url 入口跳转链接
--icon-url 入口图标 URL
--pc-url PC 端跳转链接(可与 --url 相同)
可选参数:
--extension 扩展信息,格式 key=value,可重复使用
--desc 入口描述(为空时使用 --title)
--tag 入口标签
--sort-index 排序权重`,
Example: ` dws chat toolbar create-custom --conversation-id <cid> --title "周报" --url "https://example.com" --icon-url "https://example.com/icon.png" --pc-url "https://example.com"
dws chat toolbar create-custom --conversation-id <cid> --title "工具" --url "https://example.com" --icon-url "https://example.com/icon.png" --pc-url "https://example.com" --extension color=blue --desc "常用工具"`,
RunE: func(cmd *cobra.Command, args []string) error {
cid, err := toolbarConversationID(cmd)
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "title", "url", "icon-url", "pc-url"); err != nil {
return err
}
toolArgs := map[string]any{
"openConversationId": cid,
"name": toolbarI18nJSONString(mustGetFlag(cmd, "title")),
"url": mustGetFlag(cmd, "url"),
"icons": toolbarIconsJSONString(mustGetFlag(cmd, "icon-url")),
"pcUrl": mustGetFlag(cmd, "pc-url"),
}
descValue := mustGetFlag(cmd, "desc")
if descValue == "" {
descValue = mustGetFlag(cmd, "title")
}
toolArgs["desc"] = toolbarI18nJSONString(descValue)
if tag, _ := cmd.Flags().GetString("tag"); tag != "" {
toolArgs["tag"] = tag
}
if cmd.Flags().Changed("sort-index") {
sortIndex, _ := cmd.Flags().GetInt("sort-index")
toolArgs["sortIndex"] = sortIndex
}
ext, err := parseExtension(cmd)
if err != nil {
return err
}
if ext != nil {
toolArgs["extension"] = ext
}
err = callMCPToolOnServer("im", "create_custom_shortcut", toolArgs)
if isSystemBusy(err) {
return toolbarNewSystemBusyError()
}
return err
},
}
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
cmd.Flags().String("title", "", "入口标题")
cmd.Flags().String("url", "", "入口跳转链接")
cmd.Flags().String("icon-url", "", "入口图标 URL")
cmd.Flags().String("pc-url", "", "PC 端跳转链接")
cmd.Flags().StringArray("extension", nil, "扩展信息,格式 key=value,可重复使用")
cmd.Flags().String("desc", "", "入口描述(为空时使用 --title)")
cmd.Flags().String("tag", "", "入口标签")
cmd.Flags().Int("sort-index", 0, "排序权重")
_ = cmd.MarkFlagRequired("conversation-id")
_ = cmd.MarkFlagRequired("title")
_ = cmd.MarkFlagRequired("url")
_ = cmd.MarkFlagRequired("icon-url")
_ = cmd.MarkFlagRequired("pc-url")
cmd.DisableAutoGenTag = true
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat",
Name: "create_custom_shortcut",
CanonicalPath: "chat.create_custom_shortcut",
CLIPath: "chat toolbar create-custom",
PrimaryCLIPath: "chat toolbar create-custom",
},
Description: "创建自定义快捷栏入口",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "create_custom_shortcut"},
},
Selection: contract.SelectionSpec{
AgentSummary: "创建自定义快捷栏入口",
UseWhen: []string{"需要在快捷栏中新增一个自定义入口(含标题、链接、图标等)"},
AvoidWhen: []string{"移动已有入口使用 chat toolbar add / hide;更新已有自定义入口使用 chat toolbar update-custom"},
Examples: []string{"dws chat toolbar create-custom --conversation-id <cid> --title \"周报\" --url \"https://example.com\" --icon-url \"https://example.com/icon.png\" --pc-url \"https://example.com\""},
},
Parameters: []contract.ParamDecl{
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
{Name: "title", Property: "name", Required: boolPtr(true)},
{Name: "url", Property: "url", Required: boolPtr(true)},
{Name: "icon-url", Property: "icons", Required: boolPtr(true)},
{Name: "pc-url", Property: "pcUrl", Required: boolPtr(true)},
{Name: "extension", Property: "extension", Required: boolPtr(false)},
{Name: "desc", Property: "desc", Required: boolPtr(false)},
{Name: "tag", Property: "tag", Required: boolPtr(false)},
{Name: "sort-index", Property: "sortIndex", Required: boolPtr(false)},
},
},
})
return cmd
}
@@ -0,0 +1,26 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package chat
import "encoding/json"
func toolbarI18nJSONString(value string) string {
payload, _ := json.Marshal(map[string]string{"zh_CN": value})
return string(payload)
}
func toolbarIconsJSONString(iconMobile string) string {
payload, _ := json.Marshal(map[string]string{"iconMobile": iconMobile})
return string(payload)
}
+93
View File
@@ -0,0 +1,93 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package chat
import (
"fmt"
"strings"
"github.com/spf13/cobra"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
func hasIntersection(a, b []int64) bool {
if len(a) == 0 || len(b) == 0 {
return false
}
seen := make(map[int64]struct{}, len(a))
for _, v := range a {
seen[v] = struct{}{}
}
for _, v := range b {
if _, ok := seen[v]; ok {
return true
}
}
return false
}
func isSystemBusy(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "SYSTEM_BUSY")
}
func parseExtension(cmd *cobra.Command) (map[string]string, error) {
raw, _ := cmd.Flags().GetStringArray("extension")
if len(raw) == 0 {
return nil, nil
}
result := make(map[string]string, len(raw))
for _, pair := range raw {
idx := strings.Index(pair, "=")
if idx <= 0 {
return nil, fmt.Errorf("--extension 格式错误,期望 key=value,实际 %q", pair)
}
key := strings.TrimSpace(pair[:idx])
value := strings.TrimSpace(pair[idx+1:])
if key == "" {
return nil, fmt.Errorf("--extension key 不能为空: %q", pair)
}
if _, exists := result[key]; exists {
return nil, fmt.Errorf("--extension 重复 key %q,每个 key 只能指定一次", key)
}
result[key] = value
}
return result, nil
}
func toolbarConversationID(cmd *cobra.Command) (string, error) {
cid, _ := cmd.Flags().GetString("conversation-id")
cid = strings.TrimSpace(cid)
if cid == "" {
return "", apperrors.NewValidation(
"flag --conversation-id is required",
apperrors.WithReason("missing_required_flag"),
apperrors.WithHint("使用 --conversation-id 指定会话 openConversationId"),
apperrors.WithActions("通过 dws chat search 或 dws chat group list 获取会话 ID"),
)
}
return cid, nil
}
func toolbarNewSystemBusyError() error {
return apperrors.NewValidation(
"服务端繁忙(SYSTEM_BUSY),请稍后重试",
apperrors.WithReason("system_busy"),
apperrors.WithHint("服务端当前负载较高,建议等待几秒后重试"),
apperrors.WithActions("等待后使用相同参数重试"),
)
}
@@ -0,0 +1,733 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package chat
import (
"context"
"errors"
"io"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type toolbarCall struct {
product string
tool string
args map[string]any
}
type toolbarTestCaller struct {
product string
tool string
args map[string]any
err error
calls []toolbarCall
}
func (c *toolbarTestCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, toolbarCall{product: productID, tool: toolName, args: args})
c.product = productID
c.tool = toolName
c.args = args
if c.err != nil {
return nil, c.err
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: "{}"}}}, nil
}
func (c *toolbarTestCaller) Format() string { return "json" }
func (c *toolbarTestCaller) DryRun() bool { return false }
func (c *toolbarTestCaller) Fields() string { return "" }
func (c *toolbarTestCaller) JQ() string { return "" }
func executeToolbarCommand(t *testing.T, cmd *cobra.Command, caller *toolbarTestCaller, args ...string) error {
t.Helper()
initToolbarDepsForTest(t, caller)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
return cmd.Execute()
}
func initToolbarDepsForTest(t *testing.T, caller *toolbarTestCaller) {
t.Helper()
previous := deps
SetDeps(Deps{
GroupRunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
CallMCPToolOnServer: func(productID, toolName string, args map[string]any) error {
_, err := caller.CallTool(context.Background(), productID, toolName, args)
return err
},
DeclareLeafMetadata: func(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
return cmd
},
ValidateRequiredFlag: validateRequiredFlagsLocal,
})
t.Cleanup(func() {
deps = previous
})
}
func requireTypedConfirmationError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected confirmation error, got nil")
}
if !strings.Contains(err.Error(), "获得用户确认后加 --yes 执行") {
t.Fatalf("expected confirmation error, got %v", err)
}
}
func requireToolbarCall(
t *testing.T,
caller *toolbarTestCaller,
wantProduct, wantTool string,
wantArgs map[string]any,
) {
t.Helper()
if caller.product != wantProduct || caller.tool != wantTool {
t.Fatalf("tool call = %s/%s, want %s/%s", caller.product, caller.tool, wantProduct, wantTool)
}
if !reflect.DeepEqual(caller.args, wantArgs) {
t.Fatalf("tool args = %#v, want %#v", caller.args, wantArgs)
}
}
func toolbarCustomArgs(extra ...string) []string {
args := []string{"--conversation-id", "cid", "--title", "Title", "--url", "https://example.com/mobile", "--icon-url", "https://example.com/icon.png", "--pc-url", "https://example.com/pc"}
return append(args, extra...)
}
func TestHasIntersection(t *testing.T) {
tests := []struct {
name string
a, b []int64
want bool
}{
{"both empty", nil, nil, false},
{"a empty", nil, []int64{1}, false},
{"b empty", []int64{1}, nil, false},
{"no overlap", []int64{1, 2, 3}, []int64{4, 5, 6}, false},
{"has overlap", []int64{1, 2, 3}, []int64{3, 4, 5}, true},
{"single match", []int64{1}, []int64{1}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := hasIntersection(tt.a, tt.b)
if got != tt.want {
t.Errorf("hasIntersection(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want)
}
})
}
}
func TestIsSystemBusy(t *testing.T) {
if isSystemBusy(nil) {
t.Error("isSystemBusy(nil) should be false")
}
if !isSystemBusy(errors.New("error code: SYSTEM_BUSY")) {
t.Error("isSystemBusy should detect SYSTEM_BUSY in error message")
}
if isSystemBusy(errors.New("some other error")) {
t.Error("isSystemBusy should return false for non-SYSTEM_BUSY errors")
}
}
func TestParseExtension(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().StringArray("extension", nil, "")
ext, err := parseExtension(cmd)
if err != nil {
t.Fatalf("empty extension should not error: %v", err)
}
if ext != nil {
t.Fatalf("empty extension should return nil, got %v", ext)
}
_ = cmd.Flags().Set("extension", "key1=val1")
_ = cmd.Flags().Set("extension", "key2=val2")
ext, err = parseExtension(cmd)
if err != nil {
t.Fatalf("valid extension should not error: %v", err)
}
if ext["key1"] != "val1" || ext["key2"] != "val2" {
t.Fatalf("unexpected extension map: %v", ext)
}
cmd2 := &cobra.Command{Use: "test2"}
cmd2.Flags().StringArray("extension", nil, "")
_ = cmd2.Flags().Set("extension", "badformat")
_, err = parseExtension(cmd2)
if err == nil {
t.Fatal("invalid extension format should error")
}
cmd3 := &cobra.Command{Use: "test3"}
cmd3.Flags().StringArray("extension", nil, "")
_ = cmd3.Flags().Set("extension", "color=blue")
_ = cmd3.Flags().Set("extension", "color=red")
_, err = parseExtension(cmd3)
if err == nil {
t.Fatal("duplicate extension keys should error")
}
cmd4 := &cobra.Command{Use: "test4"}
cmd4.Flags().StringArray("extension", nil, "")
_ = cmd4.Flags().Set("extension", " =value")
_, err = parseExtension(cmd4)
if err == nil {
t.Fatal("blank extension key should error")
}
}
func TestToolbarConversationID(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("conversation-id", "", "")
_, err := toolbarConversationID(cmd)
if err == nil {
t.Fatal("missing conversation-id should error")
}
_ = cmd.Flags().Set("conversation-id", "cid123")
cid, err := toolbarConversationID(cmd)
if err != nil {
t.Fatalf("valid conversation-id should not error: %v", err)
}
if cid != "cid123" {
t.Fatalf("expected cid123, got %s", cid)
}
}
func TestToolbarDepsDefaultWrappersAndValidationEdges(t *testing.T) {
cmd := &cobra.Command{Use: "toolbar"}
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
if err := groupRunE(cmd, nil); err != nil {
t.Fatalf("default groupRunE returned error: %v", err)
}
if err := callMCPToolOnServer("im", "tool", nil); err == nil ||
!strings.Contains(err.Error(), "CallMCPToolOnServer is not initialized") {
t.Fatalf("default callMCPToolOnServer error = %v", err)
}
if got := DeclareLeafMetadata(cmd, LeafSpec{}); got != cmd {
t.Fatal("default DeclareLeafMetadata should return the original command")
}
toolbar := NewChatToolbarCommand()
if toolbar.Name() != "toolbar" || len(toolbar.Commands()) != 7 {
t.Fatalf("toolbar command = %q with %d children, want toolbar with 7 children", toolbar.Name(), len(toolbar.Commands()))
}
parent := &cobra.Command{Use: "parent"}
parent.PersistentFlags().String("conversation-id", "", "")
child := &cobra.Command{Use: "child"}
parent.AddCommand(child)
_ = parent.PersistentFlags().Set("conversation-id", " inherited ")
if got := mustGetFlag(child, "conversation-id"); got != "inherited" {
t.Fatalf("mustGetFlag inherited value = %q, want inherited", got)
}
if _, err := parseCSVInt64(" , "); err == nil || !strings.Contains(err.Error(), "at least one ID") {
t.Fatalf("empty csv error = %v", err)
}
if _, err := parseCSVInt64("1,bad"); err == nil || !strings.Contains(err.Error(), `invalid integer "bad"`) {
t.Fatalf("invalid csv error = %v", err)
}
required := &cobra.Command{Use: "required"}
required.Flags().String("title", "", "")
if err := validateRequiredFlagsLocal(required, "title"); err == nil ||
!strings.Contains(err.Error(), "flag --title is required") {
t.Fatalf("validateRequiredFlagsLocal error = %v", err)
}
if commandBoolFlag(nil, "yes") {
t.Fatal("commandBoolFlag(nil) should be false")
}
boolParent := &cobra.Command{Use: "parent"}
boolParent.PersistentFlags().Bool("yes", false, "")
boolChild := &cobra.Command{Use: "child"}
boolParent.AddCommand(boolChild)
_ = boolParent.PersistentFlags().Set("yes", "true")
if !commandBoolFlag(boolChild, "yes") {
t.Fatal("commandBoolFlag should read inherited true value")
}
}
func TestToolbarCommandsValidationEdges(t *testing.T) {
tests := []struct {
name string
cmd func() *cobra.Command
args []string
wantError string
}{
{
name: "add blank conversation id",
cmd: newToolbarAddCommand,
args: []string{"--conversation-id", " ", "--shortcut-ids", "1"},
wantError: "flag --conversation-id is required",
},
{
name: "add blank shortcut ids",
cmd: newToolbarAddCommand,
args: []string{"--conversation-id", "cid", "--shortcut-ids", " "},
wantError: "flag --shortcut-ids is required",
},
{
name: "add invalid shortcut ids",
cmd: newToolbarAddCommand,
args: []string{"--conversation-id", "cid", "--shortcut-ids", "1,bad"},
wantError: "--shortcut-ids",
},
{
name: "hide blank conversation id",
cmd: newToolbarHideCommand,
args: []string{"--conversation-id", " ", "--shortcut-ids", "1"},
wantError: "flag --conversation-id is required",
},
{
name: "hide blank shortcut ids",
cmd: newToolbarHideCommand,
args: []string{"--conversation-id", "cid", "--shortcut-ids", " "},
wantError: "flag --shortcut-ids is required",
},
{
name: "hide invalid shortcut ids",
cmd: newToolbarHideCommand,
args: []string{"--conversation-id", "cid", "--shortcut-ids", "1,bad"},
wantError: "--shortcut-ids",
},
{
name: "list blank conversation id",
cmd: newToolbarListCommand,
args: []string{"--conversation-id", " "},
wantError: "flag --conversation-id is required",
},
{
name: "sort blank conversation id",
cmd: newToolbarSortCommand,
args: []string{"--conversation-id", " ", "--sorted-ids", "1"},
wantError: "flag --conversation-id is required",
},
{
name: "sort blank sorted ids",
cmd: newToolbarSortCommand,
args: []string{"--conversation-id", "cid", "--sorted-ids", " "},
wantError: "flag --sorted-ids is required",
},
{
name: "sort invalid sorted ids",
cmd: newToolbarSortCommand,
args: []string{"--conversation-id", "cid", "--sorted-ids", "1,bad"},
wantError: "--sorted-ids",
},
{
name: "sort invalid unsorted ids",
cmd: newToolbarSortCommand,
args: []string{"--conversation-id", "cid", "--sorted-ids", "1", "--unsorted-ids", "bad"},
wantError: "--unsorted-ids",
},
{
name: "create blank conversation id",
cmd: newToolbarCreateCustomCommand,
args: toolbarCustomArgs("--conversation-id", " "),
wantError: "flag --conversation-id is required",
},
{
name: "create blank title",
cmd: newToolbarCreateCustomCommand,
args: toolbarCustomArgs("--title", " "),
wantError: "flag --title is required",
},
{
name: "create invalid extension",
cmd: newToolbarCreateCustomCommand,
args: toolbarCustomArgs("--extension", "bad"),
wantError: "--extension",
},
{
name: "remove blank conversation id",
cmd: newToolbarRemoveCustomCommand,
args: []string{"--conversation-id", " ", "--shortcut-id", "42", "--yes"},
wantError: "flag --conversation-id is required",
},
{
name: "update blank conversation id",
cmd: newToolbarUpdateCustomCommand,
args: toolbarCustomArgs("--shortcut-id", "42", "--conversation-id", " "),
wantError: "flag --conversation-id is required",
},
{
name: "update blank title",
cmd: newToolbarUpdateCustomCommand,
args: toolbarCustomArgs("--shortcut-id", "42", "--title", " "),
wantError: "flag --title is required",
},
{
name: "update invalid extension",
cmd: newToolbarUpdateCustomCommand,
args: toolbarCustomArgs("--shortcut-id", "42", "--extension", "bad"),
wantError: "--extension",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := executeToolbarCommand(t, tt.cmd(), &toolbarTestCaller{}, tt.args...)
if err == nil || !strings.Contains(err.Error(), tt.wantError) {
t.Fatalf("error = %v, want containing %q", err, tt.wantError)
}
})
}
}
func TestToolbarCommandsConvertSystemBusy(t *testing.T) {
tests := []struct {
name string
cmd func() *cobra.Command
args []string
}{
{
name: "add",
cmd: newToolbarAddCommand,
args: []string{"--conversation-id", "cid", "--shortcut-ids", "1,2"},
},
{
name: "hide",
cmd: newToolbarHideCommand,
args: []string{"--conversation-id", "cid", "--shortcut-ids", "1,2"},
},
{
name: "sort",
cmd: newToolbarSortCommand,
args: []string{"--conversation-id", "cid", "--sorted-ids", "1,2"},
},
{
name: "remove-custom",
cmd: newToolbarRemoveCustomCommand,
args: []string{"--conversation-id", "cid", "--shortcut-id", "1", "--yes"},
},
{
name: "create-custom",
cmd: newToolbarCreateCustomCommand,
args: toolbarCustomArgs(),
},
{
name: "update-custom",
cmd: newToolbarUpdateCustomCommand,
args: toolbarCustomArgs("--shortcut-id", "99"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := executeToolbarCommand(t, tt.cmd(), &toolbarTestCaller{
err: errors.New("remote SYSTEM_BUSY"),
}, tt.args...)
if err == nil || !strings.Contains(err.Error(), "SYSTEM_BUSY") {
t.Fatalf("expected system_busy validation error, got %v", err)
}
})
}
}
func TestToolbarCreateAndUpdateCustomIncludeOptionalFields(t *testing.T) {
createCaller := &toolbarTestCaller{}
err := executeToolbarCommand(t, newToolbarCreateCustomCommand(), createCaller, toolbarCustomArgs(
"--desc", "Description",
"--tag", "Tag",
"--sort-index", "7",
"--extension", "color=blue",
)...)
if err != nil {
t.Fatalf("create-custom returned error: %v", err)
}
requireToolbarCall(t, createCaller, "im", "create_custom_shortcut", map[string]any{
"openConversationId": "cid",
"name": `{"zh_CN":"Title"}`,
"url": "https://example.com/mobile",
"icons": `{"iconMobile":"https://example.com/icon.png"}`,
"pcUrl": "https://example.com/pc",
"desc": `{"zh_CN":"Description"}`,
"tag": "Tag",
"sortIndex": 7,
"extension": map[string]string{"color": "blue"},
})
updateCaller := &toolbarTestCaller{}
err = executeToolbarCommand(t, newToolbarUpdateCustomCommand(), updateCaller, toolbarCustomArgs(
"--shortcut-id", "99",
"--desc", "Description",
"--tag", "Tag",
"--sort-index", "7",
"--extension", "color=blue",
)...)
if err != nil {
t.Fatalf("update-custom returned error: %v", err)
}
requireToolbarCall(t, updateCaller, "im", "update_custom_shortcut", map[string]any{
"openConversationId": "cid",
"shortcutId": int64(99),
"name": `{"zh_CN":"Title"}`,
"url": "https://example.com/mobile",
"icons": `{"iconMobile":"https://example.com/icon.png"}`,
"pcUrl": "https://example.com/pc",
"desc": `{"zh_CN":"Description"}`,
"tag": "Tag",
"sortIndex": 7,
"extension": map[string]string{"color": "blue"},
})
}
func TestToolbarCreateCustomDefaultsDescToTitle(t *testing.T) {
tests := []struct {
name string
args []string
}{
{name: "missing desc", args: toolbarCustomArgs()},
{name: "blank desc", args: toolbarCustomArgs("--desc", " ")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &toolbarTestCaller{}
if err := executeToolbarCommand(t, newToolbarCreateCustomCommand(), caller, tt.args...); err != nil {
t.Fatalf("create-custom returned error: %v", err)
}
requireToolbarCall(t, caller, "im", "create_custom_shortcut", map[string]any{
"openConversationId": "cid",
"name": `{"zh_CN":"Title"}`,
"url": "https://example.com/mobile",
"icons": `{"iconMobile":"https://example.com/icon.png"}`,
"pcUrl": "https://example.com/pc",
"desc": `{"zh_CN":"Title"}`,
})
})
}
}
func TestToolbarUpdateCustomDefaultsDescToTitle(t *testing.T) {
tests := []struct {
name string
args []string
}{
{name: "missing desc", args: toolbarCustomArgs("--shortcut-id", "99")},
{name: "blank desc", args: toolbarCustomArgs("--shortcut-id", "99", "--desc", " ")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &toolbarTestCaller{}
if err := executeToolbarCommand(t, newToolbarUpdateCustomCommand(), caller, tt.args...); err != nil {
t.Fatalf("update-custom returned error: %v", err)
}
requireToolbarCall(t, caller, "im", "update_custom_shortcut", map[string]any{
"openConversationId": "cid",
"shortcutId": int64(99),
"name": `{"zh_CN":"Title"}`,
"url": "https://example.com/mobile",
"icons": `{"iconMobile":"https://example.com/icon.png"}`,
"pcUrl": "https://example.com/pc",
"desc": `{"zh_CN":"Title"}`,
})
})
}
}
func TestToolbarSortValidatesOptionalUnsortedIDs(t *testing.T) {
intersectionErr := executeToolbarCommand(t, newToolbarSortCommand(), &toolbarTestCaller{},
"--conversation-id", "cid",
"--sorted-ids", "1,2",
"--unsorted-ids", "2,3",
)
if intersectionErr == nil || !strings.Contains(intersectionErr.Error(), "不能有交集") {
t.Fatalf("expected id_intersection error, got %v", intersectionErr)
}
caller := &toolbarTestCaller{}
err := executeToolbarCommand(t, newToolbarSortCommand(), caller,
"--conversation-id", "cid",
"--sorted-ids", "1,2",
"--unsorted-ids", "3,4",
)
if err != nil {
t.Fatalf("sort returned error: %v", err)
}
requireToolbarCall(t, caller, "im", "sort_shortcut_bar", map[string]any{
"openConversationId": "cid",
"sortedShortcutIds": []int64{1, 2},
"unsortedShortcutIds": []int64{3, 4},
})
}
func TestCrossPlatformCoverageToolbarAddHideAndListCallMCPWithExactArgs(t *testing.T) {
tests := []struct {
name string
cmd func() *cobra.Command
args []string
wantTool string
wantArgs map[string]any
}{
{
name: "add",
cmd: newToolbarAddCommand,
args: []string{"--conversation-id", "cidX", "--shortcut-ids", "101,102"},
wantTool: "add_shortcut_to_bar",
wantArgs: map[string]any{
"openConversationId": "cidX",
"shortcutIds": []int64{101, 102},
},
},
{
name: "hide",
cmd: newToolbarHideCommand,
args: []string{"--conversation-id", "cidX", "--shortcut-ids", "201,202"},
wantTool: "hide_shortcut_from_bar",
wantArgs: map[string]any{
"openConversationId": "cidX",
"shortcutIds": []int64{201, 202},
},
},
{
name: "list",
cmd: newToolbarListCommand,
args: []string{"--conversation-id", "cidX"},
wantTool: "list_chat_toolbar_shortcuts",
wantArgs: map[string]any{
"openConversationId": "cidX",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caller := &toolbarTestCaller{}
if err := executeToolbarCommand(t, tt.cmd(), caller, tt.args...); err != nil {
t.Fatalf("%s returned error: %v", tt.name, err)
}
requireToolbarCall(t, caller, "im", tt.wantTool, tt.wantArgs)
})
}
}
func TestCrossPlatformCoverageToolbarRemoveCustomRejectsWithoutYes(t *testing.T) {
caller := &toolbarTestCaller{}
err := executeToolbarCommand(t, newToolbarRemoveCustomCommand(), caller,
"--conversation-id", "cidX", "--shortcut-id", "42")
requireTypedConfirmationError(t, err)
if len(caller.calls) != 0 {
t.Fatalf("expected 0 MCP calls before --yes, got %d: %+v", len(caller.calls), caller.calls)
}
if caller.product != "" || caller.tool != "" || caller.args != nil {
t.Fatalf("expected no MCP call recorded, got product=%q tool=%q args=%#v", caller.product, caller.tool, caller.args)
}
}
func TestCrossPlatformCoverageToolbarRemoveCustomCallsMCPWithExactArgsWhenYes(t *testing.T) {
caller := &toolbarTestCaller{}
err := executeToolbarCommand(t, newToolbarRemoveCustomCommand(), caller,
"--conversation-id", "cidX", "--shortcut-id", "42", "--yes")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected exactly 1 MCP call, got %d: %+v", len(caller.calls), caller.calls)
}
call := caller.calls[0]
if call.product != "im" {
t.Fatalf("product = %q, want im", call.product)
}
if call.tool != "remove_custom_shortcut" {
t.Fatalf("tool = %q, want remove_custom_shortcut", call.tool)
}
wantArgs := map[string]any{"openConversationId": "cidX", "shortcutId": int64(42)}
if !reflect.DeepEqual(call.args, wantArgs) {
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
}
}
type removeCustomSeamCall struct {
openConversationId string
shortcutId int64
}
type removeCustomSeamStub struct {
calls []removeCustomSeamCall
err error
}
// invoke records the seam call and forwards to deps.Caller.CallTool so the
// command path still records the same remote invocation shape as production.
func (s *removeCustomSeamStub) invoke(openConversationId string, shortcutId int64) error {
s.calls = append(s.calls, removeCustomSeamCall{openConversationId: openConversationId, shortcutId: shortcutId})
if err := callMCPToolOnServer("im", "remove_custom_shortcut", map[string]any{
"openConversationId": openConversationId,
"shortcutId": shortcutId,
}); err != nil {
return err
}
return s.err
}
func TestCrossPlatformCoverageToolbarRemoveCustomSeamRejectsWithoutYes(t *testing.T) {
stub := &removeCustomSeamStub{}
testseam.Swap(t, &removeChatToolbarCustomShortcutFn, stub.invoke)
err := executeToolbarCommand(t, newToolbarRemoveCustomCommand(), &toolbarTestCaller{},
"--conversation-id", "cidX", "--shortcut-id", "42")
requireTypedConfirmationError(t, err)
if len(stub.calls) != 0 {
t.Fatalf("expected 0 seam calls before --yes, got %d: %+v", len(stub.calls), stub.calls)
}
}
func TestCrossPlatformCoverageToolbarRemoveCustomSeamCallsWithExactArgsWhenYes(t *testing.T) {
stub := &removeCustomSeamStub{}
testseam.Swap(t, &removeChatToolbarCustomShortcutFn, stub.invoke)
err := executeToolbarCommand(t, newToolbarRemoveCustomCommand(), &toolbarTestCaller{},
"--conversation-id", "cidX", "--shortcut-id", "42", "--yes")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(stub.calls) != 1 {
t.Fatalf("expected exactly 1 seam call, got %d: %+v", len(stub.calls), stub.calls)
}
call := stub.calls[0]
if call.openConversationId != "cidX" {
t.Fatalf("seam openConversationId = %q, want cidX", call.openConversationId)
}
if call.shortcutId != int64(42) {
t.Fatalf("seam shortcutId = %d, want 42", call.shortcutId)
}
}
func TestToolbarRemoveCustomDeclaresDestructiveHighRiskConfirmation(t *testing.T) {
var got LeafSpec
next := deps
next.DeclareLeafMetadata = func(cmd *cobra.Command, spec LeafSpec) *cobra.Command {
got = spec
return cmd
}
testseam.Swap(t, &deps, next)
_ = newToolbarRemoveCustomCommand()
if got.Safety.Effect != "destructive" || got.Safety.Risk != "high" ||
got.Safety.Confirmation != "user_required" {
t.Fatalf("remove-custom safety = %#v, want destructive/high/user_required", got.Safety)
}
}
+92
View File
@@ -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 chat
import (
"fmt"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func newToolbarHideCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "hide",
Short: "将入口从快捷栏可见区隐藏",
Long: "将一个或多个快捷入口从可见区移至隐藏区。入口 ID 使用逗号分隔。",
Example: ` dws chat toolbar hide --conversation-id <cid> --shortcut-ids 101,102
# 查询入口 ID: dws chat toolbar list --conversation-id <cid>`,
RunE: func(cmd *cobra.Command, args []string) error {
cid, err := toolbarConversationID(cmd)
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "shortcut-ids"); err != nil {
return err
}
ids, err := parseCSVInt64(mustGetFlag(cmd, "shortcut-ids"))
if err != nil {
return fmt.Errorf("--shortcut-ids: %w", err)
}
err = callMCPToolOnServer("im", "hide_shortcut_from_bar", map[string]any{
"openConversationId": cid,
"shortcutIds": ids,
})
if isSystemBusy(err) {
return toolbarNewSystemBusyError()
}
return err
},
}
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
cmd.Flags().String("shortcut-ids", "", "入口 ID 列表(逗号分隔)")
_ = cmd.MarkFlagRequired("conversation-id")
_ = cmd.MarkFlagRequired("shortcut-ids")
cmd.DisableAutoGenTag = true
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat",
Name: "hide_shortcut_from_bar",
CanonicalPath: "chat.hide_shortcut_from_bar",
CLIPath: "chat toolbar hide",
PrimaryCLIPath: "chat toolbar hide",
},
Description: "将快捷入口从会话快捷栏可见区移至隐藏区",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "hide_shortcut_from_bar"},
},
Selection: contract.SelectionSpec{
AgentSummary: "将快捷入口从会话快捷栏可见区隐藏",
UseWhen: []string{"需要将快捷栏中的入口隐藏到隐藏区"},
AvoidWhen: []string{"永久删除自定义入口应使用 chat toolbar remove-custom"},
Examples: []string{"dws chat toolbar hide --conversation-id <cid> --shortcut-ids 101,102"},
},
Parameters: []contract.ParamDecl{
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
{Name: "shortcut-ids", Property: "shortcutIds", Required: boolPtr(true)},
},
},
})
return cmd
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package chat
import (
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func newToolbarListCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "查询会话快捷栏入口列表",
Long: "查询指定会话的快捷栏入口列表,包括可见区和隐藏区的所有入口。",
Example: ` dws chat toolbar list --conversation-id <cid>
# 查询群 ID: dws chat search --query "群名"`,
RunE: func(cmd *cobra.Command, args []string) error {
cid, err := toolbarConversationID(cmd)
if err != nil {
return err
}
return callMCPToolOnServer("im", "list_chat_toolbar_shortcuts", map[string]any{
"openConversationId": cid,
})
},
}
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
_ = cmd.MarkFlagRequired("conversation-id")
cmd.DisableAutoGenTag = true
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat",
Name: "list_chat_toolbar_shortcuts",
CanonicalPath: "chat.list_chat_toolbar_shortcuts",
CLIPath: "chat toolbar list",
PrimaryCLIPath: "chat toolbar list",
},
Description: "查询会话快捷栏入口列表",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "list_chat_toolbar_shortcuts"},
},
Selection: contract.SelectionSpec{
AgentSummary: "查询会话快捷栏入口列表",
UseWhen: []string{"需要查看会话快捷栏中有哪些入口、获取入口 ID 时"},
AvoidWhen: []string{"修改快捷栏排序使用 chat toolbar sort;创建自定义入口使用 chat toolbar create-custom"},
Examples: []string{"dws chat toolbar list --conversation-id <cid>"},
},
Parameters: []contract.ParamDecl{
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
},
},
})
return cmd
}
@@ -0,0 +1,103 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package chat
import (
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
// removeChatToolbarCustomShortcutFn is the package-level injection seam for
// the remove-custom command's remote call. The default implementation routes
// through callMCPToolOnServer against the im server, so production behavior is
// unchanged. Tests swap it with testseam.Swap(t, &removeChatToolbarCustomShortcutFn, stub)
// to capture the call without hitting a real remote. Like every package-var
// seam in this repo it is not safe for t.Parallel tests.
var removeChatToolbarCustomShortcutFn = func(openConversationId string, shortcutId int64) error {
return callMCPToolOnServer("im", "remove_custom_shortcut", map[string]any{
"openConversationId": openConversationId,
"shortcutId": shortcutId,
})
}
func newToolbarRemoveCustomCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "remove-custom",
Short: "删除自定义快捷栏入口",
Long: "删除一个自定义快捷栏入口。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。",
Example: ` dws chat toolbar remove-custom --conversation-id <cid> --shortcut-id 123`,
RunE: func(cmd *cobra.Command, args []string) error {
cid, err := toolbarConversationID(cmd)
if err != nil {
return err
}
shortcutId, _ := cmd.Flags().GetInt64("shortcut-id")
if !commandBoolFlag(cmd, "yes") {
return apperrors.NewValidation(
"删除自定义快捷入口不可逆;获得用户确认后加 --yes 执行",
apperrors.WithReason("confirmation_required"),
apperrors.WithHint("先确认目标入口及影响范围;用户明确同意后以相同参数追加 --yes"),
apperrors.WithActions("确认目标自定义入口", "获得用户确认后使用 --yes 执行"),
)
}
err = removeChatToolbarCustomShortcutFn(cid, shortcutId)
if isSystemBusy(err) {
return toolbarNewSystemBusyError()
}
return err
},
}
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
cmd.Flags().Int64("shortcut-id", 0, "自定义入口 ID")
cmd.Flags().Bool("yes", false, "确认执行删除操作")
_ = cmd.MarkFlagRequired("conversation-id")
_ = cmd.MarkFlagRequired("shortcut-id")
cmd.DisableAutoGenTag = true
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "destructive", Risk: "high",
Confirmation: "user_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat",
Name: "remove_custom_shortcut",
CanonicalPath: "chat.remove_custom_shortcut",
CLIPath: "chat toolbar remove-custom",
PrimaryCLIPath: "chat toolbar remove-custom",
},
Description: "删除自定义快捷栏入口",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "remove_custom_shortcut"},
},
Selection: contract.SelectionSpec{
AgentSummary: "删除自定义快捷栏入口",
UseWhen: []string{"需要永久删除某个自定义快捷栏入口"},
AvoidWhen: []string{"仅隐藏入口使用 chat toolbar hide;更新入口使用 chat toolbar update-custom"},
Examples: []string{"dws chat toolbar remove-custom --conversation-id <cid> --shortcut-id 123"},
},
Parameters: []contract.ParamDecl{
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
{Name: "shortcut-id", Property: "shortcutId", Required: boolPtr(true)},
},
},
})
return cmd
}
+115
View File
@@ -0,0 +1,115 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package chat
import (
"fmt"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
func newToolbarSortCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "sort",
Short: "排序快捷栏入口",
Long: "对会话快捷栏入口进行排序。sorted-ids 指定排序后的可见区入口 ID 列表,unsorted-ids 指定不参与排序放在末尾的入口 ID 列表。两个列表不能有交集。",
Example: ` dws chat toolbar sort --conversation-id <cid> --sorted-ids 101,102,103
dws chat toolbar sort --conversation-id <cid> --sorted-ids 101,102 --unsorted-ids 103,104
# 查询入口 ID: dws chat toolbar list --conversation-id <cid>`,
RunE: func(cmd *cobra.Command, args []string) error {
cid, err := toolbarConversationID(cmd)
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "sorted-ids"); err != nil {
return err
}
sortedIds, err := parseCSVInt64(mustGetFlag(cmd, "sorted-ids"))
if err != nil {
return fmt.Errorf("--sorted-ids: %w", err)
}
toolArgs := map[string]any{
"openConversationId": cid,
"sortedShortcutIds": sortedIds,
}
if unsortedRaw, _ := cmd.Flags().GetString("unsorted-ids"); unsortedRaw != "" {
unsortedIds, err := parseCSVInt64(unsortedRaw)
if err != nil {
return fmt.Errorf("--unsorted-ids: %w", err)
}
if hasIntersection(sortedIds, unsortedIds) {
return apperrors.NewValidation(
"--sorted-ids 与 --unsorted-ids 不能有交集",
apperrors.WithReason("id_intersection"),
apperrors.WithHint("检查两个 ID 列表,确保同一个 ID 不同时出现在两个列表中"),
apperrors.WithActions("修正 ID 列表后重试"),
)
}
toolArgs["unsortedShortcutIds"] = unsortedIds
}
err = callMCPToolOnServer("im", "sort_shortcut_bar", toolArgs)
if isSystemBusy(err) {
return toolbarNewSystemBusyError()
}
return err
},
}
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
cmd.Flags().String("sorted-ids", "", "排序后的入口 ID 列表(逗号分隔)")
cmd.Flags().String("unsorted-ids", "", "不参与排序放在末尾的入口 ID 列表(逗号分隔)")
_ = cmd.MarkFlagRequired("conversation-id")
_ = cmd.MarkFlagRequired("sorted-ids")
cmd.DisableAutoGenTag = true
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat",
Name: "sort_shortcut_bar",
CanonicalPath: "chat.sort_shortcut_bar",
CLIPath: "chat toolbar sort",
PrimaryCLIPath: "chat toolbar sort",
},
Description: "对会话快捷栏入口进行排序",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "sort_shortcut_bar"},
},
Selection: contract.SelectionSpec{
AgentSummary: "对会话快捷栏入口进行排序",
UseWhen: []string{"需要调整快捷栏入口的显示顺序"},
AvoidWhen: []string{"仅添加或隐藏入口使用 chat toolbar add / hide"},
Examples: []string{"dws chat toolbar sort --conversation-id <cid> --sorted-ids 101,102,103"},
},
Parameters: []contract.ParamDecl{
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
{Name: "sorted-ids", Property: "sortedShortcutIds", Required: boolPtr(true)},
{Name: "unsorted-ids", Property: "unsortedShortcutIds", Required: boolPtr(false)},
},
},
})
return cmd
}
@@ -0,0 +1,149 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package chat
import (
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func newToolbarUpdateCustomCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update-custom",
Short: "更新自定义快捷栏入口",
Long: `更新一个已有的自定义快捷栏入口。
必填参数:
--conversation-id 会话 openConversationId
--shortcut-id 自定义入口 ID
--title 入口标题
--url 入口跳转链接
--icon-url 入口图标 URL
--pc-url PC 端跳转链接
可选参数:
--extension 扩展信息,格式 key=value,可重复使用
--desc 入口描述
--tag 入口标签
--sort-index 排序权重`,
Example: ` dws chat toolbar update-custom --conversation-id <cid> --shortcut-id 123 --title "周报" --url "https://example.com" --icon-url "https://example.com/icon.png" --pc-url "https://example.com"
# 查询入口 ID: dws chat toolbar list --conversation-id <cid>`,
RunE: func(cmd *cobra.Command, args []string) error {
cid, err := toolbarConversationID(cmd)
if err != nil {
return err
}
shortcutId, _ := cmd.Flags().GetInt64("shortcut-id")
if err := validateRequiredFlags(cmd, "title", "url", "icon-url", "pc-url"); err != nil {
return err
}
toolArgs := map[string]any{
"openConversationId": cid,
"shortcutId": shortcutId,
"name": toolbarI18nJSONString(mustGetFlag(cmd, "title")),
"url": mustGetFlag(cmd, "url"),
"icons": toolbarIconsJSONString(mustGetFlag(cmd, "icon-url")),
"pcUrl": mustGetFlag(cmd, "pc-url"),
}
descValue := mustGetFlag(cmd, "desc")
if descValue == "" {
descValue = mustGetFlag(cmd, "title")
}
toolArgs["desc"] = toolbarI18nJSONString(descValue)
if tag, _ := cmd.Flags().GetString("tag"); tag != "" {
toolArgs["tag"] = tag
}
if cmd.Flags().Changed("sort-index") {
sortIndex, _ := cmd.Flags().GetInt("sort-index")
toolArgs["sortIndex"] = sortIndex
}
ext, err := parseExtension(cmd)
if err != nil {
return err
}
if ext != nil {
toolArgs["extension"] = ext
}
err = callMCPToolOnServer("im", "update_custom_shortcut", toolArgs)
if isSystemBusy(err) {
return toolbarNewSystemBusyError()
}
return err
},
}
cmd.Flags().String("conversation-id", "", "会话 openConversationId")
cmd.Flags().Int64("shortcut-id", 0, "自定义入口 ID")
cmd.Flags().String("title", "", "入口标题")
cmd.Flags().String("url", "", "入口跳转链接")
cmd.Flags().String("icon-url", "", "入口图标 URL")
cmd.Flags().String("pc-url", "", "PC 端跳转链接")
cmd.Flags().StringArray("extension", nil, "扩展信息,格式 key=value,可重复使用")
cmd.Flags().String("desc", "", "入口描述")
cmd.Flags().String("tag", "", "入口标签")
cmd.Flags().Int("sort-index", 0, "排序权重")
_ = cmd.MarkFlagRequired("conversation-id")
_ = cmd.MarkFlagRequired("shortcut-id")
_ = cmd.MarkFlagRequired("title")
_ = cmd.MarkFlagRequired("url")
_ = cmd.MarkFlagRequired("icon-url")
_ = cmd.MarkFlagRequired("pc-url")
cmd.DisableAutoGenTag = true
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat",
Name: "update_custom_shortcut",
CanonicalPath: "chat.update_custom_shortcut",
CLIPath: "chat toolbar update-custom",
PrimaryCLIPath: "chat toolbar update-custom",
},
Description: "更新自定义快捷栏入口",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "im", RPCName: "update_custom_shortcut"},
},
Selection: contract.SelectionSpec{
AgentSummary: "更新自定义快捷栏入口",
UseWhen: []string{"需要修改已有自定义快捷栏入口的标题、链接、图标等属性"},
AvoidWhen: []string{"创建新入口使用 chat toolbar create-custom;删除入口使用 chat toolbar remove-custom"},
Examples: []string{"dws chat toolbar update-custom --conversation-id <cid> --shortcut-id 123 --title \"周报\" --url \"https://example.com\" --icon-url \"https://example.com/icon.png\" --pc-url \"https://example.com\""},
},
Parameters: []contract.ParamDecl{
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(true)},
{Name: "shortcut-id", Property: "shortcutId", Required: boolPtr(true)},
{Name: "title", Property: "name", Required: boolPtr(true)},
{Name: "url", Property: "url", Required: boolPtr(true)},
{Name: "icon-url", Property: "icons", Required: boolPtr(true)},
{Name: "pc-url", Property: "pcUrl", Required: boolPtr(true)},
{Name: "extension", Property: "extension", Required: boolPtr(false)},
{Name: "desc", Property: "desc", Required: boolPtr(false)},
{Name: "tag", Property: "tag", Required: boolPtr(false)},
{Name: "sort-index", Property: "sortIndex", Required: boolPtr(false)},
},
},
})
return cmd
}
+4 -4
View File
@@ -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,62 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageChatCatalogSafetyMetadataIsExplicit(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.AddCommand(newChatCommand())
checked := 0
walkChatCatalogLeaves(root, func(cmd *cobra.Command) {
final, ok := contractfinal.RuntimeContractFinal(cmd)
if !ok || final.Identity == nil || final.Safety == nil {
return
}
if strings.TrimSpace(final.Identity.ProductID) != "chat" {
return
}
checked++
canonical := strings.TrimSpace(final.Identity.CanonicalPath)
safety := final.Safety
if safety.Effect == "" || safety.Risk == "" ||
safety.Confirmation == "" || safety.Idempotency == "" {
t.Fatalf("chat Catalog leaf %s has incomplete Safety: %+v", canonical, *safety)
}
if safety.Effect == "read" {
if safety.Risk != "low" ||
safety.Confirmation != "not_required" ||
safety.Idempotency != "idempotent" {
t.Fatalf("chat read Catalog leaf %s Safety = %+v, want read/low/not_required/idempotent",
canonical, *safety)
}
}
})
if checked == 0 {
t.Fatal("no chat Catalog leaves checked")
}
}
func walkChatCatalogLeaves(cmd *cobra.Command, fn func(*cobra.Command)) {
if cmd == nil {
return
}
if cmd.Runnable() && !cmd.HasSubCommands() {
fn(cmd)
return
}
for _, child := range cmd.Commands() {
if child.Name() == "help" {
continue
}
walkChatCatalogLeaves(child, fn)
}
}
+34
View File
@@ -0,0 +1,34 @@
// 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 (
"github.com/spf13/cobra"
chathelpers "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers/chat"
)
func newChatToolbarCommand() *cobra.Command {
chathelpers.SetDeps(chathelpers.Deps{
GroupRunE: GroupRunE,
CallMCPToolOnServer: CallMCPToolOnServer,
DeclareLeafMetadata: func(cmd *cobra.Command, spec chathelpers.LeafSpec) *cobra.Command {
return DeclareLeafMetadata(cmd, LeafSpec{
Safety: spec.Safety,
Contract: spec.Contract,
})
},
})
return chathelpers.NewChatToolbarCommand()
}
+52
View File
@@ -0,0 +1,52 @@
package helpers
import (
"context"
"io"
"reflect"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type chatToolbarParentCaller struct {
product string
tool string
args map[string]any
}
func (c *chatToolbarParentCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.product = productID
c.tool = toolName
c.args = args
return textToolResult(`{"ok":true}`), nil
}
func (*chatToolbarParentCaller) Format() string { return "json" }
func (*chatToolbarParentCaller) DryRun() bool { return false }
func (*chatToolbarParentCaller) Fields() string { return "" }
func (*chatToolbarParentCaller) JQ() string { return "" }
func TestCrossPlatformCoverageChatToolbarMountedThroughParentDeps(t *testing.T) {
caller := &chatToolbarParentCaller{}
installHelpersCoreDeps(t, caller)
root := newChatCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
if command, _, err := root.Find([]string{"toolbar", "list"}); err != nil || command == nil || command.Name() != "list" {
t.Fatalf("find chat toolbar list = command %v, err %v", command, err)
}
root.SetArgs([]string{"toolbar", "list", "--conversation-id", "cidX"})
if err := root.Execute(); err != nil {
t.Fatalf("chat toolbar list returned error: %v", err)
}
if caller.product != "im" || caller.tool != "list_chat_toolbar_shortcuts" {
t.Fatalf("tool call = %s/%s, want im/list_chat_toolbar_shortcuts", caller.product, caller.tool)
}
wantArgs := map[string]any{"openConversationId": "cidX"}
if !reflect.DeepEqual(caller.args, wantArgs) {
t.Fatalf("tool args = %#v, want %#v", caller.args, wantArgs)
}
}
@@ -81,6 +81,53 @@ func requireTypedConfirmationError(t *testing.T, err error) {
}
}
func TestChatChmodRequiresConfirmationBeforeRemoteCall(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"chmod", "chat.message:send",
"--agentCode", "wukong",
"--grant-type", "timed",
"--ttl", "24h",
"--conversation-id", "cidX",
)
requireTypedConfirmationError(t, err)
if len(caller.calls) != 0 {
t.Fatalf("expected 0 MCP calls before --yes, got %d: %+v", len(caller.calls), caller.calls)
}
}
func TestChatChmodCallsRemoteWithExactArgsWhenConfirmed(t *testing.T) {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand,
"chmod", "chat.message:send",
"--agentCode", "wukong",
"--grant-type", "timed",
"--ttl", "24h",
"--conversation-id", "cidX",
"--yes",
)
if err != nil {
t.Fatalf("chat chmod with --yes returned error: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("expected exactly 1 MCP call, got %d: %+v", len(caller.calls), caller.calls)
}
call := caller.calls[0]
if call.productID != "im" || call.toolName != "chat_permission_grant" {
t.Fatalf("tool call = %s/%s, want im/chat_permission_grant", call.productID, call.toolName)
}
wantArgs := map[string]any{
"agentCode": "wukong",
"scope": "chat.message:send",
"grantType": "timed",
"ttl": "24h",
"grantParams": `{"conversationId":"cidX","openCid":"cidX","openConversationId":"cidX"}`,
}
if !reflect.DeepEqual(call.args, wantArgs) {
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
}
}
type guardedStepCaller struct {
calls []guardedMutationCall
steps []string
+40
View File
@@ -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
@@ -3764,6 +3801,9 @@ CLI 内部自动完成全部流程:
xlsx, xls → 电子表格
md, txt → 文字文档
xmind, mark → 脑图
其他格式(html/pdf/zip 等)→ 不做在线文档转换,自动改走文件上传链路,
以原文件形式存入 --folder/--workspace 指定位置;如需在线文档请先转换
为 md;上传到钉盘请用 dws drive upload
文件大小限制: 20MB
+15 -1
View File
@@ -210,8 +210,16 @@ func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResul
}
return "", WrapError(err)
}
if result == nil {
return "", &CLIError{
Code: CodeMCPToolError,
Message: "MCP 工具返回 nil result,无法判断操作结果",
Suggestion: "不要把空回复当作成功;请重试并携带 operation/trace 信息排查服务端",
Operation: serverID + "/" + toolName,
}
}
for _, c := range result.Content {
if c.Type == "text" && c.Text != "" {
if c.Type == "text" && strings.TrimSpace(c.Text) != "" {
dumpRawToolResponse(serverID, toolName, c.Text)
var errBody map[string]any
if json.Unmarshal([]byte(c.Text), &errBody) == nil {
@@ -243,6 +251,12 @@ func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResul
return c.Text, nil
}
}
// Some legacy tools intentionally use an empty text response as an
// acknowledgement. This low-level parser cannot know the business contract,
// so preserve that representation. Data-returning callers must validate the
// operation-specific shape (for example records/tables/valid) before they
// report success. Orchestrators whose contract requires a business result use
// CallMCPWriteDataStrict and may prove an unknown effect by independent read-back.
return "", nil
}
+5 -1
View File
@@ -196,7 +196,11 @@ func TestCrossPlatformCoverageMCPReturnTextClassification(t *testing.T) {
}
caller.result = &edition.ToolResult{Content: []edition.ContentBlock{{Type: "image", Text: "ignored"}, {Type: "text"}}}
if got, err := callMCPToolReturnTextOnServer(context.Background(), "server", "tool", nil); err != nil || got != "" {
t.Fatalf("empty text result = %q, %v", got, err)
t.Fatalf("low-level empty text representation = %q, %v", got, err)
}
caller.result = nil
if _, err := callMCPToolReturnTextOnServer(context.Background(), "server", "tool", nil); err == nil {
t.Fatal("nil result must fail")
}
caller.result = textToolResult("shortcut result")
if got, err := CallMCPToolTextOnServer("server", "tool", nil); err != nil || got != "shortcut result" {
+125 -1
View File
@@ -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)
}
})
}
+32 -29
View File
@@ -143,39 +143,42 @@ func leafSpecCompositeLits(t *testing.T) []leafSpecLit {
}
dir := filepath.Dir(file)
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, dir, func(info os.FileInfo) bool {
name := info.Name()
return strings.HasSuffix(name, ".go") && !strings.HasSuffix(name, "_test.go")
}, 0)
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("parse helpers: %v", err)
t.Fatalf("read helpers dir: %v", err)
}
var out []leafSpecLit
for _, pkg := range pkgs {
for _, f := range pkg.Files {
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
fun, ok := call.Fun.(*ast.Ident)
if !ok || fun.Name != "NewLeafCommand" || len(call.Args) != 1 {
return true
}
lit, ok := call.Args[0].(*ast.CompositeLit)
if !ok {
return true
}
if typ, ok := lit.Type.(*ast.Ident); !ok || typ.Name != "LeafSpec" {
return true
}
out = append(out, leafSpecLit{
pos: fset.Position(lit.Pos()).String(),
lit: lit,
})
return true
})
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
parsed, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, 0)
if err != nil {
t.Fatalf("parse helpers file %s: %v", name, err)
}
ast.Inspect(parsed, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
fun, ok := call.Fun.(*ast.Ident)
if !ok || fun.Name != "NewLeafCommand" || len(call.Args) != 1 {
return true
}
lit, ok := call.Args[0].(*ast.CompositeLit)
if !ok {
return true
}
if typ, ok := lit.Type.(*ast.Ident); !ok || typ.Name != "LeafSpec" {
return true
}
out = append(out, leafSpecLit{
pos: fset.Position(lit.Pos()).String(),
lit: lit,
})
return true
})
}
if len(out) == 0 {
t.Fatal("no NewLeafCommand(LeafSpec{...}) literals found")
+188 -210
View File
@@ -59,6 +59,51 @@ func parseJSONObject(flag, s string) (map[string]any, error) {
return m, nil
}
// resolveNamedList finds a list in a response envelope without conflating a
// missing/malformed collection with a legitimate empty array. Some AITable MCP
// versions use a direct collection key while others wrap it one level below
// data/result, so all reviewed shapes are accepted but at least one explicit
// JSON array must be present.
func resolveNamedList(operation string, data map[string]any, outerKeys, innerKeys []string) ([]any, error) {
if data == nil {
return nil, fmt.Errorf("%s response is nil and does not establish a list result", operation)
}
invalid := make([]string, 0)
for _, key := range outerKeys {
value, exists := data[key]
if !exists {
continue
}
if list, ok := value.([]any); ok {
return list, nil
}
envelope, ok := value.(map[string]any)
if !ok {
invalid = append(invalid, fmt.Sprintf("%s=%T", key, value))
continue
}
foundInner := false
for _, innerKey := range innerKeys {
innerValue, innerExists := envelope[innerKey]
if !innerExists {
continue
}
foundInner = true
if list, ok := innerValue.([]any); ok {
return list, nil
}
invalid = append(invalid, fmt.Sprintf("%s.%s=%T", key, innerKey, innerValue))
}
if !foundInner {
invalid = append(invalid, fmt.Sprintf("%s has no recognized collection", key))
}
}
if len(invalid) > 0 {
return nil, fmt.Errorf("%s response has no valid list collection (%v)", operation, invalid)
}
return nil, fmt.Errorf("%s response is missing its list collection", operation)
}
// ─────────────────────────────────────────────────────────────
// base: Base 管理(server: aitable)
// ─────────────────────────────────────────────────────────────
@@ -116,7 +161,10 @@ var BaseList = shortcut.Shortcut{
if err != nil {
return err
}
bases := baseListProject(data)
bases, err := baseListProject("list_bases", data)
if err != nil {
return err
}
return rt.Output(map[string]any{"count": len(bases), "bases": bases})
},
}
@@ -124,15 +172,19 @@ var BaseList = shortcut.Shortcut{
// baseListProject reshapes list_bases / search_bases responses into a clean
// {baseId, baseName} list — clean output projection. Both the list
// container and the per-item field names are probed defensively across
// candidate keys, so an empty/unknown shape yields an empty list rather than a
// crash or fabricated data.
func baseListProject(data map[string]any) []map[string]any {
raw := baseListResolveList(data)
// candidate keys. An explicitly returned empty array is a successful empty
// result; a missing/malformed collection is a protocol error and must not be
// reported as count=0.
func baseListProject(operation string, data map[string]any) ([]map[string]any, error) {
raw, err := baseListResolveList(operation, data)
if err != nil {
return nil, err
}
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
for index, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
return nil, fmt.Errorf("%s response item %d must be an object, got %T", operation, index, item)
}
row := map[string]any{}
if v, ok := baseListFirst(m, "baseId", "base_id", "id"); ok {
@@ -141,36 +193,20 @@ func baseListProject(data map[string]any) []map[string]any {
if v, ok := baseListFirst(m, "baseName", "base_name", "name", "title"); ok {
row["baseName"] = v
}
if len(row) > 0 {
out = append(out, row)
if _, ok := row["baseId"]; !ok {
return nil, fmt.Errorf("%s response item %d is missing baseId", operation, index)
}
out = append(out, row)
}
return out
return out, nil
}
// baseListResolveList locates the list payload inside the response, tolerating a
// bare top-level array container or nesting one level under a common envelope.
func baseListResolveList(data map[string]any) []any {
if data == nil {
return []any{}
}
for _, key := range []string{"bases", "result", "data", "list", "items", "records"} {
v, ok := data[key]
if !ok {
continue
}
if arr, ok := v.([]any); ok {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"bases", "list", "items", "result", "data", "records"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
}
}
}
return []any{}
func baseListResolveList(operation string, data map[string]any) ([]any, error) {
return resolveNamedList(operation, data,
[]string{"bases", "result", "data", "list", "items", "records"},
[]string{"bases", "list", "items", "result", "data", "records"})
}
// baseListFirst returns the first present candidate key's value.
@@ -230,7 +266,10 @@ var BaseSearch = shortcut.Shortcut{
if err != nil {
return err
}
bases := baseListProject(data)
bases, err := baseListProject("search_bases", data)
if err != nil {
return err
}
return rt.Output(map[string]any{"count": len(bases), "bases": bases})
},
}
@@ -339,7 +378,7 @@ var BaseDelete = shortcut.Shortcut{
{Name: "base-id", Type: shortcut.FlagString, Desc: "待删除 Base ID", Required: true},
{Name: "reason", Type: shortcut.FlagString, Desc: "删除原因(可选)"},
},
Tips: []string{`dws aitable +base-delete --base-id BASE_ID --yes`},
Tips: []string{`dws aitable +base-delete --base-id BASE_ID`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"baseId": rt.Str("base-id")}
if rt.Changed("reason") {
@@ -474,7 +513,7 @@ var TableDelete = shortcut.Shortcut{
{Name: "table-id", Type: shortcut.FlagString, Desc: "待删除 Table ID", Required: true},
{Name: "reason", Type: shortcut.FlagString, Desc: "删除原因(可选)"},
},
Tips: []string{`dws aitable +table-delete --base-id B --table-id T --yes`},
Tips: []string{`dws aitable +table-delete --base-id B --table-id T`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"baseId": rt.Str("base-id"),
@@ -601,7 +640,7 @@ var FieldDelete = shortcut.Shortcut{
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
{Name: "field-id", Type: shortcut.FlagString, Desc: "待删除字段 ID", Required: true},
},
Tips: []string{`dws aitable +field-delete --base-id B --table-id T --field-id F --yes`},
Tips: []string{`dws aitable +field-delete --base-id B --table-id T --field-id F`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("delete_field", map[string]any{
"baseId": rt.Str("base-id"),
@@ -704,26 +743,16 @@ var RecordUpdate = shortcut.Shortcut{
Service: "aitable",
Command: "+record-update",
Product: serverMain,
Description: "批量更新记录(每条含 recordId 与 cells),单次最多 100 条",
Intent: "当你要批量修改已有行的字段值(如把一批任务标为已完成,每条须带 recordId)时使用;会实际覆盖对应单元格,单次最多 100 条。",
Description: "批量更新记录,自动按 100 条分片并逐批读回验证",
Intent: "当你要批量修改已有行的字段值(每条须带 recordId)时使用;自动按 100 条分片,任一批失败或读回不一致即非零退出并给出 nextOffset。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
{Name: "records", Type: shortcut.FlagString, Desc: "记录 JSON 数组,如 '[{\"recordId\":\"rec\",\"cells\":{...}}]'", Required: true},
},
Tips: []string{`dws aitable +record-update --base-id B --table-id T --records '[{"recordId":"rec","cells":{"fldStatusId":"已完成"}}]'`},
Execute: func(rt *shortcut.RuntimeContext) error {
recs, err := parseJSONAny("records", rt.Str("records"))
if err != nil {
return err
}
return rt.CallMCP("update_records", map[string]any{
"baseId": rt.Str("base-id"),
"tableId": rt.Str("table-id"),
"records": recs,
})
},
Tips: []string{`dws aitable +record-update --base-id B --table-id T --records '[{"recordId":"rec","cells":{"fldStatusId":"已完成"}}]'`},
Execute: executeRecordUpdateBatches,
}
// RecordDelete 删除行记录(delete_records)。
@@ -731,22 +760,16 @@ var RecordDelete = shortcut.Shortcut{
Service: "aitable",
Command: "+record-delete",
Product: serverMain,
Description: "批量删除记录(不可逆),单次最多 100 条",
Intent: "当你确认要批量删除若干行记录时使用;不可逆,按 recordId 列表删除,单次最多 100 条。",
Description: "批量删除记录(不可逆),自动按 100 条分片并逐批确认记录已不存在",
Intent: "当你确认要批量删除若干行记录时使用;自动按 100 条分片,只有每批全部 recordId 读回均不存在才判定成功,否则非零退出并给出 nextOffset。",
Risk: shortcut.RiskHighWrite,
Flags: []shortcut.Flag{
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
{Name: "record-ids", Type: shortcut.FlagStringSlice, Desc: "待删除记录 ID 列表,逗号分隔", Required: true},
},
Tips: []string{`dws aitable +record-delete --base-id B --table-id T --record-ids rec1,rec2 --yes`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("delete_records", map[string]any{
"baseId": rt.Str("base-id"),
"tableId": rt.Str("table-id"),
"recordIds": rt.StrSlice("record-ids"),
})
{Name: "record-ids", Type: shortcut.FlagStringSlice, Desc: "待删除记录 ID 列表,逗号分隔;最多 10000 个唯一 ID", Required: true},
},
Tips: []string{`dws aitable +record-delete --base-id B --table-id T --record-ids rec1,rec2`},
Execute: executeRecordDeleteBatches,
}
// RecordQueryEmpty 查询空行(query_empty_records,server: aitable-helper)。
@@ -919,26 +942,16 @@ var RecordUpsert = shortcut.Shortcut{
Service: "aitable",
Command: "+record-upsert",
Product: serverHelper,
Description: "按 records 是否带 recordId 自动拆分 create / update",
Intent: "当你有一批数据、其中部分是新增部分是更新、不想自己区分时使用;按记录是否带 recordId 自动拆分为创建或更新并实际写入,单次最多 100 条。",
Description: "按 recordId 自动拆分 create/update,按 100 条分片并读回验证",
Intent: "当一批数据中部分新增、部分更新时使用;自动按 recordId 拆分并以 100 条为批次写入,创建必须返回新 recordId,全部批次都需读回验证。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
{Name: "records", Type: shortcut.FlagString, Desc: "记录 JSON 数组,单次最多 100 条", Required: true},
},
Tips: []string{`dws aitable +record-upsert --base-id B --table-id T --records '[{"cells":{...}}]'`},
Execute: func(rt *shortcut.RuntimeContext) error {
recs, err := parseJSONAny("records", rt.Str("records"))
if err != nil {
return err
}
return rt.CallMCP("record_upsert", map[string]any{
"baseId": rt.Str("base-id"),
"tableId": rt.Str("table-id"),
"records": recs,
})
},
Tips: []string{`dws aitable +record-upsert --base-id B --table-id T --records '[{"cells":{...}}]'`},
Execute: executeRecordUpsertBatches,
}
// RecordPrimaryDocGet 查询记录主键文档(get_primary_doc,server: aitable-helper)。
@@ -1047,7 +1060,10 @@ var TemplateSearch = shortcut.Shortcut{
if err != nil {
return err
}
templates := templateSearchProject(data)
templates, err := templateSearchProject(data)
if err != nil {
return err
}
return rt.Output(map[string]any{"count": len(templates), "templates": templates})
},
}
@@ -1055,15 +1071,17 @@ var TemplateSearch = shortcut.Shortcut{
// templateSearchProject reshapes the raw search_templates response into a clean
// {templateId, templateName} list — clean output projection. Both
// the list container and the per-item field names are probed defensively across
// candidate keys, so an empty/unknown shape yields an empty list rather than a
// crash or fabricated data.
func templateSearchProject(data map[string]any) []map[string]any {
raw := templateSearchResolveList(data)
// candidate keys. Only an explicit array can establish a successful result.
func templateSearchProject(data map[string]any) ([]map[string]any, error) {
raw, err := templateSearchResolveList(data)
if err != nil {
return nil, err
}
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
for index, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
return nil, fmt.Errorf("search_templates response item %d must be an object, got %T", index, item)
}
row := map[string]any{}
if v, ok := templateSearchFirst(m, "templateId", "template_id", "id"); ok {
@@ -1072,37 +1090,21 @@ func templateSearchProject(data map[string]any) []map[string]any {
if v, ok := templateSearchFirst(m, "templateName", "template_name", "name", "title"); ok {
row["templateName"] = v
}
if len(row) > 0 {
out = append(out, row)
if _, ok := row["templateId"]; !ok {
return nil, fmt.Errorf("search_templates response item %d is missing templateId", index)
}
out = append(out, row)
}
return out
return out, nil
}
// templateSearchResolveList locates the list payload inside the response,
// tolerating a bare top-level array container or nesting one level under a
// common envelope key.
func templateSearchResolveList(data map[string]any) []any {
if data == nil {
return []any{}
}
for _, key := range []string{"templates", "result", "data", "list", "items"} {
v, ok := data[key]
if !ok {
continue
}
if arr, ok := v.([]any); ok {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"templates", "list", "items", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
}
}
}
return []any{}
func templateSearchResolveList(data map[string]any) ([]any, error) {
return resolveNamedList("search_templates", data,
[]string{"templates", "result", "data", "list", "items"},
[]string{"templates", "list", "items", "result", "data"})
}
// templateSearchFirst returns the first present candidate key's value.
@@ -1198,7 +1200,10 @@ var ViewGet = shortcut.Shortcut{
if err != nil {
return err
}
views := viewGetProject(data)
views, err := viewGetProject(data)
if err != nil {
return err
}
return rt.Output(map[string]any{"count": len(views), "views": views})
},
}
@@ -1206,15 +1211,17 @@ var ViewGet = shortcut.Shortcut{
// viewGetProject reshapes the raw get_views response into a clean
// {viewId, viewName, viewType} list — clean output projection. Both
// the list container and the per-item field names are probed defensively across
// candidate keys, so an empty/unknown shape yields an empty list rather than a
// crash or fabricated data.
func viewGetProject(data map[string]any) []map[string]any {
raw := viewGetResolveList(data)
// candidate keys. Only an explicit array can establish a successful result.
func viewGetProject(data map[string]any) ([]map[string]any, error) {
raw, err := viewGetResolveList(data)
if err != nil {
return nil, err
}
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
for index, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
return nil, fmt.Errorf("get_views response item %d must be an object, got %T", index, item)
}
row := map[string]any{}
if v, ok := viewGetFirst(m, "viewId", "view_id", "id"); ok {
@@ -1226,37 +1233,21 @@ func viewGetProject(data map[string]any) []map[string]any {
if v, ok := viewGetFirst(m, "viewType", "view_type", "type"); ok {
row["viewType"] = v
}
if len(row) > 0 {
out = append(out, row)
if _, ok := row["viewId"]; !ok {
return nil, fmt.Errorf("get_views response item %d is missing viewId", index)
}
out = append(out, row)
}
return out
return out, nil
}
// viewGetResolveList locates the list payload inside the response, tolerating a
// bare top-level array container or nesting one level under a common envelope
// key.
func viewGetResolveList(data map[string]any) []any {
if data == nil {
return []any{}
}
for _, key := range []string{"views", "result", "data", "list", "items"} {
v, ok := data[key]
if !ok {
continue
}
if arr, ok := v.([]any); ok {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"views", "list", "items", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
}
}
}
return []any{}
func viewGetResolveList(data map[string]any) ([]any, error) {
return resolveNamedList("get_views", data,
[]string{"views", "result", "data", "list", "items"},
[]string{"views", "list", "items", "result", "data"})
}
// viewGetFirst returns the first present candidate key's value.
@@ -1355,7 +1346,7 @@ var ViewDelete = shortcut.Shortcut{
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
{Name: "view-id", Type: shortcut.FlagString, Desc: "待删除 View ID", Required: true},
},
Tips: []string{`dws aitable +view-delete --base-id B --table-id T --view-id V --yes`},
Tips: []string{`dws aitable +view-delete --base-id B --table-id T --view-id V`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("delete_view", map[string]any{
"baseId": rt.Str("base-id"),
@@ -1668,23 +1659,28 @@ var FormList = shortcut.Shortcut{
if err != nil {
return err
}
forms := formListProject(data)
forms, err := formListProject(data)
if err != nil {
return err
}
return rt.Output(map[string]any{"count": len(forms), "forms": forms})
},
}
// formListProject reshapes the raw list_form_views response into a clean form
// view list ({viewId, viewName, viewType}) — clean output projection.
// Both the list container and the per-item field names are probed
// defensively across candidate keys, so an empty/unknown shape yields an empty
// list rather than a crash or fabricated data.
func formListProject(data map[string]any) []map[string]any {
raw := formListResolveList(data)
// Both the list container and the per-item field names are probed defensively
// across candidate keys. Only an explicit array can establish success.
func formListProject(data map[string]any) ([]map[string]any, error) {
raw, err := formListResolveList(data)
if err != nil {
return nil, err
}
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
for index, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
return nil, fmt.Errorf("list_form_views response item %d must be an object, got %T", index, item)
}
row := map[string]any{}
if v, ok := formListFirst(m, "viewId", "view_id", "id"); ok {
@@ -1696,36 +1692,20 @@ func formListProject(data map[string]any) []map[string]any {
if v, ok := formListFirst(m, "viewType", "view_type", "type"); ok {
row["viewType"] = v
}
if len(row) > 0 {
out = append(out, row)
if _, ok := row["viewId"]; !ok {
return nil, fmt.Errorf("list_form_views response item %d is missing viewId", index)
}
out = append(out, row)
}
return out
return out, nil
}
// formListResolveList locates the list payload inside the response, tolerating a
// bare top-level array container or nesting one level under a common envelope.
func formListResolveList(data map[string]any) []any {
if data == nil {
return []any{}
}
for _, key := range []string{"forms", "views", "formViews", "result", "data", "list", "items"} {
v, ok := data[key]
if !ok {
continue
}
if arr, ok := v.([]any); ok {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"forms", "views", "formViews", "list", "items", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
}
}
}
return []any{}
func formListResolveList(data map[string]any) ([]any, error) {
return resolveNamedList("list_form_views", data,
[]string{"forms", "views", "formViews", "result", "data", "list", "items"},
[]string{"forms", "views", "formViews", "list", "items", "result", "data"})
}
// formListFirst returns the first present candidate key's value.
@@ -1751,7 +1731,7 @@ var FormDelete = shortcut.Shortcut{
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
{Name: "view-id", Type: shortcut.FlagString, Desc: "表单 View ID", Required: true},
},
Tips: []string{`dws aitable +form-delete --base-id B --table-id T --view-id V --yes`},
Tips: []string{`dws aitable +form-delete --base-id B --table-id T --view-id V`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("delete_form_view", map[string]any{
"baseId": rt.Str("base-id"),
@@ -2013,7 +1993,7 @@ var WorkflowDisable = shortcut.Shortcut{
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
{Name: "workflow-id", Type: shortcut.FlagString, Desc: "Workflow ID", Required: true},
},
Tips: []string{`dws aitable +workflow-disable --base-id B --workflow-id W --yes`},
Tips: []string{`dws aitable +workflow-disable --base-id B --workflow-id W`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("disable_workflow", map[string]any{
"baseId": rt.Str("base-id"),
@@ -2069,23 +2049,27 @@ var WorkflowList = shortcut.Shortcut{
if err != nil {
return err
}
workflows := workflowListProject(data)
workflows, err := workflowListProject(data)
if err != nil {
return err
}
return rt.Output(map[string]any{"count": len(workflows), "workflows": workflows})
},
}
// workflowListProject reshapes the raw list_workflows response into a clean
// workflow list ({workflowId, name, status/enabled}) — output-projection
// clean output projection. Both the list container and the per-item field names are
// probed defensively across candidate keys, so an empty/unknown shape yields an
// empty list rather than a crash or fabricated data.
func workflowListProject(data map[string]any) []map[string]any {
raw := workflowListResolveList(data)
// clean output projection. Only an explicit array can establish success.
func workflowListProject(data map[string]any) ([]map[string]any, error) {
raw, err := workflowListResolveList(data)
if err != nil {
return nil, err
}
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
for index, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
return nil, fmt.Errorf("list_workflows response item %d must be an object, got %T", index, item)
}
row := map[string]any{}
if v, ok := workflowListFirst(m, "workflowId", "workflow_id", "id"); ok {
@@ -2097,37 +2081,21 @@ func workflowListProject(data map[string]any) []map[string]any {
if v, ok := workflowListFirst(m, "status", "enabled", "isEnabled", "state"); ok {
row["status"] = v
}
if len(row) > 0 {
out = append(out, row)
if _, ok := row["workflowId"]; !ok {
return nil, fmt.Errorf("list_workflows response item %d is missing workflowId", index)
}
out = append(out, row)
}
return out
return out, nil
}
// workflowListResolveList locates the list payload inside the response,
// tolerating a bare top-level array container or nesting one level under a
// common envelope.
func workflowListResolveList(data map[string]any) []any {
if data == nil {
return []any{}
}
for _, key := range []string{"workflows", "result", "data", "list", "items"} {
v, ok := data[key]
if !ok {
continue
}
if arr, ok := v.([]any); ok {
return arr
}
if inner, ok := v.(map[string]any); ok {
for _, ik := range []string{"workflows", "list", "items", "result", "data"} {
if arr, ok := inner[ik].([]any); ok {
return arr
}
}
}
}
return []any{}
func workflowListResolveList(data map[string]any) ([]any, error) {
return resolveNamedList("list_workflows", data,
[]string{"workflows", "result", "data", "list", "items"},
[]string{"workflows", "list", "items", "result", "data"})
}
// workflowListFirst returns the first present candidate key's value.
@@ -2281,7 +2249,7 @@ var DashboardDelete = shortcut.Shortcut{
{Name: "dashboard-id", Type: shortcut.FlagString, Desc: "Dashboard ID", Required: true},
{Name: "reason", Type: shortcut.FlagString, Desc: "删除原因(可选)"},
},
Tips: []string{`dws aitable +dashboard-delete --base-id B --dashboard-id D --yes`},
Tips: []string{`dws aitable +dashboard-delete --base-id B --dashboard-id D`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"baseId": rt.Str("base-id"),
@@ -2512,7 +2480,7 @@ var ChartDelete = shortcut.Shortcut{
{Name: "chart-id", Type: shortcut.FlagString, Desc: "Chart ID", Required: true},
{Name: "reason", Type: shortcut.FlagString, Desc: "删除原因(可选)"},
},
Tips: []string{`dws aitable +chart-delete --base-id B --dashboard-id D --chart-id C --yes`},
Tips: []string{`dws aitable +chart-delete --base-id B --dashboard-id D --chart-id C`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"baseId": rt.Str("base-id"),
@@ -2744,7 +2712,7 @@ var AdvpermDisable = shortcut.Shortcut{
Flags: []shortcut.Flag{
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
},
Tips: []string{`dws aitable +advperm-disable --base-id B --yes`},
Tips: []string{`dws aitable +advperm-disable --base-id B`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("set_advanced_permission", map[string]any{
"baseId": rt.Str("base-id"),
@@ -2908,7 +2876,7 @@ var RoleDelete = shortcut.Shortcut{
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
{Name: "role-id", Type: shortcut.FlagString, Desc: "Role ID(数字 long 字符串)", Required: true},
},
Tips: []string{`dws aitable +role-delete --base-id B --role-id R --yes`},
Tips: []string{`dws aitable +role-delete --base-id B --role-id R`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("delete_role", map[string]any{
"baseId": rt.Str("base-id"),
@@ -2986,7 +2954,7 @@ var SectionDelete = shortcut.Shortcut{
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
{Name: "section-id", Type: shortcut.FlagString, Desc: "Section ID", Required: true},
},
Tips: []string{`dws aitable +section-delete --base-id B --section-id S --yes`},
Tips: []string{`dws aitable +section-delete --base-id B --section-id S`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("delete_section", map[string]any{
"baseId": rt.Str("base-id"),
@@ -3131,7 +3099,7 @@ var SectionMoveNode = shortcut.Shortcut{
}
func init() {
shortcut.Register(
shortcut.Register(withReviewedAITableShortcutContracts(
BaseList,
BaseSearch,
BaseGet,
@@ -3139,7 +3107,10 @@ func init() {
BaseUpdate,
BaseDelete,
BaseCopy,
BaseSchemaSnapshot,
BaseBootstrap,
TableGet,
TableCopy,
TableUpdate,
TableDelete,
FieldGet,
@@ -3152,12 +3123,17 @@ func init() {
RecordHistoryList,
RecordShareURL,
RecordUpsert,
RecordUpsertByKey,
RecordBulkPatch,
RecordPrimaryDocGet,
RecordPrimaryDocCreate,
TemplateSearch,
AttachmentUpload,
AttachmentPut,
AttachmentRemove,
ViewGet,
ViewUpdate,
ViewPresetApply,
ViewDuplicate,
ViewDelete,
ViewGetLock,
@@ -3179,6 +3155,7 @@ func init() {
WorkflowDisable,
WorkflowGet,
WorkflowList,
WorkflowDeploy,
DashboardConfigExample,
DashboardGet,
DashboardUpdate,
@@ -3209,5 +3186,6 @@ func init() {
SectionListEmpty,
SectionListNodes,
SectionMoveNode,
)
URLResolve,
)...)
}
@@ -0,0 +1,372 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"fmt"
"io"
"mime"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"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"
)
const maxAttachmentFileSize = int64(100 * 1024 * 1024)
func rejectAttachmentUploadRedirect(*http.Request, []*http.Request) error {
// Upload credentials are issued for one exact endpoint. Do not let a 3xx
// response redirect local file bytes or the credential to another host or
// to a URL outside validateAttachmentUploadURL's transport policy.
return http.ErrUseLastResponse
}
var attachmentHTTPDo = (&http.Client{CheckRedirect: rejectAttachmentUploadRedirect}).Do
var AttachmentPut = shortcut.Shortcut{
Service: "aitable",
Command: "+attachment-put",
Product: serverMain,
Description: "准备凭证、实际 PUT 本地文件、写入 attachment 单元格并读回验证",
Intent: "当你要把本地文件真正上传并落到指定记录附件字段时使用;不是只返回 uploadUrl,支持 replace 或在现有项可安全重写时 append。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: aitableCompositeContract(
"+attachment-put",
"准备凭证、实际 PUT 本地文件、写入 attachment 单元格并读回验证",
"当你要把本地文件真正上传并落到指定记录附件字段时使用;不是只返回 uploadUrl,支持 replace 或在现有项可安全重写时 append。",
"只申请上传凭证用 attachment upload;不要用 drive fileId;现有附件读回缺 fileToken 时无法安全 append,只能 replace 或停止",
`dws aitable +attachment-put --base-id B --table-id T --record-id R --field-id F --file ./report.pdf --mode replace`,
),
Flags: []shortcut.Flag{
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
{Name: "record-id", Type: shortcut.FlagString, Desc: "Record ID", Required: true},
{Name: "field-id", Type: shortcut.FlagString, Desc: "attachment Field ID", Required: true},
{Name: "file", Type: shortcut.FlagString, Desc: "本地文件路径", Required: true},
{Name: "mode", Type: shortcut.FlagString, Default: "append", Desc: "append 保留现有附件;replace 整体替换", Enum: []string{"append", "replace"}},
{Name: "mime-type", Type: shortcut.FlagString, Desc: "覆盖自动推断的 MIME type(可选)"},
},
Tips: []string{`dws aitable +attachment-put --base-id B --table-id T --record-id R --field-id F --file ./report.pdf --mode replace`},
Execute: func(rt *shortcut.RuntimeContext) error {
return executeAttachmentPut(rt)
},
}
var AttachmentRemove = shortcut.Shortcut{
Service: "aitable",
Command: "+attachment-remove",
Product: serverMain,
Description: "从 attachment 字段清空全部或按文件名移除,写前确保剩余项具有可重写 fileToken,并读回验证",
Intent: "当你要清空附件字段,或按精确文件名移除且安全保留其他附件时使用;读回若不给剩余项 fileToken 会在写前停止并说明服务边界。",
Risk: shortcut.RiskHighWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "idempotent",
},
Contract: aitableCompositeContract(
"+attachment-remove",
"从 attachment 字段清空全部或按文件名移除,写前确保剩余项具有可重写 fileToken,并读回验证",
"当你要清空附件字段,或按精确文件名移除且安全保留其他附件时使用;读回若不给剩余项 fileToken 会在写前停止并说明服务边界。",
"读回仅有下载 URL 而没有剩余附件 fileToken 时,DWS 无法在覆盖式写接口上安全对齐逐项删除;此时只能 --clear-all 或停止",
`dws aitable +attachment-remove --base-id B --table-id T --record-id R --field-id F --clear-all`,
),
Flags: []shortcut.Flag{
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
{Name: "record-id", Type: shortcut.FlagString, Desc: "Record ID", Required: true},
{Name: "field-id", Type: shortcut.FlagString, Desc: "attachment Field ID", Required: true},
{Name: "remove-name", Type: shortcut.FlagString, Desc: "移除精确文件名的所有匹配项;与 --clear-all 二选一"},
{Name: "clear-all", Type: shortcut.FlagBool, Desc: "清空该字段全部附件;与 --remove-name 二选一"},
},
Tips: []string{`dws aitable +attachment-remove --base-id B --table-id T --record-id R --field-id F --clear-all`},
Execute: func(rt *shortcut.RuntimeContext) error {
return executeAttachmentRemove(rt)
},
}
func executeAttachmentPut(rt *shortcut.RuntimeContext) error {
file, info, mimeType, err := openAttachmentFile(rt.Str("file"), rt.Str("mime-type"))
if err != nil {
return err
}
defer file.Close()
baseID, tableID, recordIDValue, fieldID := rt.Str("base-id"), rt.Str("table-id"), rt.Str("record-id"), rt.Str("field-id")
record, existing, err := readAttachmentCell(rt, baseID, tableID, recordIDValue, fieldID)
if err != nil {
return err
}
_ = record
desired := make([]any, 0, len(existing)+1)
if rt.Str("mode") == "append" {
for index, item := range existing {
token := attachmentToken(item)
if token == "" {
return apperrors.NewValidation(fmt.Sprintf("现有附件[%d]读回缺少 fileToken,覆盖式写接口无法安全 append;请用 --mode replace 或保留原数据后人工处理", index),
apperrors.WithReason("attachment_tokens_unavailable"), apperrors.WithExecutionStarted(false))
}
desired = append(desired, map[string]any{"fileToken": token})
}
}
result := newCompositeResult("attachment_put")
result.Resolved = map[string]any{"baseId": baseID, "tableId": tableID, "recordId": recordIDValue, "fieldId": fieldID, "fileName": info.Name(), "size": info.Size(), "mode": rt.Str("mode")}
result.Plan = []compositeStep{
{Index: 1, Name: "prepare attachment upload", Tool: "prepare_attachment_upload", Status: "planned"},
{Index: 2, Name: "PUT file bytes", Tool: "HTTP PUT", Status: "planned"},
{Index: 3, Name: "write and verify attachment cell", Tool: "update_records", Status: "planned"},
}
if rt.DryRun() {
result.Status = "planned"
result.Executed = false
return rt.Output(result)
}
prepareData, err := rt.CallMCPWriteDataStrict(serverMain, "prepare_attachment_upload", map[string]any{"baseId": baseID, "fileName": info.Name(), "size": info.Size(), "mimeType": mimeType})
if err != nil {
result.Status = "unknown"
return compositeError(result, err, false)
}
uploadURL := findStringByKeys(prepareData, "uploadUrl")
fileToken := findStringByKeys(prepareData, "fileToken")
if uploadURL == "" || fileToken == "" {
result.Status = "unknown"
return compositeError(result, fmt.Errorf("prepare_attachment_upload response is missing uploadUrl or fileToken"), false)
}
if err := validateAttachmentUploadURL(uploadURL); err != nil {
result.Status = "unknown"
return compositeError(result, err, false)
}
// validateAttachmentUploadURL has already rejected every URL shape for
// which NewRequestWithContext can fail; PUT is a fixed valid method.
request, _ := http.NewRequestWithContext(rt.Command().Context(), http.MethodPut, uploadURL, file)
request.Header.Set("Content-Type", mimeType)
request.ContentLength = info.Size()
response, err := attachmentHTTPDo(request)
if err != nil {
result.Status = "partial_success"
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "prepare_attachment_upload", "fileToken": fileToken})
return compositeError(result, fmt.Errorf("attachment HTTP PUT failed: %w", err), false)
}
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<20))
closeErr := response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 || closeErr != nil {
result.Status = "partial_success"
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "prepare_attachment_upload", "fileToken": fileToken, "httpStatus": response.StatusCode})
return compositeError(result, fmt.Errorf("attachment HTTP PUT returned status %d (close error: %v)", response.StatusCode, closeErr), false)
}
desired = append(desired, map[string]any{"fileToken": fileToken})
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "HTTP PUT", "fileToken": fileToken, "fileName": info.Name(), "size": info.Size()})
_, writeErr := rt.CallMCPWriteDataStrict(serverMain, "update_records", map[string]any{
"baseId": baseID, "tableId": tableID,
"records": []any{map[string]any{"recordId": recordIDValue, "cells": map[string]any{fieldID: desired}}},
})
_, readBack, verifyErr := readAttachmentCell(rt, baseID, tableID, recordIDValue, fieldID)
if verifyErr == nil {
verifyErr = verifyAttachmentPut(readBack, len(desired), fileToken, info.Name(), info.Size())
}
if verifyErr != nil {
result.Status = "partial_success"
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "update_records", "recordId": recordIDValue, "fieldId": fieldID})
if writeErr != nil {
result.Warnings = append(result.Warnings, "record write response error: "+writeErr.Error())
}
return compositeError(result, verifyErr, false)
}
result.CompletedCount = 1
result.Verification = map[string]any{"status": "verified", "attachmentCount": len(readBack), "fileName": info.Name(), "size": info.Size()}
result.Result = map[string]any{"fileToken": fileToken, "fileName": info.Name(), "size": info.Size(), "mimeType": mimeType, "attachmentCount": len(readBack)}
if writeErr != nil {
result.Status = "recovered"
result.Warnings = append(result.Warnings, "record write response was an error, but the attachment cell was proven by read-back")
}
return rt.Output(result)
}
func executeAttachmentRemove(rt *shortcut.RuntimeContext) error {
removeName := strings.TrimSpace(rt.Str("remove-name"))
clearAll := rt.Bool("clear-all")
if (removeName == "") == !clearAll {
return apperrors.NewValidation("必须且只能提供 --remove-name 或 --clear-all")
}
baseID, tableID, recordIDValue, fieldID := rt.Str("base-id"), rt.Str("table-id"), rt.Str("record-id"), rt.Str("field-id")
_, existing, err := readAttachmentCell(rt, baseID, tableID, recordIDValue, fieldID)
if err != nil {
return err
}
desired := make([]any, 0, len(existing))
removed := 0
if clearAll {
removed = len(existing)
} else {
for index, item := range existing {
if attachmentName(item) == removeName {
removed++
continue
}
token := attachmentToken(item)
if token == "" {
return apperrors.NewValidation(fmt.Sprintf("剩余附件[%d]读回缺少 fileToken,覆盖式写接口无法安全保留后删除;服务端当前不支持按附件增量删除", index),
apperrors.WithReason("attachment_tokens_unavailable"), apperrors.WithExecutionStarted(false))
}
desired = append(desired, map[string]any{"fileToken": token})
}
}
result := newCompositeResult("attachment_remove")
result.RequestedCount = removed
result.Resolved = map[string]any{"baseId": baseID, "tableId": tableID, "recordId": recordIDValue, "fieldId": fieldID, "removeName": removeName, "clearAll": clearAll}
if removed == 0 {
result.Status = "unchanged"
result.Executed = false
result.Verification = map[string]any{"status": "verified", "attachmentCount": len(existing), "removedCount": 0}
return rt.Output(result)
}
result.Plan = []compositeStep{{Index: 1, Name: "replace attachment cell without selected items", Tool: "update_records", Status: "planned", Count: removed}}
if rt.DryRun() {
result.Status = "planned"
result.Executed = false
return rt.Output(result)
}
_, writeErr := rt.CallMCPWriteDataStrict(serverMain, "update_records", map[string]any{
"baseId": baseID, "tableId": tableID,
"records": []any{map[string]any{"recordId": recordIDValue, "cells": map[string]any{fieldID: desired}}},
})
_, readBack, verifyErr := readAttachmentCell(rt, baseID, tableID, recordIDValue, fieldID)
if verifyErr == nil && len(readBack) != len(desired) {
verifyErr = fmt.Errorf("attachment read-back count is %d, want %d", len(readBack), len(desired))
}
if verifyErr == nil && removeName != "" {
for _, item := range readBack {
if attachmentName(item) == removeName {
verifyErr = fmt.Errorf("attachment %q is still present after removal", removeName)
break
}
}
}
if verifyErr != nil {
result.Status = "unknown"
if writeErr != nil {
result.Warnings = append(result.Warnings, "record write response error: "+writeErr.Error())
}
return compositeError(result, verifyErr, true)
}
result.CompletedCount = removed
result.Verification = map[string]any{"status": "verified", "removedCount": removed, "attachmentCount": len(readBack)}
result.Result = map[string]any{"removedCount": removed, "attachmentCount": len(readBack)}
if writeErr != nil {
result.Status = "recovered"
result.Warnings = append(result.Warnings, "record write response was an error, but removal was proven by read-back")
}
return rt.Output(result)
}
func openAttachmentFile(path, overrideMIME string) (*os.File, os.FileInfo, string, error) {
file, err := os.Open(path)
if err != nil {
return nil, nil, "", apperrors.NewValidation("无法打开 --file: " + err.Error())
}
info, err := file.Stat()
if err != nil || !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxAttachmentFileSize {
file.Close()
return nil, nil, "", apperrors.NewValidation(fmt.Sprintf("--file 必须是 1..%d 字节的普通文件", maxAttachmentFileSize))
}
mimeType := strings.TrimSpace(overrideMIME)
if mimeType == "" {
mimeType = mime.TypeByExtension(strings.ToLower(filepath.Ext(info.Name())))
}
if mimeType == "" {
header := make([]byte, 512)
read, _ := file.Read(header)
mimeType = http.DetectContentType(header[:read])
_, _ = file.Seek(0, io.SeekStart)
}
return file, info, mimeType, nil
}
func validateAttachmentUploadURL(raw string) error {
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" || parsed.User != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") {
return fmt.Errorf("prepare_attachment_upload returned an invalid uploadUrl")
}
if parsed.Scheme == "http" {
host := parsed.Hostname()
ip := net.ParseIP(host)
if host != "localhost" && (ip == nil || !ip.IsLoopback()) {
return fmt.Errorf("plaintext attachment uploadUrl is allowed only for loopback test servers")
}
}
return nil
}
func readAttachmentCell(rt *shortcut.RuntimeContext, baseID, tableID, recordIDValue, fieldID string) (map[string]any, []map[string]any, error) {
records, err := queryRecordsByIDs(rt, baseID, tableID, []string{recordIDValue})
if err != nil {
return nil, nil, err
}
if len(records) != 1 || recordID(records[0]) != recordIDValue {
return nil, nil, fmt.Errorf("attachment read-back returned %d exact records for %s, want 1", len(records), recordIDValue)
}
cells, ok := records[0]["cells"].(map[string]any)
if !ok {
return nil, nil, fmt.Errorf("record %s is missing cells", recordIDValue)
}
raw, exists := cells[fieldID]
if !exists || raw == nil {
return records[0], []map[string]any{}, nil
}
list, ok := raw.([]any)
if !ok {
return nil, nil, fmt.Errorf("attachment field %s is not an array", fieldID)
}
out := make([]map[string]any, 0, len(list))
for index, item := range list {
object, ok := item.(map[string]any)
if !ok {
return nil, nil, fmt.Errorf("attachment field %s item %d is not an object", fieldID, index)
}
out = append(out, object)
}
return records[0], out, nil
}
func attachmentToken(item map[string]any) string {
return stringValue(item, "fileToken", "file_token")
}
func attachmentName(item map[string]any) string {
return stringValue(item, "fileName", "filename", "name")
}
func verifyAttachmentPut(actual []map[string]any, expectedCount int, token, name string, size int64) error {
if len(actual) != expectedCount {
return fmt.Errorf("attachment read-back count is %d, want %d", len(actual), expectedCount)
}
for _, item := range actual {
if attachmentToken(item) == token {
return nil
}
itemSize, sizeOK := numericInt64(item["size"])
if attachmentName(item) == name && sizeOK && itemSize == size {
return nil
}
}
return fmt.Errorf("attachment read-back does not identify uploaded token or file %s/%d", name, size)
}
func numericInt64(value any) (int64, bool) {
switch typed := value.(type) {
case float64:
return int64(typed), typed == float64(int64(typed))
case int:
return int64(typed), true
case int64:
return typed, true
}
return 0, false
}
@@ -0,0 +1,461 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func writeAttachmentFixture(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "report.txt")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func attachmentRecordJSON(t *testing.T, fieldID string, attachments []any) string {
t.Helper()
return mustJSONText(t, map[string]any{"records": []any{map[string]any{
"recordId": "record", "cells": map[string]any{fieldID: attachments},
}}})
}
func TestCrossPlatformCoverageAttachmentPutPerformsHTTPAndReadBackE2E(t *testing.T) {
filePath := writeAttachmentFixture(t, "actual attachment bytes")
fileName := filepath.Base(filePath)
uploaded := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
body, _ := io.ReadAll(request.Body)
if request.Method != http.MethodPut || request.Header.Get("Content-Type") != "text/plain" || string(body) != "actual attachment bytes" {
t.Errorf("upload request = method:%s type:%s body:%q", request.Method, request.Header.Get("Content-Type"), body)
http.Error(w, "bad upload", http.StatusBadRequest)
return
}
uploaded = true
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: attachmentRecordJSON(t, "field", []any{})},
{text: mustJSONText(t, map[string]any{"uploadUrl": server.URL + "/put", "fileToken": "ft1"})},
{text: ""},
{text: attachmentRecordJSON(t, "field", []any{map[string]any{"filename": fileName, "size": len("actual attachment bytes")}})},
}}
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--mime-type", "text/plain", "--yes")
if err != nil || !uploaded {
t.Fatalf("attachment put = output:%q err:%v uploaded:%v", out, err, uploaded)
}
for _, want := range []string{`"status": "recovered"`, `"fileToken": "ft1"`, `"attachmentCount": 1`, `"status": "verified"`} {
if !strings.Contains(out, want) {
t.Fatalf("attachment output missing %s: %s", want, out)
}
}
if len(caller.calls) != 4 || caller.calls[1].tool != "prepare_attachment_upload" || caller.calls[2].tool != "update_records" {
t.Fatalf("attachment calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageAttachmentPutFailuresDoNotBecomeSuccessE2E(t *testing.T) {
filePath := writeAttachmentFixture(t, "bytes")
t.Run("append stops before upload when existing tokens unavailable", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: attachmentRecordJSON(t, "field", []any{map[string]any{"filename": "old.pdf", "url": "https://download"}})},
}}
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--yes")
if err == nil || out != "" || len(caller.calls) != 1 || !strings.Contains(err.Error(), "fileToken") {
t.Fatalf("unsafe append = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("HTTP failure leaves no record write", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "no", http.StatusBadGateway) }))
defer server.Close()
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: attachmentRecordJSON(t, "field", []any{})},
{text: mustJSONText(t, map[string]any{"uploadUrl": server.URL, "fileToken": "ft1"})},
}}
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes")
if err == nil || out != "" || len(caller.calls) != 2 || !strings.Contains(err.Error(), "partial_success") {
t.Fatalf("HTTP failure = output:%q err:%v calls:%#v", out, err, caller.calls)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Retryable {
t.Fatalf("partially uploaded attachment must not be blindly retryable: %#v", err)
}
})
t.Run("prepare response must carry both values", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: attachmentRecordJSON(t, "field", []any{})}, {text: `{"uploadUrl":"https://example.invalid"}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes")
if err == nil || out != "" || len(caller.calls) != 2 {
t.Fatalf("bad prepare = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
}
func TestCrossPlatformCoverageAttachmentPutRejectsHTTPSRedirectToPlaintextNonLoopbackE2E(t *testing.T) {
filePath := writeAttachmentFixture(t, "redirect-sensitive bytes")
sourceHits := 0
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
sourceHits++
w.Header().Set("Location", "http://example.com/upload")
w.WriteHeader(http.StatusFound)
}))
defer server.Close()
client := server.Client()
client.CheckRedirect = rejectAttachmentUploadRedirect
testseam.Swap(t, &attachmentHTTPDo, client.Do)
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: attachmentRecordJSON(t, "field", []any{})},
{text: mustJSONText(t, map[string]any{"uploadUrl": server.URL + "/put", "fileToken": "ft-redirect"})},
}}
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes")
var typed *apperrors.Error
if err == nil || out != "" || !errors.As(err, &typed) || typed.Reason != "aitable_composite_partial_success" ||
typed.Cause == nil || !strings.Contains(typed.Cause.Error(), "status 302") {
t.Fatalf("redirect upload = output:%q err:%v", out, err)
}
if sourceHits != 1 || len(caller.calls) != 2 {
t.Fatalf("redirect upload reached an unexpected target: sourceHits=%d calls=%#v", sourceHits, caller.calls)
}
}
func TestCrossPlatformCoverageAttachmentRemoveClearAndSelectiveE2E(t *testing.T) {
t.Run("clear empty write response recovered by empty read-back", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: attachmentRecordJSON(t, "field", []any{map[string]any{"filename": "a.pdf", "url": "https://download"}})},
{text: ""},
{text: attachmentRecordJSON(t, "field", []any{})},
}}
out, err := runAITableCompositeCLI(t, caller, "+attachment-remove",
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--clear-all", "--yes")
if err != nil || !strings.Contains(out, `"status": "recovered"`) || !strings.Contains(out, `"removedCount": 1`) {
t.Fatalf("clear attachments = output:%q err:%v", out, err)
}
})
t.Run("selective removal preserves tokenized remainder", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: attachmentRecordJSON(t, "field", []any{
map[string]any{"fileToken": "remove-token", "filename": "remove.pdf"},
map[string]any{"fileToken": "keep-token", "filename": "keep.pdf"},
})},
{text: `{"updatedCount":1}`},
{text: attachmentRecordJSON(t, "field", []any{map[string]any{"filename": "keep.pdf", "size": 10}})},
}}
out, err := runAITableCompositeCLI(t, caller, "+attachment-remove",
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--remove-name", "remove.pdf", "--yes")
if err != nil || !strings.Contains(out, `"removedCount": 1`) {
t.Fatalf("selective remove = output:%q err:%v", out, err)
}
written := caller.calls[1].args["records"].([]any)[0].(map[string]any)["cells"].(map[string]any)["field"].([]any)
if len(written) != 1 || written[0].(map[string]any)["fileToken"] != "keep-token" {
t.Fatalf("selective desired attachments = %#v", written)
}
})
}
func TestCrossPlatformCoverageAttachmentRemoveExplainsTokenBoundaryE2E(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: attachmentRecordJSON(t, "field", []any{
map[string]any{"filename": "remove.pdf", "url": "https://one"},
map[string]any{"filename": "keep.pdf", "url": "https://two"},
})}}}
out, err := runAITableCompositeCLI(t, caller, "+attachment-remove",
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--remove-name", "remove.pdf", "--yes")
if err == nil || out != "" || len(caller.calls) != 1 || !strings.Contains(err.Error(), "fileToken") || !strings.Contains(err.Error(), "增量删除") {
t.Fatalf("token boundary = output:%q err:%v calls:%#v", out, err, caller.calls)
}
}
func TestCrossPlatformCoverageAttachmentPutRemainingOutcomesE2E(t *testing.T) {
filePath := writeAttachmentFixture(t, "bytes")
t.Run("file open failure", func(t *testing.T) {
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+attachment-put",
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filepath.Join(t.TempDir(), "missing"), "--yes")
if err == nil || out != "" {
t.Fatalf("file open = output:%q err:%v", out, err)
}
})
t.Run("initial read failure", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{err: errors.New("read failed")}}}
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--yes")
if err == nil || out != "" || !strings.Contains(err.Error(), "read failed") {
t.Fatalf("initial read = output:%q err:%v", out, err)
}
})
t.Run("append dry run preserves token", func(t *testing.T) {
caller := &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: attachmentRecordJSON(t, "field", []any{
map[string]any{"file_token": "old-token", "filename": "old.txt"},
})}}}
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--dry-run", "--yes")
if err != nil || !strings.Contains(out, `"status": "planned"`) || len(caller.calls) != 1 {
t.Fatalf("append dry run = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("prepare call failure", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: attachmentRecordJSON(t, "field", []any{})},
{err: errors.New("prepare failed")},
}}
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes")
if err == nil || out != "" {
t.Fatalf("prepare failure = output:%q err:%v", out, err)
}
})
t.Run("invalid upload URL", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: attachmentRecordJSON(t, "field", []any{})},
{text: `{"uploadUrl":"http://example.com/upload","fileToken":"ft1"}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes")
if err == nil || out != "" {
t.Fatalf("invalid upload URL = output:%q err:%v", out, err)
}
})
t.Run("HTTP transport failure", func(t *testing.T) {
testseam.Swap(t, &attachmentHTTPDo, func(*http.Request) (*http.Response, error) {
return nil, errors.New("transport failed")
})
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: attachmentRecordJSON(t, "field", []any{})},
{text: `{"uploadUrl":"https://uploads.example.test/put","fileToken":"ft1"}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes")
if err == nil || out != "" {
t.Fatalf("transport failure = output:%q err:%v", out, err)
}
})
t.Run("write and read-back both fail", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }))
defer server.Close()
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: attachmentRecordJSON(t, "field", []any{})},
{text: mustJSONText(t, map[string]any{"uploadUrl": server.URL, "fileToken": "ft1"})},
{err: errors.New("write reply failed")},
{err: errors.New("verification read failed")},
}}
out, err := runAITableCompositeCLI(t, caller, "+attachment-put",
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--file", filePath, "--mode", "replace", "--yes")
if err == nil || out != "" {
t.Fatalf("verification failure = output:%q err:%v", out, err)
}
})
}
func TestCrossPlatformCoverageAttachmentRemoveRemainingOutcomesE2E(t *testing.T) {
run := func(t *testing.T, caller *upsertByKeyCaller, extra ...string) (string, error) {
args := []string{"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field"}
args = append(args, extra...)
args = append(args, "--yes")
return runAITableCompositeCLI(t, caller, "+attachment-remove", args...)
}
t.Run("initial read failure", func(t *testing.T) {
out, err := run(t, &upsertByKeyCaller{steps: []upsertByKeyStep{{err: errors.New("read failed")}}}, "--clear-all")
if err == nil || out != "" || !strings.Contains(err.Error(), "read failed") {
t.Fatalf("initial read = output:%q err:%v", out, err)
}
})
t.Run("unchanged selection", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: attachmentRecordJSON(t, "field", []any{
map[string]any{"fileToken": "keep", "filename": "keep.pdf"},
})}}}
out, err := run(t, caller, "--remove-name", "missing.pdf")
if err != nil || !strings.Contains(out, `"status": "unchanged"`) || len(caller.calls) != 1 {
t.Fatalf("unchanged = output:%q err:%v", out, err)
}
})
t.Run("dry run", func(t *testing.T) {
caller := &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: attachmentRecordJSON(t, "field", []any{
map[string]any{"fileToken": "remove", "filename": "remove.pdf"},
})}}}
out, err := run(t, caller, "--remove-name", "remove.pdf", "--dry-run")
if err != nil || !strings.Contains(out, `"status": "planned"`) || len(caller.calls) != 1 {
t.Fatalf("dry run = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("read-back count mismatch", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: attachmentRecordJSON(t, "field", []any{map[string]any{"fileToken": "remove", "filename": "remove.pdf"}})},
{text: `{"updatedCount":1}`},
{text: attachmentRecordJSON(t, "field", []any{map[string]any{"filename": "other.pdf"}})},
}}
out, err := run(t, caller, "--clear-all")
if err == nil || out != "" {
t.Fatalf("count mismatch = output:%q err:%v", out, err)
}
})
t.Run("removed name remains", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: attachmentRecordJSON(t, "field", []any{
map[string]any{"fileToken": "remove", "filename": "remove.pdf"},
map[string]any{"fileToken": "keep", "filename": "keep.pdf"},
})},
{text: `{"updatedCount":1}`},
{text: attachmentRecordJSON(t, "field", []any{map[string]any{"filename": "remove.pdf"}})},
}}
out, err := run(t, caller, "--remove-name", "remove.pdf")
if err == nil || out != "" {
t.Fatalf("removed item remains = output:%q err:%v", out, err)
}
})
t.Run("write and verification fail", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: attachmentRecordJSON(t, "field", []any{map[string]any{"fileToken": "remove", "filename": "remove.pdf"}})},
{err: errors.New("write reply failed")},
{err: errors.New("verification failed")},
}}
out, err := run(t, caller, "--clear-all")
if err == nil || out != "" {
t.Fatalf("verification failure = output:%q err:%v", out, err)
}
})
}
func TestCrossPlatformCoverageAttachmentParsingAndValidation(t *testing.T) {
t.Run("file validation and MIME sniffing", func(t *testing.T) {
if file, _, _, err := openAttachmentFile(filepath.Join(t.TempDir(), "missing"), ""); err == nil || file != nil {
t.Fatalf("missing file must be rejected: file:%v err:%v", file, err)
}
if file, _, _, err := openAttachmentFile(t.TempDir(), ""); err == nil || file != nil {
t.Fatalf("directory must be rejected: file:%v err:%v", file, err)
}
empty := filepath.Join(t.TempDir(), "empty")
if err := os.WriteFile(empty, nil, 0o600); err != nil {
t.Fatal(err)
}
if file, _, _, err := openAttachmentFile(empty, ""); err == nil || file != nil {
t.Fatalf("empty file must be rejected: file:%v err:%v", file, err)
}
unknown := filepath.Join(t.TempDir(), "payload.unknown-extension")
if err := os.WriteFile(unknown, []byte("plain text"), 0o600); err != nil {
t.Fatal(err)
}
file, _, mimeType, err := openAttachmentFile(unknown, "")
if err != nil {
t.Fatal(err)
}
defer file.Close()
if mimeType == "" {
t.Fatal("sniffed MIME type is empty")
}
})
for name, raw := range map[string]string{
"missing host": "https:///upload",
"credentials": "https://user:pass@example.com/upload",
"wrong scheme": "ftp://example.com/upload",
} {
t.Run(name, func(t *testing.T) {
if err := validateAttachmentUploadURL(raw); err == nil {
t.Fatalf("%q must be rejected", raw)
}
})
}
for _, raw := range []string{"http://localhost/upload", "http://127.0.0.1/upload", "https://example.com/upload"} {
if err := validateAttachmentUploadURL(raw); err != nil {
t.Fatalf("%q = %v", raw, err)
}
}
if err := verifyAttachmentPut(nil, 1, "token", "name", 1); err == nil {
t.Fatal("count mismatch must fail")
}
if err := verifyAttachmentPut([]map[string]any{{"fileToken": "token"}}, 1, "token", "name", 1); err != nil {
t.Fatal(err)
}
if err := verifyAttachmentPut([]map[string]any{{"filename": "other", "size": 1}}, 1, "token", "name", 1); err == nil {
t.Fatal("identity mismatch must fail")
}
for value, want := range map[any]bool{int(1): true, int64(1): true, "1": false} {
_, ok := numericInt64(value)
if ok != want {
t.Fatalf("numericInt64(%T) = %v, want %v", value, ok, want)
}
}
}
func TestCrossPlatformCoverageAttachmentRemoveSelectionValidationE2E(t *testing.T) {
for name, extra := range map[string][]string{
"neither": nil,
"both": {"--remove-name", "a.pdf", "--clear-all"},
} {
t.Run(name, func(t *testing.T) {
args := []string{"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field"}
args = append(args, extra...)
args = append(args, "--yes")
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+attachment-remove", args...)
if err == nil || out != "" {
t.Fatalf("selection validation = output:%q err:%v", out, err)
}
})
}
}
func TestCrossPlatformCoverageAttachmentReadBackShapesE2E(t *testing.T) {
cases := []struct {
name string
step upsertByKeyStep
want string
}{
{name: "query error", step: upsertByKeyStep{err: errors.New("query failed")}, want: "query failed"},
{name: "wrong record", step: upsertByKeyStep{text: `{"records":[{"recordId":"other","cells":{}}]}`}, want: "exact records"},
{name: "missing cells", step: upsertByKeyStep{text: `{"records":[{"recordId":"record"}]}`}, want: "missing cells"},
{name: "non-array field", step: upsertByKeyStep{text: `{"records":[{"recordId":"record","cells":{"field":"bad"}}]}`}, want: "not an array"},
{name: "non-object item", step: upsertByKeyStep{text: `{"records":[{"recordId":"record","cells":{"field":["bad"]}}]}`}, want: "not an object"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{tc.step}}
out, err := runAITableCompositeCLI(t, caller, "+attachment-remove",
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--clear-all", "--yes")
if err == nil || out != "" || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("shape = output:%q err:%v", out, err)
}
})
}
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"records":[{"recordId":"record","cells":{}}]}`}}}
out, err := runAITableCompositeCLI(t, caller, "+attachment-remove",
"--base-id", "base", "--table-id", "table", "--record-id", "record", "--field-id", "field", "--clear-all", "--yes")
if err != nil || !strings.Contains(out, `"status": "unchanged"`) {
t.Fatalf("missing field = output:%q err:%v", out, err)
}
}
+393
View File
@@ -0,0 +1,393 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"fmt"
"strings"
"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 BaseSchemaSnapshot = shortcut.Shortcut{
Service: "aitable",
Command: "+base-schema-snapshot",
Product: serverMain,
Description: "读取 Base、全部数据表、字段和视图的可复用结构快照,并严格校验每层响应",
Intent: "当你要审计、迁移或复制一个 Base 的完整结构但不需要记录数据时使用;明确空 tables/fields/views 合法,缺失容器或缺少请求 ID 则失败。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: aitableCompositeContract(
"+base-schema-snapshot",
"读取 Base、全部数据表、字段和视图的可复用结构快照,并严格校验每层响应",
"当你要审计、迁移或复制一个 Base 的完整结构但不需要记录数据时使用;明确空 tables/fields/views 合法,缺失容器或缺少请求 ID 则失败。",
"需要记录数据用 record query;只看 Base 目录用 base get;快照不会创建或修改资源",
"dws aitable +base-schema-snapshot --base-id BASE_ID",
),
Flags: []shortcut.Flag{{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true}},
Tips: []string{`dws aitable +base-schema-snapshot --base-id BASE_ID`},
Execute: func(rt *shortcut.RuntimeContext) error {
return executeBaseSchemaSnapshot(rt)
},
}
var BaseBootstrap = shortcut.Shortcut{
Service: "aitable",
Command: "+base-bootstrap",
Product: serverMain,
Description: "一次创建 Base、数据表和字段,逐层读回验证并在中断时报告已知副作用",
Intent: "当你已有声明式 tables JSON、想一次搭好一套 AI 表格结构时使用;表内字段自动按 15 个拆批,每次创建都读回验证。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: aitableCompositeContract(
"+base-bootstrap",
"一次创建 Base、数据表和字段,逐层读回验证并在中断时报告已知副作用",
"当你已有声明式 tables JSON、想一次搭好一套 AI 表格结构时使用;表内字段自动按 15 个拆批,每次创建都读回验证。",
"已有 Base 只需新增一张表时用 table create;复制现有 Base 用 base copy;不要对失败请求盲目重试",
`dws aitable +base-bootstrap --name "项目管理" --tables '[{"name":"任务","fields":[]}]'`,
),
Flags: []shortcut.Flag{
{Name: "name", Type: shortcut.FlagString, Desc: "新 Base 名称", Required: true},
{Name: "folder-id", Type: shortcut.FlagString, Desc: "目标知识库文件夹 ID(可选)"},
{Name: "template-id", Type: shortcut.FlagString, Desc: "模板 ID(可选)"},
{Name: "tables", Type: shortcut.FlagString, Desc: `表结构 JSON 数组:[{'name':'任务','fields':[...]}]`, Required: true},
},
Tips: []string{`dws aitable +base-bootstrap --name "项目管理" --tables '[{"name":"任务","fields":[]}]'`},
Execute: func(rt *shortcut.RuntimeContext) error {
return executeBaseBootstrap(rt)
},
}
type bootstrapTable struct {
Name string
Fields []any
}
func parseBootstrapTables(raw string) ([]bootstrapTable, error) {
value, err := parseJSONAny("tables", raw)
if err != nil {
return nil, err
}
items, ok := value.([]any)
if !ok || len(items) == 0 {
return nil, apperrors.NewValidation("--tables 必须是非空 JSON 数组")
}
if len(items) > 100 {
return nil, apperrors.NewValidation("--tables 最多接受 100 张表")
}
seen := map[string]bool{}
out := make([]bootstrapTable, 0, len(items))
for index, item := range items {
object, ok := item.(map[string]any)
if !ok {
return nil, apperrors.NewValidation(fmt.Sprintf("--tables[%d] 必须是 JSON 对象", index))
}
name := strings.TrimSpace(stringValue(object, "name"))
if name == "" || seen[name] {
return nil, apperrors.NewValidation(fmt.Sprintf("--tables[%d].name 必须非空且不能重复", index))
}
seen[name] = true
fields := []any{}
if rawFields, exists := object["fields"]; exists {
var fieldsOK bool
fields, fieldsOK = rawFields.([]any)
if !fieldsOK {
return nil, apperrors.NewValidation(fmt.Sprintf("--tables[%d].fields 必须是 JSON 数组", index))
}
}
for fieldIndex, field := range fields {
object, ok := field.(map[string]any)
if !ok || strings.TrimSpace(stringValue(object, "fieldName", "name")) == "" || strings.TrimSpace(stringValue(object, "type")) == "" {
return nil, apperrors.NewValidation(fmt.Sprintf("--tables[%d].fields[%d] 必须包含 fieldName 和 type", index, fieldIndex))
}
}
out = append(out, bootstrapTable{Name: name, Fields: fields})
}
return out, nil
}
func executeBaseSchemaSnapshot(rt *shortcut.RuntimeContext) error {
baseID := rt.Str("base-id")
base, err := rt.CallMCPData(serverMain, "get_base", map[string]any{"baseId": baseID})
if err != nil {
return err
}
if !deepContainsString(base, baseID) {
return fmt.Errorf("get_base response does not identify requested baseId %s", baseID)
}
tables, found := findNamedObjectList(base, "tables", "tableList", "sheets")
if !found {
return fmt.Errorf("get_base response is missing the tables collection")
}
result := newCompositeResult("base_schema_snapshot")
result.RequestedCount = len(tables)
result.Resolved = map[string]any{"baseId": baseID}
tableSnapshots := make([]any, 0, len(tables))
for index, table := range tables {
tableID := stringValue(table, "tableId", "sheetId", "id")
if tableID == "" {
return fmt.Errorf("get_base tables[%d] is missing tableId", index)
}
detail, err := rt.CallMCPData(serverMain, "get_tables", map[string]any{"baseId": baseID, "tableIds": []string{tableID}})
if err != nil || !deepContainsString(detail, tableID) {
if err == nil {
err = fmt.Errorf("get_tables response does not identify tableId %s", tableID)
}
return err
}
fieldsData, err := rt.CallMCPData(serverMain, "get_fields", map[string]any{"baseId": baseID, "tableId": tableID})
if err != nil {
return err
}
fields, found := findNamedObjectList(fieldsData, "fields", "fieldList")
if !found {
return fmt.Errorf("get_fields response for %s is missing the fields collection", tableID)
}
viewsData, err := rt.CallMCPData(serverMain, "get_views", map[string]any{"baseId": baseID, "tableId": tableID})
if err != nil {
return err
}
views, found := findNamedObjectList(viewsData, "views", "viewList")
if !found {
return fmt.Errorf("get_views response for %s is missing the views collection", tableID)
}
tableSnapshots = append(tableSnapshots, map[string]any{
"tableId": tableID, "summary": table, "detail": detail, "fields": fields, "views": views,
})
result.CompletedCount = index + 1
}
result.Result = map[string]any{"base": base, "tables": tableSnapshots}
result.Verification = map[string]any{"status": "verified", "tableCount": len(tables)}
return rt.Output(result)
}
func executeBaseBootstrap(rt *shortcut.RuntimeContext) error {
tables, err := parseBootstrapTables(rt.Str("tables"))
if err != nil {
return err
}
result := newCompositeResult("base_bootstrap")
result.RequestedCount = len(tables)
result.Plan = append(result.Plan, compositeStep{Index: 1, Name: "create base", Tool: "create_base", Status: "planned"})
for index, table := range tables {
result.Plan = append(result.Plan, compositeStep{Index: index + 2, Name: "create and verify table", Tool: "create_table", Status: "planned", Count: len(table.Fields), Arguments: map[string]any{"name": table.Name}})
}
if rt.DryRun() {
result.Status = "planned"
result.Executed = false
return rt.Output(result)
}
baseArgs := map[string]any{"baseName": rt.Str("name")}
if rt.Changed("folder-id") {
baseArgs["folderId"] = rt.Str("folder-id")
}
if rt.Changed("template-id") {
baseArgs["templateId"] = rt.Str("template-id")
}
baseData, err := rt.CallMCPWriteDataStrict(serverMain, "create_base", baseArgs)
if err != nil {
result.Status = "unknown"
result.FailedCount = len(tables)
result.Checkpoint = map[string]any{"nextStep": "resolve whether base was created before retrying"}
return compositeError(result, err, false)
}
baseID := findStringByKeys(baseData, "baseId")
if baseID == "" {
result.Status = "unknown"
result.Result = baseData
result.Checkpoint = map[string]any{"nextStep": "locate the created Base by exact name before retrying"}
return compositeError(result, fmt.Errorf("create_base response is missing baseId"), false)
}
result.Resolved = map[string]any{"baseId": baseID}
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "create_base", "baseId": baseID})
result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: 1, Name: "create base", Tool: "create_base", Status: "completed", Result: baseData})
baseRead, err := rt.CallMCPData(serverMain, "get_base", map[string]any{"baseId": baseID})
if err != nil || !deepContainsString(baseRead, baseID) {
if err == nil {
err = fmt.Errorf("get_base does not identify created baseId %s", baseID)
}
result.Status = "partial_success"
result.Checkpoint = map[string]any{"nextTableIndex": 0, "baseId": baseID}
return compositeError(result, err, false)
}
createdTables := make([]any, 0, len(tables))
for index, spec := range tables {
initialEnd := minInt(15, len(spec.Fields))
initialFields := spec.Fields[:initialEnd]
createData, createErr := rt.CallMCPWriteDataStrict(serverMain, "create_table", map[string]any{
"baseId": baseID, "tableName": spec.Name, "fields": initialFields,
})
tableID := findStringByKeys(createData, "tableId", "sheetId")
if createErr != nil || tableID == "" {
if createErr == nil {
createErr = fmt.Errorf("create_table response for %q is missing tableId", spec.Name)
}
result.Status = "partial_success"
result.CompletedCount = index
result.FailedCount = len(tables) - index
result.Checkpoint = map[string]any{"nextTableIndex": index, "baseId": baseID}
return compositeError(result, createErr, false)
}
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "create_table", "baseId": baseID, "tableId": tableID, "name": spec.Name})
for offset := initialEnd; offset < len(spec.Fields); offset += 15 {
end := minInt(offset+15, len(spec.Fields))
_, fieldErr := rt.CallMCPWriteDataStrict(serverMain, "create_fields", map[string]any{
"baseId": baseID, "tableId": tableID, "fields": spec.Fields[offset:end],
})
if fieldErr != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("create_fields offset %d returned an error; checking final field state: %v", offset, fieldErr))
}
}
detail, verifyErr := rt.CallMCPData(serverMain, "get_tables", map[string]any{"baseId": baseID, "tableIds": []string{tableID}})
if verifyErr != nil || !deepContainsString(detail, tableID) {
if verifyErr == nil {
verifyErr = fmt.Errorf("get_tables does not identify created tableId %s", tableID)
}
result.Status = "partial_success"
result.CompletedCount = index
result.FailedCount = len(tables) - index
result.Checkpoint = map[string]any{"nextTableIndex": index, "baseId": baseID, "tableId": tableID, "step": "verify table and fields"}
return compositeError(result, verifyErr, false)
}
fieldsData, verifyErr := rt.CallMCPData(serverMain, "get_fields", map[string]any{"baseId": baseID, "tableId": tableID})
fields, found := findNamedObjectList(fieldsData, "fields", "fieldList")
if verifyErr != nil || !found || !containsAllFieldNames(fields, spec.Fields) {
if verifyErr == nil {
verifyErr = fmt.Errorf("field read-back for table %s does not contain the declared field set", tableID)
}
result.Status = "partial_success"
result.CompletedCount = index
result.FailedCount = len(tables) - index
result.Checkpoint = map[string]any{"nextTableIndex": index, "baseId": baseID, "tableId": tableID, "step": "repair fields"}
return compositeError(result, verifyErr, false)
}
result.CompletedCount = index + 1
result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: index + 2, Name: "create and verify table", Tool: "create_table", Status: "completed", Count: len(spec.Fields), Result: map[string]any{"tableId": tableID, "fieldCount": len(fields)}})
createdTables = append(createdTables, map[string]any{"tableId": tableID, "name": spec.Name, "fieldCount": len(fields)})
}
result.Verification = map[string]any{"status": "verified", "baseId": baseID, "tableCount": len(createdTables)}
result.Result = map[string]any{"baseId": baseID, "tables": createdTables}
return rt.Output(result)
}
func findNamedObjectList(value any, names ...string) ([]map[string]any, bool) {
nameSet := map[string]bool{}
for _, name := range names {
nameSet[strings.ToLower(name)] = true
}
var walk func(any) ([]map[string]any, bool)
walk = func(current any) ([]map[string]any, bool) {
object, ok := current.(map[string]any)
if !ok {
return nil, false
}
for key, child := range object {
if nameSet[strings.ToLower(key)] {
items, ok := child.([]any)
if !ok {
return nil, false
}
out := make([]map[string]any, 0, len(items))
for _, item := range items {
entry, ok := item.(map[string]any)
if !ok {
return nil, false
}
out = append(out, entry)
}
return out, true
}
}
for _, child := range object {
if found, ok := walk(child); ok {
return found, true
}
}
return nil, false
}
return walk(value)
}
func stringValue(object map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := object[key].(string); ok {
return value
}
}
return ""
}
func findStringByKeys(value any, keys ...string) string {
keySet := map[string]bool{}
for _, key := range keys {
keySet[strings.ToLower(key)] = true
}
var walk func(any) string
walk = func(current any) string {
switch typed := current.(type) {
case map[string]any:
for key, child := range typed {
if keySet[strings.ToLower(key)] {
if text, ok := child.(string); ok && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
}
for _, child := range typed {
if found := walk(child); found != "" {
return found
}
}
case []any:
for _, child := range typed {
if found := walk(child); found != "" {
return found
}
}
}
return ""
}
return walk(value)
}
func deepContainsString(value any, expected string) bool {
switch typed := value.(type) {
case map[string]any:
for _, child := range typed {
if deepContainsString(child, expected) {
return true
}
}
case []any:
for _, child := range typed {
if deepContainsString(child, expected) {
return true
}
}
case string:
return typed == expected
}
return false
}
func containsAllFieldNames(actual []map[string]any, expected []any) bool {
names := map[string]bool{}
for _, field := range actual {
names[stringValue(field, "fieldName", "name")] = true
}
for _, raw := range expected {
field, _ := raw.(map[string]any)
if !names[stringValue(field, "fieldName", "name")] {
return false
}
}
return true
}
@@ -0,0 +1,311 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
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/spf13/cobra"
)
func runAITableCompositeCLI(t *testing.T, caller *upsertByKeyCaller, command string, args ...string) (string, error) {
t.Helper()
helpers.InitDepsForTest(t, caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
root.AddCommand(shortcut.Commands()...)
stdout := &bytes.Buffer{}
root.SetOut(stdout)
root.SetErr(&bytes.Buffer{})
root.SetArgs(append([]string{"aitable", command}, args...))
err := root.Execute()
return stdout.String(), err
}
func TestCrossPlatformCoverageBaseSchemaSnapshotE2E(t *testing.T) {
t.Run("one table with legal empty fields and views", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"data":{"baseId":"base","tables":[{"tableId":"t1","name":"任务"}]}}`},
{text: `{"data":{"tables":[{"tableId":"t1","name":"任务"}]}}`},
{text: `{"data":{"fields":[]}}`},
{text: `{"data":{"views":[]}}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+base-schema-snapshot", "--base-id", "base")
if err != nil {
t.Fatalf("snapshot error = %v", err)
}
for _, want := range []string{`"status": "success"`, `"tableCount": 1`, `"fields": []`, `"views": []`} {
if !strings.Contains(out, want) {
t.Fatalf("snapshot output missing %s: %s", want, out)
}
}
})
t.Run("empty tables is explicit success", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"baseId":"base","tables":[]}`}}}
out, err := runAITableCompositeCLI(t, caller, "+base-schema-snapshot", "--base-id", "base")
if err != nil || !strings.Contains(out, `"tableCount": 0`) || len(caller.calls) != 1 {
t.Fatalf("empty snapshot = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("missing tables contract fails", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"baseId":"base","data":{}}`}}}
out, err := runAITableCompositeCLI(t, caller, "+base-schema-snapshot", "--base-id", "base")
if err == nil || out != "" {
t.Fatalf("missing tables = output:%q err:%v", out, err)
}
})
}
func bootstrapFields(count int) []any {
fields := make([]any, 0, count)
for index := 0; index < count; index++ {
fields = append(fields, map[string]any{"fieldName": fmt.Sprintf("F%02d", index), "type": "text"})
}
return fields
}
func marshalBootstrapTables(t *testing.T, fields []any) string {
t.Helper()
if fields == nil {
fields = []any{}
}
raw, err := json.Marshal([]any{map[string]any{"name": "任务", "fields": fields}})
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func fieldReadBackJSON(t *testing.T, fields []any) string {
t.Helper()
raw, err := json.Marshal(map[string]any{"fields": fields})
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func TestCrossPlatformCoverageBaseBootstrapCreatesChunksAndVerifiesE2E(t *testing.T) {
fields := bootstrapFields(16)
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"data":{"baseId":"b-new"}}`},
{text: `{"baseId":"b-new","tables":[]}`},
{text: `{"data":{"tableId":"t-new"}}`},
{text: `{"createdFields":[{"fieldId":"f16"}]}`},
{text: `{"tables":[{"tableId":"t-new","name":"任务"}]}`},
{text: fieldReadBackJSON(t, fields)},
}}
out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap", "--name", "项目", "--tables", marshalBootstrapTables(t, fields), "--yes")
if err != nil {
t.Fatalf("bootstrap error = %v", err)
}
for _, want := range []string{`"baseId": "b-new"`, `"tableId": "t-new"`, `"status": "verified"`} {
if !strings.Contains(out, want) {
t.Fatalf("bootstrap output missing %s: %s", want, out)
}
}
if len(caller.calls) != 6 || caller.calls[0].tool != "create_base" || caller.calls[2].tool != "create_table" || caller.calls[3].tool != "create_fields" {
t.Fatalf("bootstrap calls = %#v", caller.calls)
}
if caller.calls[0].args["baseName"] != "项目" {
t.Fatalf("create_base args = %#v", caller.calls[0].args)
}
createBaseCalls := 0
for _, call := range caller.calls {
if call.tool == "create_base" {
createBaseCalls++
}
}
if createBaseCalls != 1 {
t.Fatalf("create_base calls = %d, want exactly 1; calls = %#v", createBaseCalls, caller.calls)
}
if got := len(caller.calls[2].args["fields"].([]any)); got != 15 {
t.Fatalf("create_table fields = %d, want 15", got)
}
if got := len(caller.calls[3].args["fields"].([]any)); got != 1 {
t.Fatalf("create_fields fields = %d, want 1", got)
}
}
func TestCrossPlatformCoverageBaseBootstrapRequiresConfirmationBeforeMCP(t *testing.T) {
caller := &upsertByKeyCaller{}
out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap", "--name", "项目", "--tables", marshalBootstrapTables(t, nil))
if out != "" {
t.Fatalf("unconfirmed bootstrap output = %q, want empty", out)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "confirmation_required" {
t.Fatalf("unconfirmed bootstrap error = %#v, want confirmation_required", err)
}
if len(caller.calls) != 0 {
t.Fatalf("unconfirmed bootstrap reached MCP: %#v", caller.calls)
}
}
func TestCrossPlatformCoverageBaseBootstrapUnknownAndDryRunE2E(t *testing.T) {
t.Run("empty create response is unknown and not retry-safe", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: ""}}}
out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap", "--name", "项目", "--tables", marshalBootstrapTables(t, nil), "--yes")
if err == nil || out != "" {
t.Fatalf("unknown base create = output:%q err:%v", out, err)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" || typed.Retryable {
t.Fatalf("unknown base error = %#v", err)
}
})
t.Run("dry run validates and makes no calls", func(t *testing.T) {
caller := &upsertByKeyCaller{dryRun: true}
out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap", "--name", "项目", "--tables", marshalBootstrapTables(t, nil), "--dry-run")
if err != nil || len(caller.calls) != 0 || !strings.Contains(out, `"status": "planned"`) {
t.Fatalf("bootstrap dry run = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
}
func TestCrossPlatformCoverageBaseBootstrapInputValidation(t *testing.T) {
tooMany := make([]any, 101)
for index := range tooMany {
tooMany[index] = map[string]any{"name": fmt.Sprintf("T%d", index)}
}
tooManyJSON, err := json.Marshal(tooMany)
if err != nil {
t.Fatal(err)
}
cases := map[string]string{
"invalid JSON": `{`,
"not an array": `{}`,
"empty array": `[]`,
"too many tables": string(tooManyJSON),
"non-object table": `[1]`,
"empty name": `[{"name":" "}]`,
"duplicate name": `[{"name":"T"},{"name":"T"}]`,
"fields not array": `[{"name":"T","fields":{}}]`,
"field not object": `[{"name":"T","fields":[1]}]`,
"field missing type": `[{"name":"T","fields":[{"fieldName":"F"}]}]`,
}
for name, raw := range cases {
t.Run(name, func(t *testing.T) {
if tables, err := parseBootstrapTables(raw); err == nil || tables != nil {
t.Fatalf("parseBootstrapTables(%s) = %#v, %v", raw, tables, err)
}
})
}
}
func TestCrossPlatformCoverageBaseSnapshotFailureStagesE2E(t *testing.T) {
base := `{"baseId":"base","tables":[{"tableId":"t1","name":"T"}]}`
cases := []struct {
name string
steps []upsertByKeyStep
}{
{name: "get base error", steps: []upsertByKeyStep{{err: errors.New("get base failed")}}},
{name: "get base wrong id", steps: []upsertByKeyStep{{text: `{"baseId":"other","tables":[]}`}}},
{name: "table missing id", steps: []upsertByKeyStep{{text: `{"baseId":"base","tables":[{"name":"T"}]}`}}},
{name: "get tables error", steps: []upsertByKeyStep{{text: base}, {err: errors.New("get tables failed")}}},
{name: "get tables wrong id", steps: []upsertByKeyStep{{text: base}, {text: `{"tables":[{"tableId":"other"}]}`}}},
{name: "get fields error", steps: []upsertByKeyStep{{text: base}, {text: `{"tables":[{"tableId":"t1"}]}`}, {err: errors.New("get fields failed")}}},
{name: "get fields missing collection", steps: []upsertByKeyStep{{text: base}, {text: `{"tables":[{"tableId":"t1"}]}`}, {text: `{}`}}},
{name: "get views error", steps: []upsertByKeyStep{{text: base}, {text: `{"tables":[{"tableId":"t1"}]}`}, {text: `{"fields":[]}`}, {err: errors.New("get views failed")}}},
{name: "get views missing collection", steps: []upsertByKeyStep{{text: base}, {text: `{"tables":[{"tableId":"t1"}]}`}, {text: `{"fields":[]}`}, {text: `{}`}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+base-schema-snapshot", "--base-id", "base")
if err == nil || out != "" {
t.Fatalf("snapshot failure = output:%q err:%v", out, err)
}
})
}
}
func TestCrossPlatformCoverageBaseBootstrapExecuteRejectsInvalidTablesE2E(t *testing.T) {
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+base-bootstrap", "--name", "Project", "--tables", `{`, "--yes")
if err == nil || out != "" {
t.Fatalf("invalid bootstrap tables = output:%q err:%v", out, err)
}
}
func TestCrossPlatformCoverageBaseBootstrapFailureStagesE2E(t *testing.T) {
tables := marshalBootstrapTables(t, nil)
cases := []struct {
name string
steps []upsertByKeyStep
extra []string
withField bool
}{
{name: "missing base id", steps: []upsertByKeyStep{{text: `{}`}}},
{name: "base verification error", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {err: errors.New("verify base failed")}}},
{name: "base verification wrong id", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"other"}`}}},
{name: "create table error", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {err: errors.New("create table failed")}}},
{name: "create table missing id", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {text: `{}`}}},
{name: "verify table error", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {text: `{"tableId":"t"}`}, {err: errors.New("verify table failed")}}},
{name: "verify table wrong id", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {text: `{"tableId":"t"}`}, {text: `{"tables":[]}`}}},
{name: "verify fields error", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {text: `{"tableId":"t"}`}, {text: `{"tables":[{"tableId":"t"}]}`}, {err: errors.New("verify fields failed")}}},
{name: "verify fields mismatch", steps: []upsertByKeyStep{{text: `{"baseId":"b"}`}, {text: `{"baseId":"b"}`}, {text: `{"tableId":"t"}`}, {text: `{"tables":[{"tableId":"t"}]}`}, {text: `{"fields":[]}`}}, extra: []string{"--folder-id", "folder", "--template-id", "template"}, withField: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
inputTables := tables
if tc.withField {
inputTables = marshalBootstrapTables(t, bootstrapFields(1))
}
args := []string{"--name", "Project", "--tables", inputTables}
args = append(args, tc.extra...)
args = append(args, "--yes")
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+base-bootstrap", args...)
if err == nil || out != "" {
t.Fatalf("bootstrap failure = output:%q err:%v", out, err)
}
})
}
}
func TestCrossPlatformCoverageBaseBootstrapRecoversFieldCallErrorE2E(t *testing.T) {
fields := bootstrapFields(16)
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"baseId":"b"}`},
{text: `{"baseId":"b"}`},
{text: `{"tableId":"t"}`},
{err: errors.New("create fields reply failed")},
{text: `{"tables":[{"tableId":"t"}]}`},
{text: fieldReadBackJSON(t, fields)},
}}
out, err := runAITableCompositeCLI(t, caller, "+base-bootstrap",
"--name", "Project", "--tables", marshalBootstrapTables(t, fields), "--folder-id", "folder", "--template-id", "template", "--yes")
if err != nil || !strings.Contains(out, `"status": "success"`) || !strings.Contains(out, "create_fields offset") {
t.Fatalf("field recovery = output:%q err:%v", out, err)
}
if caller.calls[0].args["folderId"] != "folder" || caller.calls[0].args["templateId"] != "template" {
t.Fatalf("create base args = %#v", caller.calls[0].args)
}
}
func TestCrossPlatformCoverageBaseCompositeShapeHelpers(t *testing.T) {
if _, ok := findNamedObjectList(map[string]any{"tables": "bad"}, "tables"); ok {
t.Fatal("non-array named child must fail")
}
if _, ok := findNamedObjectList(map[string]any{"tables": []any{"bad"}}, "tables"); ok {
t.Fatal("non-object list item must fail")
}
if containsAllFieldNames([]map[string]any{{"fieldName": "A"}}, []any{map[string]any{"fieldName": "B"}}) {
t.Fatal("missing field name must fail")
}
if got := findStringByKeys(map[string]any{"items": []any{map[string]any{"nested": " value "}}}, "nested"); got != "value" {
t.Fatalf("nested array string = %q", got)
}
}
@@ -0,0 +1,38 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func aitableCompositeContract(command, description, useWhen, avoidWhen, example string) corecmd.ContractDecl {
name := strings.TrimPrefix(command, "+")
name = strings.ReplaceAll(name, "-", "_")
cliPath := "aitable " + command
return corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "shortcut_" + name,
CanonicalPath: "aitable.shortcut_" + name,
CLIPath: cliPath,
PrimaryCLIPath: cliPath,
},
Description: description,
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "DWS reviewed composite orchestration with explicit read-back success predicates, partial-effect reporting, and resumable checkpoints; no single MCP operation represents the full command contract.",
},
Selection: contract.SelectionSpec{
AgentSummary: description,
UseWhen: []string{useWhen},
AvoidWhen: []string{avoidWhen},
Examples: []string{example},
},
}
}
@@ -0,0 +1,71 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"fmt"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
const aitableCompositeContractVersion = "aitable.composite.v1"
type compositeResult struct {
ContractVersion string `json:"contractVersion"`
Operation string `json:"operation"`
Status string `json:"status"`
Executed bool `json:"executed"`
Retryable bool `json:"retryable"`
RequestedCount int `json:"requestedCount,omitempty"`
CompletedCount int `json:"completedCount,omitempty"`
FailedCount int `json:"failedCount,omitempty"`
Resolved map[string]any `json:"resolved,omitempty"`
Plan []compositeStep `json:"plan,omitempty"`
CompletedSteps []compositeStep `json:"completedSteps,omitempty"`
Verification map[string]any `json:"verification,omitempty"`
Checkpoint map[string]any `json:"checkpoint,omitempty"`
NextCommand string `json:"nextCommand,omitempty"`
KnownEffects []map[string]any `json:"knownSideEffects,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Result map[string]any `json:"result,omitempty"`
}
type compositeStep struct {
Index int `json:"index"`
Name string `json:"name"`
Tool string `json:"tool,omitempty"`
Status string `json:"status"`
Offset int `json:"offset,omitempty"`
Count int `json:"count,omitempty"`
Arguments map[string]any `json:"arguments,omitempty"`
Result map[string]any `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
func newCompositeResult(operation string) compositeResult {
return compositeResult{
ContractVersion: aitableCompositeContractVersion,
Operation: operation,
Status: "success",
Executed: true,
}
}
func compositeError(result compositeResult, cause error, retryable bool) error {
result.Retryable = retryable
if result.Status == "success" {
result.Status = "unknown"
}
return apperrors.NewAPI(fmt.Sprintf("AI Table composite %s ended with status %s", result.Operation, result.Status),
apperrors.WithOperation("aitable."+result.Operation),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("composite_execution"),
apperrors.WithExecutionStarted(result.Executed),
apperrors.WithRetryable(retryable),
apperrors.WithReason("aitable_composite_"+result.Status),
apperrors.WithHint("inspect error.details.result before retrying; unknown means the remote effect could not be proven"),
apperrors.WithDetails(map[string]any{"result": result}),
apperrors.WithCause(cause),
)
}
@@ -0,0 +1,92 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"strings"
"testing"
)
func TestCrossPlatformCoverageAITableListProjectionExplicitEmptyIsSuccessE2E(t *testing.T) {
tests := []struct {
name string
command string
args []string
payload string
}{
{name: "base list", command: "+base-list", payload: `{"bases":[]}`},
{name: "base search", command: "+base-search", args: []string{"--query", "none"}, payload: `{"data":{"bases":[]}}`},
{name: "template search", command: "+template-search", payload: `{"data":{"templates":[]}}`},
{name: "view get", command: "+view-get", args: []string{"--base-id", "base", "--table-id", "table"}, payload: `{"views":[]}`},
{name: "form list", command: "+form-list", args: []string{"--base-id", "base", "--table-id", "table"}, payload: `{"data":{"formViews":[]}}`},
{name: "workflow list", command: "+workflow-list", args: []string{"--base-id", "base"}, payload: `{"data":{"list":[]}}`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: test.payload}}}
out, err := runAITableCompositeCLI(t, caller, test.command, test.args...)
if err != nil || !strings.Contains(out, `"count": 0`) || len(caller.calls) != 1 {
t.Fatalf("explicit empty list = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
}
}
func TestCrossPlatformCoverageAITableListProjectionUnknownIsNotEmptySuccessE2E(t *testing.T) {
commands := []struct {
name string
command string
args []string
}{
{name: "base list", command: "+base-list"},
{name: "base search", command: "+base-search", args: []string{"--query", "none"}},
{name: "template search", command: "+template-search"},
{name: "view get", command: "+view-get", args: []string{"--base-id", "base", "--table-id", "table"}},
{name: "form list", command: "+form-list", args: []string{"--base-id", "base", "--table-id", "table"}},
{name: "workflow list", command: "+workflow-list", args: []string{"--base-id", "base"}},
}
payloads := []struct {
name string
text string
}{
{name: "empty response", text: ""},
{name: "missing collection", text: `{"success":true}`},
{name: "wrong collection type", text: `{"data":{"list":{}}}`},
}
for _, command := range commands {
for _, payload := range payloads {
t.Run(command.name+"/"+payload.name, func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: payload.text}}}
out, err := runAITableCompositeCLI(t, caller, command.command, command.args...)
if err == nil || out != "" || len(caller.calls) != 1 {
t.Fatalf("unknown list result was accepted = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
}
}
}
func TestCrossPlatformCoverageAITableListProjectionMalformedItemIsNotSilentlyDroppedE2E(t *testing.T) {
tests := []struct {
name string
command string
args []string
payload string
}{
{name: "base without id", command: "+base-list", payload: `{"bases":[{"baseName":"orphan"}]}`},
{name: "template scalar", command: "+template-search", payload: `{"templates":["bad"]}`},
{name: "view without id", command: "+view-get", args: []string{"--base-id", "base", "--table-id", "table"}, payload: `{"views":[{"viewName":"orphan"}]}`},
{name: "form without id", command: "+form-list", args: []string{"--base-id", "base", "--table-id", "table"}, payload: `{"forms":[{"viewName":"orphan"}]}`},
{name: "workflow without id", command: "+workflow-list", args: []string{"--base-id", "base"}, payload: `{"workflows":[{"name":"orphan"}]}`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: test.payload}}}
out, err := runAITableCompositeCLI(t, caller, test.command, test.args...)
if err == nil || out != "" || len(caller.calls) != 1 {
t.Fatalf("malformed list item was accepted = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
}
}
+406
View File
@@ -0,0 +1,406 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"fmt"
"sort"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const (
recordBatchSize = 100
maxCompositeRecordRun = 10000
)
func parseRecordObjects(raw string, requireRecordID bool) ([]map[string]any, error) {
value, err := parseJSONAny("records", raw)
if err != nil {
return nil, err
}
list, ok := value.([]any)
if !ok || len(list) == 0 {
return nil, apperrors.NewValidation("--records 必须是非空 JSON 数组")
}
if len(list) > maxCompositeRecordRun {
return nil, apperrors.NewValidation(fmt.Sprintf("--records 最多接受 %d 条,got %d", maxCompositeRecordRun, len(list)))
}
out := make([]map[string]any, 0, len(list))
for index, item := range list {
record, ok := item.(map[string]any)
if !ok {
return nil, apperrors.NewValidation(fmt.Sprintf("--records[%d] 必须是 JSON 对象", index))
}
cells, ok := record["cells"].(map[string]any)
if !ok || len(cells) == 0 {
return nil, apperrors.NewValidation(fmt.Sprintf("--records[%d].cells 必须是非空 JSON 对象", index))
}
if requireRecordID && recordID(record) == "" {
return nil, apperrors.NewValidation(fmt.Sprintf("--records[%d] 缺少 recordId", index))
}
out = append(out, record)
}
return out, nil
}
func executeRecordUpdateBatches(rt *shortcut.RuntimeContext) error {
records, err := parseRecordObjects(rt.Str("records"), true)
if err != nil {
return err
}
return executeRecordBatches(rt, "record_update", "update_records", serverMain, records, verifyUpdateBatch)
}
func executeRecordUpsertBatches(rt *shortcut.RuntimeContext) error {
records, err := parseRecordObjects(rt.Str("records"), false)
if err != nil {
return err
}
return executeRecordBatches(rt, "record_upsert", "record_upsert", serverHelper, records, verifyUpsertBatch)
}
func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error {
ids, err := parseRecordIDs(rt.StrSlice("record-ids"))
if err != nil {
return err
}
baseID, tableID := rt.Str("base-id"), rt.Str("table-id")
result := newCompositeResult("record_delete")
result.RequestedCount = len(ids)
for offset := 0; offset < len(ids); offset += recordBatchSize {
end := minInt(offset+recordBatchSize, len(ids))
result.Plan = append(result.Plan, compositeStep{
Index: len(result.Plan) + 1, Name: "delete record batch", Tool: "delete_records",
Status: "planned", Offset: offset, Count: end - offset,
})
}
if rt.DryRun() {
result.Status = "planned"
result.Executed = false
return rt.Output(result)
}
for offset := 0; offset < len(ids); offset += recordBatchSize {
end := minInt(offset+recordBatchSize, len(ids))
batch := ids[offset:end]
writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, "delete_records", map[string]any{
"baseId": baseID, "tableId": tableID, "recordIds": batch,
})
step := compositeStep{
Index: len(result.CompletedSteps) + 1, Name: "delete record batch", Tool: "delete_records",
Status: "completed", Offset: offset, Count: len(batch), Result: writeData,
}
if writeErr != nil {
step.Status = "unknown"
step.Error = writeErr.Error()
}
remaining, verifyErr := queryRecordsByIDs(rt, baseID, tableID, batch)
if verifyErr == nil && len(remaining) > 0 {
verifyErr = fmt.Errorf("read-back still contains deleted record IDs: %s", strings.Join(recordIDs(remaining), ","))
}
if verifyErr != nil {
if step.Error == "" {
step.Error = verifyErr.Error()
}
step.Status = "unknown"
result.CompletedSteps = append(result.CompletedSteps, step)
result.CompletedCount = offset
result.FailedCount = len(ids) - offset
result.Status = "unknown"
if offset > 0 {
result.Status = "partial_success"
}
result.Verification = map[string]any{"status": "failed", "error": verifyErr.Error(), "batchOffset": offset}
result.Checkpoint = map[string]any{"nextOffset": offset, "batchSize": recordBatchSize}
if writeErr != nil {
result.Warnings = append(result.Warnings, "write response error: "+writeErr.Error())
}
return compositeError(result, verifyErr, true)
}
if writeErr != nil {
step.Status = "recovered"
result.Warnings = append(result.Warnings, fmt.Sprintf("batch at offset %d had a write-response error but deletion was proven by read-back", offset))
}
step.Result = map[string]any{"status": "verified_absent", "recordIds": batch}
result.CompletedSteps = append(result.CompletedSteps, step)
result.CompletedCount = end
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "delete_records", "offset": offset, "recordIds": batch})
}
result.Verification = map[string]any{"status": "verified_absent", "verifiedCount": len(ids)}
result.Result = map[string]any{"deletedCount": len(ids), "batchCount": len(result.CompletedSteps)}
return rt.Output(result)
}
func parseRecordIDs(values []string) ([]string, error) {
seen := map[string]bool{}
ids := make([]string, 0, len(values))
for _, value := range values {
id := strings.TrimSpace(value)
if id == "" {
continue
}
if !seen[id] {
seen[id] = true
ids = append(ids, id)
}
}
if len(ids) == 0 {
return nil, apperrors.NewValidation("--record-ids 必须至少包含一个非空 recordId")
}
if len(ids) > maxCompositeRecordRun {
return nil, apperrors.NewValidation(fmt.Sprintf("--record-ids 最多接受 %d 个唯一 ID,got %d", maxCompositeRecordRun, len(ids)))
}
// Stable ordering makes checkpoints and retries deterministic even when the
// flag parser or a caller supplied IDs through more than one occurrence.
sort.Strings(ids)
return ids, nil
}
type recordBatchVerifier func(*shortcut.RuntimeContext, string, string, []map[string]any, map[string]any) (map[string]any, error)
func executeRecordBatches(
rt *shortcut.RuntimeContext,
operation, tool, product string,
records []map[string]any,
verify recordBatchVerifier,
) error {
baseID, tableID := rt.Str("base-id"), rt.Str("table-id")
result := newCompositeResult(operation)
result.RequestedCount = len(records)
for offset := 0; offset < len(records); offset += recordBatchSize {
end := minInt(offset+recordBatchSize, len(records))
result.Plan = append(result.Plan, compositeStep{
Index: len(result.Plan) + 1, Name: "write record batch", Tool: tool,
Status: "planned", Offset: offset, Count: end - offset,
})
}
if rt.DryRun() {
result.Status = "planned"
result.Executed = false
return rt.Output(result)
}
for offset := 0; offset < len(records); offset += recordBatchSize {
end := minInt(offset+recordBatchSize, len(records))
batch := records[offset:end]
wireRecords := make([]any, 0, len(batch))
for _, record := range batch {
wireRecords = append(wireRecords, record)
}
params := map[string]any{"baseId": baseID, "tableId": tableID, "records": wireRecords}
writeData, writeErr := rt.CallMCPWriteDataStrict(product, tool, params)
step := compositeStep{
Index: len(result.CompletedSteps) + 1, Name: "write record batch", Tool: tool,
Status: "completed", Offset: offset, Count: len(batch), Result: writeData,
}
if writeErr != nil {
step.Status = "unknown"
step.Error = writeErr.Error()
}
verification, verifyErr := verify(rt, baseID, tableID, batch, writeData)
if verifyErr != nil {
step.Status = "unknown"
if step.Error == "" {
step.Error = verifyErr.Error()
}
result.CompletedSteps = append(result.CompletedSteps, step)
result.CompletedCount = offset
result.FailedCount = len(records) - offset
result.Status = "unknown"
if offset > 0 {
result.Status = "partial_success"
}
result.Verification = map[string]any{"status": "failed", "error": verifyErr.Error(), "batchOffset": offset}
result.Checkpoint = map[string]any{"nextOffset": offset, "batchSize": recordBatchSize}
if writeErr != nil {
result.Warnings = append(result.Warnings, "write response error: "+writeErr.Error())
}
retryable := true
if tool == "record_upsert" {
for _, record := range batch {
if recordID(record) == "" {
retryable = false
break
}
}
}
return compositeError(result, verifyErr, retryable)
}
if writeErr != nil {
step.Status = "recovered"
result.Warnings = append(result.Warnings, fmt.Sprintf("batch at offset %d had a write-response error but its final state was proven by read-back", offset))
}
step.Result = verification
result.CompletedSteps = append(result.CompletedSteps, step)
result.CompletedCount = end
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": tool, "offset": offset, "count": len(batch)})
}
result.Verification = map[string]any{"status": "verified", "verifiedCount": len(records)}
result.Result = map[string]any{"processedCount": len(records), "batchCount": len(result.CompletedSteps)}
return rt.Output(result)
}
func verifyUpdateBatch(rt *shortcut.RuntimeContext, baseID, tableID string, batch []map[string]any, _ map[string]any) (map[string]any, error) {
ids := recordIDs(batch)
actual, err := queryRecordsByIDs(rt, baseID, tableID, ids)
if err != nil {
return nil, err
}
byID := make(map[string]map[string]any, len(actual))
for _, record := range actual {
byID[recordID(record)] = record
}
for _, expected := range batch {
id := recordID(expected)
got := byID[id]
if got == nil {
return nil, fmt.Errorf("read-back is missing updated record %s", id)
}
if err := verifyRecordCells(got, expected["cells"].(map[string]any)); err != nil {
return nil, err
}
}
return map[string]any{"status": "verified", "recordIds": ids}, nil
}
func verifyUpsertBatch(rt *shortcut.RuntimeContext, baseID, tableID string, batch []map[string]any, writeData map[string]any) (map[string]any, error) {
updates := make([]map[string]any, 0)
creates := make([]map[string]any, 0)
for _, record := range batch {
if recordID(record) == "" {
creates = append(creates, record)
} else {
updates = append(updates, record)
}
}
verifiedIDs := make([]string, 0, len(batch))
if len(updates) > 0 {
verified, err := verifyUpdateBatch(rt, baseID, tableID, updates, writeData)
if err != nil {
return nil, err
}
verifiedIDs = append(verifiedIDs, stringSlice(verified["recordIds"])...)
}
if len(creates) > 0 {
createdIDs := createdRecordIDs(writeData)
if len(createdIDs) != len(creates) {
return nil, fmt.Errorf("record_upsert returned %d created record IDs for %d creates", len(createdIDs), len(creates))
}
actual, err := queryRecordsByIDs(rt, baseID, tableID, createdIDs)
if err != nil {
return nil, err
}
if err := matchCreatedCells(creates, actual); err != nil {
return nil, err
}
verifiedIDs = append(verifiedIDs, createdIDs...)
}
return map[string]any{"status": "verified", "recordIds": verifiedIDs}, nil
}
func queryRecordsByIDs(rt *shortcut.RuntimeContext, baseID, tableID string, ids []string) ([]map[string]any, error) {
data, err := rt.CallMCPData(serverMain, "query_records", map[string]any{
"baseId": baseID, "tableId": tableID, "recordIds": ids, "limit": len(ids),
})
if err != nil {
return nil, err
}
records, found := findRecords(data)
if !found {
return nil, fmt.Errorf("query_records read-back is missing records")
}
if responseHasMore(data) {
return nil, fmt.Errorf("query_records read-back is incomplete")
}
return records, nil
}
func recordIDs(records []map[string]any) []string {
out := make([]string, 0, len(records))
seen := map[string]bool{}
for _, record := range records {
id := recordID(record)
if id != "" && !seen[id] {
seen[id] = true
out = append(out, id)
}
}
return out
}
func createdRecordIDs(data map[string]any) []string {
seen := map[string]bool{}
out := make([]string, 0)
var walk func(any, bool)
walk = func(value any, createdContext bool) {
switch typed := value.(type) {
case map[string]any:
if createdContext {
if id := recordID(typed); id != "" && !seen[id] {
seen[id] = true
out = append(out, id)
}
}
for key, child := range typed {
lower := strings.ToLower(key)
nextContext := createdContext || strings.Contains(lower, "created")
walk(child, nextContext)
}
case []any:
for _, child := range typed {
if createdContext {
if id, ok := child.(string); ok {
id = strings.TrimSpace(id)
if id != "" && !seen[id] {
seen[id] = true
out = append(out, id)
continue
}
}
}
walk(child, createdContext)
}
}
}
walk(data, false)
return out
}
func matchCreatedCells(expected, actual []map[string]any) error {
if len(actual) != len(expected) {
return fmt.Errorf("read-back returned %d created records, want %d", len(actual), len(expected))
}
used := make([]bool, len(actual))
for _, wantRecord := range expected {
wantCells := wantRecord["cells"].(map[string]any)
matched := false
for index, gotRecord := range actual {
if used[index] || verifyRecordCells(gotRecord, wantCells) != nil {
continue
}
used[index] = true
matched = true
break
}
if !matched {
return fmt.Errorf("no created read-back record matches cells %#v", wantCells)
}
}
return nil
}
func stringSlice(value any) []string {
values, _ := value.([]string)
return values
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
@@ -0,0 +1,176 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"fmt"
"strings"
"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 RecordBulkPatch = shortcut.Shortcut{
Service: "aitable",
Command: "+record-bulk-patch",
Product: serverMain,
Description: "完整查询目标记录后批量合并同一组 cells,自动分片并逐条读回验证",
Intent: "当你要按 filters/关键词/recordIds 对一批已有记录应用相同字段补丁时使用;必须显式选择范围或 --all,并受 max-matches 写前上限保护。",
Risk: shortcut.RiskHighWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "idempotent",
},
Contract: aitableCompositeContract(
"+record-bulk-patch",
"完整查询目标记录后批量合并同一组 cells,自动分片并逐条读回验证",
"当你要按 filters/关键词/recordIds 对一批已有记录应用相同字段补丁时使用;必须显式选择范围或 --all,并受 max-matches 写前上限保护。",
"单条已知 recordId 用 record update;每条记录写不同值用 record update/upsert;未明确范围时不要执行",
`dws aitable +record-bulk-patch --base-id B --table-id T --query "待处理" --patch '{"fldStatus":"完成"}'`,
),
Flags: []shortcut.Flag{
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
{Name: "patch", Type: shortcut.FlagString, Desc: "要合并到每条记录的非空 cells JSON 对象", Required: true},
{Name: "filters", Type: shortcut.FlagString, Desc: "query_records filters JSON(选择条件之一)"},
{Name: "query", Type: shortcut.FlagString, Desc: "全文关键词(选择条件之一)"},
{Name: "record-ids", Type: shortcut.FlagStringSlice, Desc: "明确的 recordId 列表(选择条件之一)"},
{Name: "view-id", Type: shortcut.FlagString, Desc: "可选视图上下文"},
{Name: "all", Type: shortcut.FlagBool, Desc: "明确允许匹配整张表"},
{Name: "max-matches", Type: shortcut.FlagInt, Default: "1000", Desc: "写入前允许匹配的最大记录数,1-10000"},
},
Tips: []string{`dws aitable +record-bulk-patch --base-id B --table-id T --query "待处理" --patch '{"fldStatus":"完成"}'`},
Execute: func(rt *shortcut.RuntimeContext) error {
return executeRecordBulkPatch(rt)
},
}
func executeRecordBulkPatch(rt *shortcut.RuntimeContext) error {
patch, err := parseJSONObject("patch", rt.Str("patch"))
if err != nil {
return err
}
if len(patch) == 0 {
return apperrors.NewValidation("--patch 必须是非空 JSON 对象")
}
params := map[string]any{"baseId": rt.Str("base-id"), "tableId": rt.Str("table-id")}
selectorCount := 0
if rt.Changed("filters") {
filters, err := parseBulkPatchFilters(rt.Str("filters"))
if err != nil {
return err
}
params["filters"] = filters
selectorCount++
}
if rt.Changed("query") {
query := strings.TrimSpace(rt.Str("query"))
if query == "" {
return apperrors.NewValidation("--query 必须包含非空白关键词")
}
params["keyword"] = query
selectorCount++
}
if rt.Changed("record-ids") {
ids, err := parseRecordIDs(rt.StrSlice("record-ids"))
if err != nil {
return err
}
params["recordIds"] = ids
selectorCount++
}
if rt.Changed("all") {
if !rt.Bool("all") {
return apperrors.NewValidation("--all=false 不构成全表授权")
}
selectorCount++
}
if selectorCount == 0 {
return apperrors.NewValidation("必须显式提供 --filters、--query、--record-ids 或 --all")
}
maxMatches := rt.Int("max-matches")
if maxMatches < 1 || maxMatches > maxCompositeRecordRun {
return apperrors.NewValidation(fmt.Sprintf("--max-matches 必须在 1..%d", maxCompositeRecordRun))
}
if rt.Changed("view-id") {
params["viewId"] = rt.Str("view-id")
}
records, err := queryAllRecords(rt, params, maxMatches)
if err != nil {
return err
}
updates := make([]map[string]any, 0, len(records))
for _, record := range records {
id := recordID(record)
if id == "" {
return fmt.Errorf("query_records returned a matched record without recordId")
}
updates = append(updates, map[string]any{"recordId": id, "cells": cloneAnyMap(patch)})
}
if len(updates) == 0 {
result := newCompositeResult("record_bulk_patch")
result.RequestedCount = 0
result.Verification = map[string]any{"status": "verified_no_matches"}
result.Result = map[string]any{"matchedCount": 0, "updatedCount": 0}
if rt.DryRun() {
result.Status = "planned"
result.Executed = false
}
return rt.Output(result)
}
return executeRecordBatches(rt, "record_bulk_patch", "update_records", serverMain, updates, verifyUpdateBatch)
}
func parseBulkPatchFilters(raw string) (map[string]any, error) {
filters, err := parseJSONObject("filters", raw)
if err != nil {
return nil, err
}
if !bulkPatchFilterConstrictsScope(filters, true) {
return nil, apperrors.NewValidation("--filters 必须是包含非空筛选条件的 JSON 对象,根 operator 必须为 and/or")
}
return filters, nil
}
func bulkPatchFilterConstrictsScope(value any, root bool) bool {
filter, ok := value.(map[string]any)
if !ok {
return false
}
operator, ok := filter["operator"].(string)
if !ok {
return false
}
operator = strings.ToLower(strings.TrimSpace(operator))
if operator == "" {
return false
}
operands, ok := filter["operands"].([]any)
if !ok || len(operands) == 0 {
return false
}
if root && operator != "and" && operator != "or" {
return false
}
if operator == "and" || operator == "or" {
for _, operand := range operands {
if !bulkPatchFilterConstrictsScope(operand, false) {
return false
}
}
return true
}
switch operator {
case "eq", "ne", "gt", "lt", "gte", "lte", "contain", "exclusive",
"exist", "un_exist", "any_of", "all_of", "none_of", "date_eq",
"before", "after", "not_before", "not_after":
default:
return false
}
fieldID, ok := operands[0].(string)
if !ok || strings.TrimSpace(fieldID) == "" {
return false
}
return (operator == "exist" || operator == "un_exist") || len(operands) >= 2
}
@@ -0,0 +1,368 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"errors"
"fmt"
"strconv"
"strings"
"testing"
)
func TestCrossPlatformCoverageRecordObjectAndIDValidation(t *testing.T) {
tooMany := make([]any, maxCompositeRecordRun+1)
for index := range tooMany {
tooMany[index] = map[string]any{"cells": map[string]any{"f": index}}
}
cases := []struct {
name string
raw string
requireID bool
}{
{name: "invalid JSON", raw: `{`},
{name: "not array", raw: `{}`},
{name: "empty array", raw: `[]`},
{name: "too many", raw: mustJSONText(t, tooMany)},
{name: "non-object", raw: `[1]`},
{name: "missing cells", raw: `[{"recordId":"r"}]`},
{name: "empty cells", raw: `[{"recordId":"r","cells":{}}]`},
{name: "missing record id", raw: `[{"cells":{"f":1}}]`, requireID: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if records, err := parseRecordObjects(tc.raw, tc.requireID); err == nil || records != nil {
t.Fatalf("parseRecordObjects = %#v, %v", records, err)
}
})
}
if ids, err := parseRecordIDs([]string{" ", "b", "a", "a"}); err != nil || strings.Join(ids, ",") != "a,b" {
t.Fatalf("parseRecordIDs = %#v, %v", ids, err)
}
tooManyIDs := make([]string, maxCompositeRecordRun+1)
for index := range tooManyIDs {
tooManyIDs[index] = strconv.Itoa(index)
}
if ids, err := parseRecordIDs(tooManyIDs); err == nil || ids != nil {
t.Fatalf("too many IDs = %#v, %v", ids, err)
}
}
func TestCrossPlatformCoverageRecordBatchEntryValidationE2E(t *testing.T) {
for _, command := range []string{"+record-update", "+record-upsert"} {
t.Run(command, func(t *testing.T) {
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, command,
"--base-id", "base", "--table-id", "table", "--records", `{`, "--yes")
if err == nil || out != "" {
t.Fatalf("invalid records = output:%q err:%v", out, err)
}
})
}
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+record-delete",
"--base-id", "base", "--table-id", "table", "--record-ids", ` `, "--yes")
if err == nil || out != "" {
t.Fatalf("invalid record IDs = output:%q err:%v", out, err)
}
}
func TestCrossPlatformCoverageRecordDeleteDryRunAndDualFailureE2E(t *testing.T) {
caller := &upsertByKeyCaller{dryRun: true}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"}, "--dry-run")
if err != nil || len(caller.calls) != 0 || !strings.Contains(out, `"status": "planned"`) {
t.Fatalf("delete dry run = output:%q err:%v calls:%#v", out, err, caller.calls)
}
caller = &upsertByKeyCaller{steps: []upsertByKeyStep{{err: errors.New("write failed")}, {err: errors.New("verify failed")}}}
out, err = runRecordDeleteCLI(t, caller, []string{"r1"})
if err == nil || out != "" {
t.Fatalf("delete dual failure = output:%q err:%v", out, err)
}
out, err = runRecordDeleteCLI(t, &upsertByKeyCaller{}, []string{"", " "})
if err == nil || out != "" {
t.Fatalf("delete parsed empty IDs = output:%q err:%v", out, err)
}
out, err = runRecordDeleteCLI(t, &upsertByKeyCaller{}, recordIDFixtures(maxCompositeRecordRun+1))
if err == nil || out != "" {
t.Fatalf("delete excessive IDs = output:%q err:%v", out, err)
}
}
func TestCrossPlatformCoverageRecordBatchVerificationEdgesE2E(t *testing.T) {
record := []map[string]any{{"recordId": "r1", "cells": map[string]any{"f": "new"}}}
t.Run("read-back query error", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"updatedCount":1}`}, {err: errors.New("read failed")}}}
out, err := runRecordBatchCLI(t, caller, "+record-update", record)
if err == nil || out != "" {
t.Fatalf("query error = output:%q err:%v", out, err)
}
})
t.Run("read-back cells mismatch", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"updatedCount":1}`},
{text: `{"records":[{"recordId":"r1","cells":{"f":"old"}}]}`},
}}
out, err := runRecordBatchCLI(t, caller, "+record-update", record)
if err == nil || out != "" {
t.Fatalf("cell mismatch = output:%q err:%v", out, err)
}
})
t.Run("upsert create read error", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"createdRecords":[{"recordId":"new"}]}`},
{err: errors.New("created read failed")},
}}
out, err := runRecordBatchCLI(t, caller, "+record-upsert", []map[string]any{{"cells": map[string]any{"f": "new"}}})
if err == nil || out != "" {
t.Fatalf("create read error = output:%q err:%v", out, err)
}
})
t.Run("upsert create cells mismatch", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"createdRecords":[{"recordId":"new"}]}`},
{text: `{"records":[{"recordId":"new","cells":{"f":"old"}}]}`},
}}
out, err := runRecordBatchCLI(t, caller, "+record-upsert", []map[string]any{{"cells": map[string]any{"f": "new"}}})
if err == nil || out != "" {
t.Fatalf("create mismatch = output:%q err:%v", out, err)
}
})
t.Run("upsert update read error", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"createdRecords":[],"updatedCount":1}`},
{err: errors.New("updated read failed")},
}}
out, err := runRecordBatchCLI(t, caller, "+record-upsert", record)
if err == nil || out != "" {
t.Fatalf("update read error = output:%q err:%v", out, err)
}
})
t.Run("incomplete update read", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"updatedCount":1}`},
{text: `{"records":[{"recordId":"r1","cells":{"f":"new"}}],"hasMore":true}`},
}}
out, err := runRecordBatchCLI(t, caller, "+record-update", record)
if err == nil || out != "" {
t.Fatalf("incomplete read = output:%q err:%v", out, err)
}
})
}
func TestCrossPlatformCoverageRecordBatchPureHelpers(t *testing.T) {
if err := matchCreatedCells([]map[string]any{{"cells": map[string]any{"f": 1}}}, nil); err == nil {
t.Fatal("created result count mismatch must fail")
}
if err := matchCreatedCells(
[]map[string]any{{"cells": map[string]any{"f": 1}}},
[]map[string]any{{"recordId": "r", "cells": map[string]any{"f": 2}}},
); err == nil {
t.Fatal("unmatched created cells must fail")
}
ids := createdRecordIDs(map[string]any{"createdIds": []any{" r1 ", "r1", map[string]any{"record_id": "r2"}}})
if strings.Join(ids, ",") != "r1,r2" {
t.Fatalf("created IDs = %#v", ids)
}
if got := stringSlice("bad"); got != nil {
t.Fatalf("stringSlice = %#v", got)
}
if minInt(1, 2) != 1 || minInt(2, 1) != 1 {
t.Fatal("minInt branch mismatch")
}
}
func TestCrossPlatformCoverageRecordBulkPatchValidationAndSelectorsE2E(t *testing.T) {
cases := []struct {
name string
args []string
}{
{name: "invalid patch", args: []string{"--all", "--patch", `{`}},
{name: "empty patch", args: []string{"--all", "--patch", `{}`}},
{name: "false all", args: []string{"--all=false", "--patch", `{"f":1}`}},
{name: "invalid max", args: []string{"--all", "--patch", `{"f":1}`, "--max-matches", "0"}},
{name: "invalid filters", args: []string{"--filters", `{`, "--patch", `{"f":1}`}},
{name: "null filters", args: []string{"--filters", `null`, "--patch", `{"f":1}`}},
{name: "empty filters object", args: []string{"--filters", `{}`, "--patch", `{"f":1}`}},
{name: "empty filters operands", args: []string{"--filters", `{"operator":"and","operands":[]}`, "--patch", `{"f":1}`}},
{name: "missing filters operator", args: []string{"--filters", `{"operands":[{"operator":"eq","operands":["fld","x"]}]}`, "--patch", `{"f":1}`}},
{name: "blank child operator", args: []string{"--filters", `{"operator":"and","operands":[{"operator":" ","operands":["fld","x"]}]}`, "--patch", `{"f":1}`}},
{name: "null filters operands", args: []string{"--filters", `{"operator":"and","operands":null}`, "--patch", `{"f":1}`}},
{name: "non condition operand", args: []string{"--filters", `{"operator":"and","operands":[null]}`, "--patch", `{"f":1}`}},
{name: "root comparison", args: []string{"--filters", `{"operator":"eq","operands":["fld","x"]}`, "--patch", `{"f":1}`}},
{name: "blank filter field", args: []string{"--filters", `{"operator":"and","operands":[{"operator":"eq","operands":[" ","x"]}]}`, "--patch", `{"f":1}`}},
{name: "unknown filter operator", args: []string{"--filters", `{"operator":"and","operands":[{"operator":"unknown","operands":["fld","x"]}]}`, "--patch", `{"f":1}`}},
{name: "missing comparison value", args: []string{"--filters", `{"operator":"and","operands":[{"operator":"eq","operands":["fld"]}]}`, "--patch", `{"f":1}`}},
{name: "blank query", args: []string{"--query", ` `, "--patch", `{"f":1}`}},
{name: "empty record IDs", args: []string{"--record-ids", ` `, "--patch", `{"f":1}`}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
args := []string{"--base-id", "base", "--table-id", "table"}
args = append(args, tc.args...)
args = append(args, "--yes")
caller := &upsertByKeyCaller{}
out, err := runAITableCompositeCLI(t, caller, "+record-bulk-patch", args...)
if err == nil || out != "" || len(caller.calls) != 0 {
t.Fatalf("validation = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
}
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"records":[]}`}}}
out, err := runAITableCompositeCLI(t, caller, "+record-bulk-patch",
"--base-id", "base", "--table-id", "table", "--filters", `{"operator":"and","operands":[{"operator":"or","operands":[{"operator":"eq","operands":["fld","x"]}]}]}`, "--query", "text",
"--record-ids", "r2,r1", "--view-id", "view", "--patch", `{"f":1}`, "--yes")
if err != nil || !strings.Contains(out, `"matchedCount": 0`) {
t.Fatalf("selectors = output:%q err:%v", out, err)
}
for _, key := range []string{"filters", "keyword", "recordIds", "viewId"} {
if _, ok := caller.calls[0].args[key]; !ok {
t.Fatalf("query args missing %s: %#v", key, caller.calls[0].args)
}
}
caller = &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: `{"records":[]}`}}}
out, err = runAITableCompositeCLI(t, caller, "+record-bulk-patch",
"--base-id", "base", "--table-id", "table", "--query", "none", "--patch", `{"f":1}`, "--dry-run", "--yes")
if err != nil || !strings.Contains(out, `"status": "planned"`) {
t.Fatalf("empty dry run = output:%q err:%v", out, err)
}
}
func TestCrossPlatformCoverageRecordBulkPatchQueryFailuresE2E(t *testing.T) {
cases := []struct {
name string
steps []upsertByKeyStep
}{
{name: "query error", steps: []upsertByKeyStep{{err: errors.New("query failed")}}},
{name: "missing records", steps: []upsertByKeyStep{{text: `{}`}}},
{name: "more without cursor", steps: []upsertByKeyStep{{text: `{"records":[],"hasMore":true}`}}},
{name: "cursor cycle", steps: []upsertByKeyStep{
{text: `{"records":[],"hasMore":true,"nextCursor":"same"}`},
{text: `{"records":[],"hasMore":true,"nextCursor":"same"}`},
}},
{name: "matched record missing id", steps: []upsertByKeyStep{{text: `{"records":[{"cells":{"f":1}}]}`}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+record-bulk-patch",
"--base-id", "base", "--table-id", "table", "--all", "--patch", `{"f":1}`, "--yes")
if err == nil || out != "" {
t.Fatalf("query failure = output:%q err:%v", out, err)
}
})
}
}
func TestCrossPlatformCoverageRecordQueryPageSafetyBoundE2E(t *testing.T) {
caller := &upsertByKeyCaller{callFn: func(index int, _, tool string, _ map[string]any) (string, error) {
if tool != "query_records" {
return "", fmt.Errorf("unexpected tool %s", tool)
}
return fmt.Sprintf(`{"records":[],"hasMore":true,"nextCursor":"cursor-%d"}`, index), nil
}}
out, err := runAITableCompositeCLI(t, caller, "+record-bulk-patch",
"--base-id", "base", "--table-id", "table", "--all", "--patch", `{"f":1}`, "--yes")
if err == nil || out != "" || len(caller.calls) != 10000 {
t.Fatalf("page safety = output:%q err:%v calls:%d", out, err, len(caller.calls))
}
}
func TestCrossPlatformCoverageRecordUpsertInputAndQueryFailuresE2E(t *testing.T) {
baseArgs := []string{"--base-id", "base", "--table-id", "table", "--key-field-id", "key", "--cells", `{"value":1}`, "--yes"}
cases := []struct {
name string
key []string
cells string
steps []upsertByKeyStep
}{
{name: "empty string key", key: []string{"--key-value", " "}},
{name: "invalid JSON key", key: []string{"--key-value-json", `{`}},
{name: "null JSON key", key: []string{"--key-value-json", `null`}},
{name: "object JSON key", key: []string{"--key-value-json", `{}`}},
{name: "invalid cells", key: []string{"--key-value", "v"}, cells: `{`},
{name: "conflicting key", key: []string{"--key-value", "v"}, cells: `{"key":"other"}`},
{name: "query error", key: []string{"--key-value", "v"}, steps: []upsertByKeyStep{{err: errors.New("query failed")}}},
{name: "missing records", key: []string{"--key-value", "v"}, steps: []upsertByKeyStep{{text: `{}`}}},
{name: "incomplete unique query", key: []string{"--key-value", "v"}, steps: []upsertByKeyStep{{text: `{"records":[],"hasMore":true}`}}},
{name: "record missing id", key: []string{"--key-value", "v"}, steps: []upsertByKeyStep{{text: `{"records":[{"cells":{"key":"v"}}]}`}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
args := append([]string{}, baseArgs...)
args = append(args, tc.key...)
if tc.cells != "" {
for index := range args {
if args[index] == "--cells" {
args[index+1] = tc.cells
}
}
}
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+record-upsert-by-key", args...)
if err == nil || out != "" {
t.Fatalf("upsert failure = output:%q err:%v", out, err)
}
})
}
}
func TestCrossPlatformCoverageRecordUpsertJSONScalarSuccessE2E(t *testing.T) {
caller := &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: `{"records":[]}`}}}
out, err := runAITableCompositeCLI(t, caller, "+record-upsert-by-key",
"--base-id", "base", "--table-id", "table", "--key-field-id", "key", "--key-value-json", `true`,
"--cells", `{"value":1}`, "--dry-run", "--yes")
if err != nil || !strings.Contains(out, `"keyValue": true`) {
t.Fatalf("JSON scalar = output:%q err:%v", out, err)
}
}
func TestCrossPlatformCoverageRecordUpsertRejectsTrailingJSONBeforeMCPE2E(t *testing.T) {
for name, value := range map[string]string{
"trailing garbage": `1 garbage`,
"second JSON value": `1 2`,
"second string value": `true "extra"`,
} {
t.Run(name, func(t *testing.T) {
caller := &upsertByKeyCaller{}
out, err := runAITableCompositeCLI(t, caller, "+record-upsert-by-key",
"--base-id", "base", "--table-id", "table", "--key-field-id", "key", "--key-value-json", value,
"--cells", `{"value":1}`, "--yes")
if err == nil || out != "" || len(caller.calls) != 0 {
t.Fatalf("trailing JSON = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
}
}
func TestCrossPlatformCoverageRecordShapeHelpers(t *testing.T) {
if records, ok := findRecords(nil); ok || records != nil {
t.Fatalf("nil records = %#v, %v", records, ok)
}
if _, ok := findRecords(map[string]any{"records": "bad"}); ok {
t.Fatal("non-array records must fail")
}
if _, ok := findRecords(map[string]any{"records": []any{"bad"}}); ok {
t.Fatal("non-object record must fail")
}
if records, ok := findRecords(map[string]any{"result": map[string]any{"records": []any{map[string]any{"id": "r"}}}}); !ok || recordID(records[0]) != "r" {
t.Fatalf("nested records = %#v, %v", records, ok)
}
if responseHasMore(nil) || !responseHasMore(map[string]any{"cursor": "next"}) || !responseHasMore(map[string]any{"pagination": map[string]any{"hasMore": true}}) {
t.Fatal("responseHasMore shape mismatch")
}
if err := verifyRecordCells(map[string]any{"recordId": "r"}, map[string]any{"f": 1}); err == nil {
t.Fatal("missing cells must fail")
}
if responseCursor(nil) != "" || responseCursor(map[string]any{"data": map[string]any{"next_cursor": " next "}}) != "next" ||
responseCursor(map[string]any{"result": map[string]any{"cursor": " legacy "}}) != "legacy" {
t.Fatal("responseCursor shape mismatch")
}
}
@@ -0,0 +1,78 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func queryAllRecords(rt *shortcut.RuntimeContext, params map[string]any, maxRecords int) ([]map[string]any, error) {
all := make([]map[string]any, 0)
cursor := ""
seen := map[string]bool{}
for page := 0; ; page++ {
request := cloneAnyMap(params)
request["limit"] = recordBatchSize
if cursor != "" {
request["cursor"] = cursor
}
data, err := rt.CallMCPData(serverMain, "query_records", request)
if err != nil {
return nil, err
}
records, found := findRecords(data)
if !found {
return nil, fmt.Errorf("query_records page %d is missing the records collection", page+1)
}
all = append(all, records...)
if maxRecords > 0 && len(all) > maxRecords {
return nil, fmt.Errorf("query matched more than the allowed %d records", maxRecords)
}
if !responseHasMore(data) {
return all, nil
}
next := responseCursor(data)
if next == "" {
return nil, fmt.Errorf("query_records page %d reports more data but no next cursor", page+1)
}
if seen[next] || next == cursor {
return nil, fmt.Errorf("query_records cursor cycle detected at %q", next)
}
seen[next] = true
cursor = next
if page >= 9999 {
return nil, fmt.Errorf("query_records exceeded the 10000-page safety bound")
}
}
}
func responseCursor(data map[string]any) string {
if data == nil {
return ""
}
for _, key := range []string{"nextCursor", "next_cursor", "cursor"} {
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
for _, key := range []string{"data", "result", "pagination", "page"} {
if nested, ok := data[key].(map[string]any); ok {
if cursor := responseCursor(nested); cursor != "" {
return cursor
}
}
}
return ""
}
func cloneAnyMap(input map[string]any) map[string]any {
out := make(map[string]any, len(input))
for key, value := range input {
out[key] = value
}
return out
}
@@ -0,0 +1,343 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"encoding/json"
"fmt"
"io"
"reflect"
"sort"
"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"
)
// RecordUpsertByKey creates or updates exactly one record selected by a unique
// field value. It never writes when the key is ambiguous and always verifies
// the final record through a read-back query.
var RecordUpsertByKey = shortcut.Shortcut{
Service: "aitable",
Command: "+record-upsert-by-key",
Product: serverMain,
Description: "按唯一字段值有则更新、无则创建记录,并读回验证",
Intent: "已知一个应当唯一的 fieldId/value、希望幂等同步一条记录时使用;先完整查询键值,0 条创建、1 条更新、2 条以上停止,写后再次查询并验证所有传入 cells。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "shortcut_record_upsert_by_key",
CanonicalPath: "aitable.shortcut_record_upsert_by_key",
CLIPath: "aitable +record-upsert-by-key",
PrimaryCLIPath: "aitable +record-upsert-by-key",
},
Description: "按唯一字段值有则更新、无则创建记录,并读回验证。",
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeComposite,
Availability: contract.InterfaceAvailable,
Reason: "The command composes query_records with create_records/update_records and a final read-back; no single RPC owns its uniqueness and verification contract.",
},
Selection: contract.SelectionSpec{
AgentSummary: "按唯一字段值有则更新、无则创建记录,并读回验证",
UseWhen: []string{"已知一个应当唯一的 fieldId/value、希望幂等同步一条记录时使用;先完整查询键值,0 条创建、1 条更新、2 条以上停止,写后再次查询并验证所有传入 cells。"},
AvoidWhen: []string{"已经知道 recordId 时用 +record-update;一次处理多条不同键值时使用批量导入或分批调用"},
Examples: []string{
"dws aitable +record-upsert-by-key --base-id B --table-id T --key-field-id fldKey --key-value TASK-001 --cells '{\"fldStatus\":\"进行中\"}'",
},
ExampleDispositions: []contract.ExampleDisposition{{
Index: recordUpsertExampleIndex(),
Mode: contract.ExampleDispositionModeContractOnly,
ReasonCode: contract.ExampleDispositionReasonStatefulPreflight,
Reason: "dry-run must query the live table to prove whether the unique key matches zero or one record; the isolated Agent example runner has no remote AITable fixture",
Reviewed: true,
}},
},
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: true},
},
Flags: []shortcut.Flag{
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
{Name: "key-field-id", Type: shortcut.FlagString, Desc: "具有唯一语义的字段 ID", Required: true},
{Name: "key-value", Type: shortcut.FlagString, Desc: "字符串键值;与 --key-value-json 二选一"},
{Name: "key-value-json", Type: shortcut.FlagString, Desc: "JSON 类型键值;与 --key-value 二选一"},
{Name: "cells", Type: shortcut.FlagString, Desc: "要写入的 cells JSON 对象", Required: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"key-value", "key-value-json"}, Description: "必须且只能提供一种键值表示"},
},
Tips: []string{
`dws aitable +record-upsert-by-key --base-id B --table-id T --key-field-id fldKey --key-value TASK-001 --cells '{"fldStatus":"进行中"}'`,
},
Execute: executeRecordUpsertByKey,
}
func recordUpsertExampleIndex() *int {
index := 0
return &index
}
func executeRecordUpsertByKey(rt *shortcut.RuntimeContext) error {
keyValue, err := recordKeyValue(rt)
if err != nil {
return err
}
cells, err := parseJSONObject("cells", rt.Str("cells"))
if err != nil {
return err
}
keyFieldID := rt.Str("key-field-id")
if existing, present := cells[keyFieldID]; present && !reflect.DeepEqual(existing, keyValue) {
return apperrors.NewValidation("--cells 中的键字段值与 --key-value/--key-value-json 冲突",
apperrors.WithReason("key_value_conflict"),
apperrors.WithExecutionStarted(false),
)
}
cells[keyFieldID] = keyValue
baseID, tableID := rt.Str("base-id"), rt.Str("table-id")
preflight, err := queryUniqueRecordByKey(rt, baseID, tableID, keyFieldID, keyValue)
if err != nil {
return err
}
action, tool := "create", "create_records"
writeRecord := map[string]any{"cells": cells}
if preflight != nil {
action, tool = "update", "update_records"
writeRecord["recordId"] = recordID(preflight)
}
params := map[string]any{
"baseId": baseID, "tableId": tableID,
"records": []any{writeRecord},
}
result := newCompositeResult("record_upsert_by_key")
result.Resolved = map[string]any{
"baseId": baseID, "tableId": tableID, "keyFieldId": keyFieldID,
"keyValue": keyValue, "matchedCount": boolCount(preflight != nil), "action": action,
}
result.RequestedCount = 1
result.Plan = []compositeStep{{Index: 1, Name: action + " record", Tool: tool, Status: "planned", Count: 1, Arguments: params}}
if rt.DryRun() {
result.Status = "planned"
result.Executed = false
return rt.Output(result)
}
writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, tool, params)
writeStep := compositeStep{Index: 1, Name: action + " record", Tool: tool, Status: "completed", Count: 1, Result: writeData}
if writeErr != nil {
writeStep.Status = "unknown"
writeStep.Error = writeErr.Error()
}
result.CompletedSteps = append(result.CompletedSteps, writeStep)
verified, verifyErr := queryUniqueRecordByKey(rt, baseID, tableID, keyFieldID, keyValue)
if verifyErr == nil && verified != nil {
verifyErr = verifyRecordCells(verified, cells)
}
if verifyErr != nil || verified == nil {
result.Status = "unknown"
result.FailedCount = 1
// An update targets a known record ID and is safe to retry. A create whose
// effect could not be read back must be resolved by the unique-key query
// first; an immediate blind retry could duplicate the row under eventual
// consistency.
retryable := action == "update"
result.Retryable = retryable
if verifyErr == nil {
verifyErr = fmt.Errorf("read-back found no record for the unique key")
}
result.Verification = map[string]any{"status": "failed", "error": verifyErr.Error()}
if writeErr != nil {
result.Warnings = append(result.Warnings, "write call also returned an error: "+writeErr.Error())
}
result.Checkpoint = map[string]any{
"nextStep": "query the unique key again and verify its cells before retrying",
"keyFieldId": keyFieldID,
"keyValue": keyValue,
}
return compositeError(result, verifyErr, retryable)
}
verifiedID := recordID(verified)
result.CompletedCount = 1
result.KnownEffects = append(result.KnownEffects, map[string]any{"action": action, "recordId": verifiedID, "keyFieldId": keyFieldID, "keyValue": keyValue})
result.Verification = map[string]any{
"status": "verified", "recordId": verifiedID,
"checkedFields": sortedMapKeys(cells),
}
result.Result = map[string]any{"action": action, "recordId": verifiedID, "record": verified}
if writeErr != nil {
result.Warnings = append(result.Warnings, "write response was an error, but the requested final state was proven by read-back")
result.CompletedSteps[0].Status = "recovered"
}
return rt.Output(result)
}
func recordKeyValue(rt *shortcut.RuntimeContext) (any, error) {
if rt.Changed("key-value") {
// The command framework rejects an explicitly empty string before Execute.
return rt.Str("key-value"), nil
}
decoder := json.NewDecoder(strings.NewReader(rt.Str("key-value-json")))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
return nil, apperrors.NewValidation("--key-value-json 不是合法 JSON: " + err.Error())
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
return nil, apperrors.NewValidation("--key-value-json 必须只包含一个 JSON 标量")
}
if value == nil || reflect.ValueOf(value).Kind() == reflect.Map || reflect.ValueOf(value).Kind() == reflect.Slice {
return nil, apperrors.NewValidation("--key-value-json 只接受 string/number/bool 标量")
}
return value, nil
}
func queryUniqueRecordByKey(rt *shortcut.RuntimeContext, baseID, tableID, fieldID string, value any) (map[string]any, error) {
filters := map[string]any{
"operator": "and",
"operands": []any{map[string]any{
"operator": "eq", "operands": []any{fieldID, value},
}},
}
data, err := rt.CallMCPData(serverMain, "query_records", map[string]any{
"baseId": baseID, "tableId": tableID, "filters": filters, "limit": 2,
})
if err != nil {
return nil, err
}
records, found := findRecords(data)
if !found {
return nil, apperrors.NewAPI("query_records response is missing the records collection",
apperrors.WithOperation("aitable/query_records"),
apperrors.WithReason("target_invalid_response"),
apperrors.WithFailureStage("response_validation"),
apperrors.WithExecutionStarted(false),
)
}
if responseHasMore(data) {
return nil, apperrors.NewAPI("unique-key query is incomplete and cannot prove uniqueness",
apperrors.WithOperation("aitable/query_records"),
apperrors.WithReason("target_incomplete"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithDetails(map[string]any{"records": records}),
)
}
switch len(records) {
case 0:
return nil, nil
case 1:
if recordID(records[0]) == "" {
return nil, apperrors.NewAPI("query_records returned a record without recordId",
apperrors.WithOperation("aitable/query_records"),
apperrors.WithReason("target_invalid_response"),
apperrors.WithExecutionStarted(false),
)
}
return records[0], nil
default:
return nil, apperrors.NewValidation("唯一键匹配到多条记录,已在写入前停止",
apperrors.WithReason("target_ambiguous"),
apperrors.WithExecutionStarted(false),
apperrors.WithDetails(map[string]any{"records": records}),
)
}
}
func findRecords(data map[string]any) ([]map[string]any, bool) {
if data == nil {
return nil, false
}
if raw, exists := data["records"]; exists {
list, ok := raw.([]any)
if !ok {
return nil, false
}
out := make([]map[string]any, 0, len(list))
for _, item := range list {
record, ok := item.(map[string]any)
if !ok {
return nil, false
}
out = append(out, record)
}
return out, true
}
for _, key := range []string{"data", "result"} {
if nested, ok := data[key].(map[string]any); ok {
if records, found := findRecords(nested); found {
return records, true
}
}
}
return nil, false
}
func responseHasMore(data map[string]any) bool {
if data == nil {
return false
}
if value, ok := data["hasMore"].(bool); ok && value {
return true
}
for _, key := range []string{"nextCursor", "cursor"} {
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
return true
}
}
for _, key := range []string{"data", "result", "pagination", "page"} {
if nested, ok := data[key].(map[string]any); ok && responseHasMore(nested) {
return true
}
}
return false
}
func recordID(record map[string]any) string {
for _, key := range []string{"recordId", "record_id", "id"} {
if value, ok := record[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func verifyRecordCells(record map[string]any, expected map[string]any) error {
actual, ok := record["cells"].(map[string]any)
if !ok {
return fmt.Errorf("read-back record %q is missing cells", recordID(record))
}
for fieldID, want := range expected {
got, exists := actual[fieldID]
if !exists || !reflect.DeepEqual(got, want) {
return fmt.Errorf("read-back mismatch for field %s: got %#v, want %#v", fieldID, got, want)
}
}
return nil
}
func sortedMapKeys(values map[string]any) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func boolCount(value bool) int {
if value {
return 1
}
return 0
}
@@ -0,0 +1,468 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
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"
"github.com/spf13/cobra"
)
type upsertByKeyStep struct {
text string
err error
}
type upsertByKeyCaller struct {
steps []upsertByKeyStep
calls []aitableURLCall
dryRun bool
callFn func(index int, product, tool string, args map[string]any) (string, error)
}
func (c *upsertByKeyCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
return c.call(product, tool, args)
}
func (c *upsertByKeyCaller) CallReadTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
return c.call(product, tool, args)
}
func (c *upsertByKeyCaller) call(product, tool string, args map[string]any) (*edition.ToolResult, error) {
cloned := make(map[string]any, len(args))
for key, value := range args {
cloned[key] = value
}
c.calls = append(c.calls, aitableURLCall{product: product, tool: tool, args: cloned})
index := len(c.calls) - 1
if c.callFn != nil {
text, err := c.callFn(index, product, tool, args)
if err != nil {
return nil, err
}
return urlResult(text), nil
}
if index >= len(c.steps) {
return nil, errors.New("unexpected upsert-by-key call")
}
step := c.steps[index]
if step.err != nil {
return nil, step.err
}
return urlResult(step.text), nil
}
func (*upsertByKeyCaller) Format() string { return "json" }
func (c *upsertByKeyCaller) DryRun() bool { return c.dryRun }
func (*upsertByKeyCaller) Fields() string { return "" }
func (*upsertByKeyCaller) JQ() string { return "" }
func runUpsertByKeyCLI(t *testing.T, caller *upsertByKeyCaller, extra ...string) (string, error) {
t.Helper()
helpers.InitDepsForTest(t, caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
root.AddCommand(shortcut.Commands()...)
stdout := &bytes.Buffer{}
root.SetOut(stdout)
root.SetErr(&bytes.Buffer{})
args := []string{
"aitable", "+record-upsert-by-key",
"--base-id", "base", "--table-id", "table",
"--key-field-id", "fldKey", "--key-value", "TASK-1",
"--cells", `{"fldStatus":"进行中"}`,
"--yes",
}
args = append(args, extra...)
root.SetArgs(args)
err := root.Execute()
return stdout.String(), err
}
func TestCrossPlatformCoverageRecordUpsertByKeyCreateE2E(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"data":{"records":[]}}`},
{text: `{"data":{"records":[{"recordId":"r1"}]}}`},
{text: `{"data":{"records":[{"recordId":"r1","cells":{"fldKey":"TASK-1","fldStatus":"进行中"}}]}}`},
}}
out, err := runUpsertByKeyCLI(t, caller)
if err != nil {
t.Fatalf("create upsert error = %v", err)
}
for _, want := range []string{`"status": "success"`, `"action": "create"`, `"recordId": "r1"`, `"status": "verified"`} {
if !bytes.Contains([]byte(out), []byte(want)) {
t.Fatalf("create output missing %s:\n%s", want, out)
}
}
if len(caller.calls) != 3 || caller.calls[1].tool != "create_records" || caller.calls[2].tool != "query_records" {
t.Fatalf("create call flow = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageRecordUpsertByKeyUpdateE2E(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"records":[{"recordId":"r1","cells":{"fldKey":"TASK-1","fldStatus":"旧"}}]}`},
{text: `{"updatedRecords":[{"recordId":"r1"}]}`},
{text: `{"records":[{"recordId":"r1","cells":{"fldKey":"TASK-1","fldStatus":"进行中"}}]}`},
}}
out, err := runUpsertByKeyCLI(t, caller)
if err != nil || !bytes.Contains([]byte(out), []byte(`"action": "update"`)) {
t.Fatalf("update upsert = output:%q err:%v", out, err)
}
if caller.calls[1].tool != "update_records" {
t.Fatalf("update tool = %s", caller.calls[1].tool)
}
}
func TestCrossPlatformCoverageRecordUpsertByKeyAmbiguousStopsBeforeWriteE2E(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"records":[
{"recordId":"r1","cells":{"fldKey":"TASK-1"}},
{"recordId":"r2","cells":{"fldKey":"TASK-1"}}
]}`}}}
out, err := runUpsertByKeyCLI(t, caller)
if err == nil || out != "" || len(caller.calls) != 1 {
t.Fatalf("ambiguous upsert = output:%q err:%v calls:%#v", out, err, caller.calls)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "target_ambiguous" || typed.ExecutionStarted == nil || *typed.ExecutionStarted {
t.Fatalf("ambiguous error = %#v", err)
}
}
func TestCrossPlatformCoverageRecordUpsertByKeyDryRunReadsAndPlansE2E(t *testing.T) {
caller := &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: `{"records":[]}`}}}
out, err := runUpsertByKeyCLI(t, caller, "--dry-run")
if err != nil {
t.Fatalf("dry-run upsert error = %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != "query_records" {
t.Fatalf("dry-run calls = %#v", caller.calls)
}
for _, want := range []string{`"status": "planned"`, `"executed": false`, `"tool": "create_records"`} {
if !bytes.Contains([]byte(out), []byte(want)) {
t.Fatalf("dry-run output missing %s: %s", want, out)
}
}
}
func TestCrossPlatformCoverageRecordUpsertByKeyWriteReplyNeedsReadBackE2E(t *testing.T) {
t.Run("empty write recovered by proven state", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"records":[]}`},
{text: ""},
{text: `{"records":[{"recordId":"r1","cells":{"fldKey":"TASK-1","fldStatus":"进行中"}}]}`},
}}
out, err := runUpsertByKeyCLI(t, caller)
if err != nil || !bytes.Contains([]byte(out), []byte(`"status": "recovered"`)) || !bytes.Contains([]byte(out), []byte("proven by read-back")) {
t.Fatalf("recovered write = output:%q err:%v", out, err)
}
})
t.Run("empty write and absent state is unknown", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"records":[]}`},
{text: ""},
{text: `{"records":[]}`},
}}
out, err := runUpsertByKeyCLI(t, caller)
if err == nil || out != "" {
t.Fatalf("unknown write = output:%q err:%v", out, err)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" || typed.Retryable {
t.Fatalf("unknown write error = %#v", err)
}
if typed.Details == nil || !strings.Contains(fmt.Sprint(typed.Details), "query the unique key again") {
t.Fatalf("unknown create is missing safe recovery checkpoint: %#v", typed.Details)
}
})
t.Run("unknown update remains retryable because record id is known", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"records":[{"recordId":"r1","cells":{"fldKey":"TASK-1","fldStatus":"旧"}}]}`},
{text: ""},
{text: `{"records":[{"recordId":"r1","cells":{"fldKey":"TASK-1","fldStatus":"旧"}}]}`},
}}
out, err := runUpsertByKeyCLI(t, caller)
if err == nil || out != "" {
t.Fatalf("unknown update = output:%q err:%v", out, err)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" || !typed.Retryable {
t.Fatalf("unknown update error = %#v", err)
}
})
}
func TestCrossPlatformCoverageRecordUpsertByKeyVerificationMismatchFailsE2E(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"records":[]}`},
{text: `{"createdRecords":[{"recordId":"r1"}]}`},
{text: `{"records":[{"recordId":"r1","cells":{"fldKey":"TASK-1","fldStatus":"错误值"}}]}`},
}}
out, err := runUpsertByKeyCLI(t, caller)
if err == nil || out != "" {
t.Fatalf("verification mismatch = output:%q err:%v", out, err)
}
}
func runRecordBatchCLI(t *testing.T, caller *upsertByKeyCaller, command string, records []map[string]any, extra ...string) (string, error) {
t.Helper()
helpers.InitDepsForTest(t, caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
root.AddCommand(shortcut.Commands()...)
stdout := &bytes.Buffer{}
root.SetOut(stdout)
root.SetErr(&bytes.Buffer{})
raw, err := json.Marshal(records)
if err != nil {
t.Fatal(err)
}
args := []string{"aitable", command, "--base-id", "base", "--table-id", "table", "--records", string(raw), "--yes"}
args = append(args, extra...)
root.SetArgs(args)
err = root.Execute()
return stdout.String(), err
}
func updateFixtureRecords(start, count int, status string) []map[string]any {
records := make([]map[string]any, 0, count)
for index := start; index < start+count; index++ {
records = append(records, map[string]any{
"recordId": fmt.Sprintf("r%03d", index),
"cells": map[string]any{"fldStatus": status},
})
}
return records
}
func recordListJSON(t *testing.T, records []map[string]any) string {
t.Helper()
items := make([]any, 0, len(records))
for _, record := range records {
items = append(items, record)
}
raw, err := json.Marshal(map[string]any{"data": map[string]any{"records": items}})
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func TestCrossPlatformCoverageRecordUpdateAutoChunksAndVerifiesE2E(t *testing.T) {
records := updateFixtureRecords(0, 101, "完成")
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"updatedCount":100}`},
{text: recordListJSON(t, records[:100])},
{text: `{"updatedCount":1}`},
{text: recordListJSON(t, records[100:])},
}}
out, err := runRecordBatchCLI(t, caller, "+record-update", records)
if err != nil {
t.Fatalf("record update batches error = %v", err)
}
for _, want := range []string{`"processedCount": 101`, `"batchCount": 2`, `"verifiedCount": 101`} {
if !bytes.Contains([]byte(out), []byte(want)) {
t.Fatalf("batch output missing %s: %s", want, out)
}
}
if len(caller.calls) != 4 || caller.calls[0].tool != "update_records" || caller.calls[1].tool != "query_records" || caller.calls[2].tool != "update_records" {
t.Fatalf("batch call sequence = %#v", caller.calls)
}
firstBatch := caller.calls[0].args["records"].([]any)
secondBatch := caller.calls[2].args["records"].([]any)
if len(firstBatch) != 100 || len(secondBatch) != 1 {
t.Fatalf("batch sizes = %d/%d", len(firstBatch), len(secondBatch))
}
}
func TestCrossPlatformCoverageRecordUpdatePartialStopsWithCheckpointE2E(t *testing.T) {
records := updateFixtureRecords(0, 101, "完成")
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"updatedCount":100}`},
{text: recordListJSON(t, records[:100])},
{err: errors.New("connection reset after send")},
{text: `{"records":[]}`},
}}
out, err := runRecordBatchCLI(t, caller, "+record-update", records)
if err == nil || out != "" {
t.Fatalf("partial record update = output:%q err:%v", out, err)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "aitable_composite_partial_success" {
t.Fatalf("partial error = %#v", err)
}
result := typed.Details["result"].(compositeResult)
if result.CompletedCount != 100 || result.Checkpoint["nextOffset"] != 100 {
t.Fatalf("partial result = %#v", result)
}
}
func TestCrossPlatformCoverageRecordUpdateEmptyWriteReplyRecoveredOnlyByReadBackE2E(t *testing.T) {
records := updateFixtureRecords(0, 1, "完成")
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: ""},
{text: recordListJSON(t, records)},
}}
out, err := runRecordBatchCLI(t, caller, "+record-update", records)
if err != nil || !bytes.Contains([]byte(out), []byte(`"status": "recovered"`)) {
t.Fatalf("empty write reply recovery = output:%q err:%v", out, err)
}
}
func TestCrossPlatformCoverageRecordUpsertRequiresCreatedIDsAndReadBackE2E(t *testing.T) {
records := []map[string]any{
{"recordId": "existing", "cells": map[string]any{"fldStatus": "完成"}},
{"cells": map[string]any{"fldKey": "new"}},
}
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"data":{"createdRecords":[{"recordId":"created"}]}}`},
{text: recordListJSON(t, []map[string]any{records[0]})},
{text: recordListJSON(t, []map[string]any{{"recordId": "created", "cells": map[string]any{"fldKey": "new"}}})},
}}
out, err := runRecordBatchCLI(t, caller, "+record-upsert", records)
if err != nil || !bytes.Contains([]byte(out), []byte(`"verifiedCount": 2`)) {
t.Fatalf("mixed upsert = output:%q err:%v", out, err)
}
caller = &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"success":true}`}}}
out, err = runRecordBatchCLI(t, caller, "+record-upsert", []map[string]any{{"cells": map[string]any{"fldKey": "new"}}})
if err == nil || out != "" {
t.Fatalf("upsert without created IDs = output:%q err:%v", out, err)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Retryable {
t.Fatalf("upsert create with unknown effect must not be blindly retryable: %#v", err)
}
}
func TestCrossPlatformCoverageRecordBatchDryRunPlansWithoutWritesE2E(t *testing.T) {
records := updateFixtureRecords(0, 101, "完成")
caller := &upsertByKeyCaller{dryRun: true}
out, err := runRecordBatchCLI(t, caller, "+record-update", records, "--dry-run")
if err != nil || len(caller.calls) != 0 {
t.Fatalf("batch dry-run = output:%q err:%v calls:%#v", out, err, caller.calls)
}
if !bytes.Contains([]byte(out), []byte(`"status": "planned"`)) || !bytes.Contains([]byte(out), []byte(`"count": 100`)) || !bytes.Contains([]byte(out), []byte(`"count": 1`)) {
t.Fatalf("batch dry-run output = %s", out)
}
}
func runRecordDeleteCLI(t *testing.T, caller *upsertByKeyCaller, ids []string, extra ...string) (string, error) {
t.Helper()
helpers.InitDepsForTest(t, caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
root.AddCommand(shortcut.Commands()...)
stdout := &bytes.Buffer{}
root.SetOut(stdout)
root.SetErr(&bytes.Buffer{})
args := []string{"aitable", "+record-delete", "--base-id", "base", "--table-id", "table", "--record-ids", strings.Join(ids, ","), "--yes"}
args = append(args, extra...)
root.SetArgs(args)
err := root.Execute()
return stdout.String(), err
}
func recordIDFixtures(count int) []string {
ids := make([]string, 0, count)
for index := 0; index < count; index++ {
ids = append(ids, fmt.Sprintf("r%03d", index))
}
return ids
}
func TestCrossPlatformCoverageRecordDeleteAutoChunksAndProvesAbsenceE2E(t *testing.T) {
ids := recordIDFixtures(101)
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"deletedCount":100}`},
{text: `{"data":{"records":[]}}`},
{text: `{"deletedCount":1}`},
{text: `{"data":{"records":[]}}`},
}}
out, err := runRecordDeleteCLI(t, caller, ids)
if err != nil {
t.Fatalf("record delete batches error = %v", err)
}
for _, want := range []string{`"deletedCount": 101`, `"batchCount": 2`, `"verifiedCount": 101`, `"status": "verified_absent"`} {
if !strings.Contains(out, want) {
t.Fatalf("delete output missing %s: %s", want, out)
}
}
if len(caller.calls) != 4 || caller.calls[0].tool != "delete_records" || caller.calls[1].tool != "query_records" {
t.Fatalf("delete call sequence = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t *testing.T) {
t.Run("legal empty collection proves deletion", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: ""}, {text: `{"records":[]}`}}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err != nil || !strings.Contains(out, `"status": "recovered"`) {
t.Fatalf("delete recovered = output:%q err:%v", out, err)
}
})
t.Run("missing records contract is unknown", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"deletedCount":1}`}, {text: `{"data":{}}`}}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err == nil || out != "" {
t.Fatalf("delete missing records = output:%q err:%v", out, err)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" {
t.Fatalf("delete missing records error = %#v", err)
}
})
t.Run("remaining record is not success", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"deletedCount":0}`},
{text: `{"records":[{"recordId":"r1","cells":{"fld":"still here"}}]}`},
}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err == nil || out != "" {
t.Fatalf("delete remaining record = output:%q err:%v", out, err)
}
})
}
func TestCrossPlatformCoverageRecordDeletePartialCheckpointE2E(t *testing.T) {
ids := recordIDFixtures(101)
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"deletedCount":100}`},
{text: `{"records":[]}`},
{text: `{"deletedCount":0}`},
{text: `{"records":[{"recordId":"r100","cells":{}}]}`},
}}
out, err := runRecordDeleteCLI(t, caller, ids)
if err == nil || out != "" {
t.Fatalf("partial delete = output:%q err:%v", out, err)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "aitable_composite_partial_success" {
t.Fatalf("partial delete error = %#v", err)
}
result := typed.Details["result"].(compositeResult)
if result.Checkpoint["nextOffset"] != 100 || result.CompletedCount != 100 {
t.Fatalf("partial delete result = %#v", result)
}
}
@@ -0,0 +1,367 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"errors"
"fmt"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestCrossPlatformCoverageAITableProjectionShapeFailures(t *testing.T) {
if _, err := resolveNamedList("op", nil, []string{"data"}, []string{"items"}); err == nil {
t.Fatal("nil list response must fail")
}
if _, err := resolveNamedList("op", map[string]any{"data": "bad"}, []string{"data"}, []string{"items"}); err == nil {
t.Fatal("non-object envelope must fail")
}
if _, err := resolveNamedList("op", map[string]any{"data": map[string]any{"other": []any{}}}, []string{"data"}, []string{"items"}); err == nil {
t.Fatal("unrecognized envelope must fail")
}
for name, project := range map[string]func() error{
"base": func() error {
_, err := baseListProject("list_bases", map[string]any{"bases": []any{"bad"}})
return err
},
"template": func() error {
_, err := templateSearchProject(map[string]any{"templates": []any{"bad"}})
return err
},
"view": func() error {
_, err := viewGetProject(map[string]any{"views": []any{"bad"}})
return err
},
"form": func() error {
_, err := formListProject(map[string]any{"views": []any{"bad"}})
return err
},
"workflow": func() error {
_, err := workflowListProject(map[string]any{"workflows": []any{"bad"}})
return err
},
} {
t.Run(name, func(t *testing.T) {
if err := project(); err == nil {
t.Fatal("non-object projection item must fail")
}
})
}
}
func TestCrossPlatformCoverageAITableProjectionValidAndMissingIdentityShapes(t *testing.T) {
bases, err := baseListProject("list_bases", map[string]any{"bases": []any{map[string]any{"baseId": "b", "baseName": "Base"}}})
if err != nil || len(bases) != 1 {
t.Fatalf("base projection = %#v, %v", bases, err)
}
if _, err := templateSearchProject(map[string]any{"templates": []any{map[string]any{"name": "missing"}}}); err == nil {
t.Fatal("template missing ID succeeded")
}
templates, err := templateSearchProject(map[string]any{"templates": []any{map[string]any{"templateId": "t"}}})
if err != nil || len(templates) != 1 {
t.Fatalf("template projection = %#v, %v", templates, err)
}
views, err := viewGetProject(map[string]any{"views": []any{map[string]any{"viewId": "v"}}})
if err != nil || len(views) != 1 {
t.Fatalf("view projection = %#v, %v", views, err)
}
forms, err := formListProject(map[string]any{"views": []any{map[string]any{"viewId": "f"}}})
if err != nil || len(forms) != 1 {
t.Fatalf("form projection = %#v, %v", forms, err)
}
workflows, err := workflowListProject(map[string]any{"workflows": []any{map[string]any{"workflowId": "w"}}})
if err != nil || len(workflows) != 1 {
t.Fatalf("workflow projection = %#v, %v", workflows, err)
}
}
func TestCrossPlatformCoverageCompositeAndReviewedContractFallbacks(t *testing.T) {
err := compositeError(newCompositeResult("test"), errors.New("cause"), true)
if err == nil || !strings.Contains(err.Error(), "unknown") {
t.Fatalf("composite default status = %v", err)
}
unreviewed := shortcut.Shortcut{Command: "+not-reviewed"}
if got := withReviewedAITableShortcutContracts(unreviewed); len(got) != 1 || !got[0].Contract.Empty() {
t.Fatalf("unreviewed shortcut changed: %#v", got)
}
value := shortcut.Shortcut{
Command: "+base-list",
Description: "fallback description",
Tips: []string{"one", "two", "three"},
}
contract := reviewedAITableShortcutContract(value)
if contract.Description != "fallback description" || len(contract.Selection.Examples) != 2 {
t.Fatalf("reviewed contract fallback = %#v", contract)
}
}
func TestCrossPlatformCoverageURLResolveAllVerificationKindsE2E(t *testing.T) {
cases := []struct {
name string
url string
tool string
text string
}{
{name: "record", url: "https://alidocs.dingtalk.com/i/nodes/base?iframeQuery=sheetId%3Dtable%26recordId%3Drecord", tool: "query_records", text: `{"records":[{"record_id":"record"}]}`},
{name: "view", url: "https://alidocs.dingtalk.com/i/nodes/base?iframeQuery=sheetId%3Dtable%26viewId%3Dview", tool: "get_views", text: `{"views":[{"view_id":"view"}]}`},
{name: "base by id", url: "https://alidocs.dingtalk.com/i/nodes/base", tool: "get_base", text: `{"base_id":"base"}`},
{name: "base structural fallback", url: "https://alidocs.dingtalk.com/i/nodes/base", tool: "get_base", text: `{"data":{"tables":[]}}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
caller := &urlResolveE2ECaller{results: []*edition.ToolResult{urlResult(tc.text)}}
out, err := runURLResolveCLI(t, caller, "--url", tc.url, "--verify")
if err != nil || !strings.Contains(out, `"verified": true`) || caller.calls[0].tool != tc.tool {
t.Fatalf("verification = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
}
t.Run("tool error", func(t *testing.T) {
caller := &urlResolveE2ECaller{errors: []error{errors.New("verification failed")}}
out, err := runURLResolveCLI(t, caller, "--url", "https://alidocs.dingtalk.com/i/nodes/base", "--verify")
if err == nil || out != "" {
t.Fatalf("tool error = output:%q err:%v", out, err)
}
})
t.Run("base unknown response", func(t *testing.T) {
caller := &urlResolveE2ECaller{results: []*edition.ToolResult{urlResult(`{"success":true}`)}}
out, err := runURLResolveCLI(t, caller, "--url", "https://alidocs.dingtalk.com/i/nodes/base", "--verify")
if err == nil || out != "" {
t.Fatalf("unknown base = output:%q err:%v", out, err)
}
})
}
func TestCrossPlatformCoverageURLResolveRecursiveShapeHelpers(t *testing.T) {
value := map[string]any{"data": []any{map[string]any{"id": "wanted"}}}
if !responseContainsID(value, "wanted", "id") || responseContainsID(value, "other", "id") {
t.Fatal("responseContainsID recursion mismatch")
}
if !responseHasAnyKey(map[string]any{"data": []any{map[string]any{"tables": []any{}}}}, "tables") {
t.Fatal("responseHasAnyKey recursion mismatch")
}
if responseHasAnyKey([]any{"value"}, "tables") {
t.Fatal("unrelated key must not match")
}
}
func TestCrossPlatformCoverageViewPresetValidationAndDryRunE2E(t *testing.T) {
cases := []struct {
name string
config string
steps []upsertByKeyStep
}{
{name: "invalid config", config: `{`},
{name: "empty config", config: `{}`},
{name: "preflight error", config: `{"f":1}`, steps: []upsertByKeyStep{{err: errors.New("views failed")}}},
{name: "preflight missing list", config: `{"f":1}`, steps: []upsertByKeyStep{{text: `{}`}}},
{name: "matched missing id", config: `{"f":1}`, steps: []upsertByKeyStep{{text: `{"views":[{"viewName":"X","viewType":"Grid"}]}`}}},
{name: "matched type conflict", config: `{"f":1}`, steps: []upsertByKeyStep{{text: `{"views":[{"viewId":"v","viewName":"X","viewType":"Kanban"}]}`}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+view-preset-apply",
"--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", tc.config, "--yes")
if err == nil || out != "" {
t.Fatalf("validation = output:%q err:%v", out, err)
}
})
}
caller := &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: `{"views":[]}`}}}
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
"--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"f":1}`, "--dry-run", "--yes")
if err != nil || !strings.Contains(out, `"status": "planned"`) {
t.Fatalf("dry run = output:%q err:%v", out, err)
}
}
func TestCrossPlatformCoverageViewPresetReadBackFailuresAndUpdateE2E(t *testing.T) {
preflight := `{"views":[{"viewId":"v","viewName":"X","viewType":"Grid","config":{"f":0}}]}`
cases := []struct {
name string
write upsertByKeyStep
readBack upsertByKeyStep
}{
{name: "read error", write: upsertByKeyStep{text: `{"updated":1}`}, readBack: upsertByKeyStep{err: errors.New("read failed")}},
{name: "missing list", write: upsertByKeyStep{text: `{"updated":1}`}, readBack: upsertByKeyStep{text: `{}`}},
{name: "missing exact name", write: upsertByKeyStep{text: `{"updated":1}`}, readBack: upsertByKeyStep{text: `{"views":[]}`}},
{name: "identity mismatch", write: upsertByKeyStep{text: `{"updated":1}`}, readBack: upsertByKeyStep{text: `{"views":[{"viewId":"other","viewName":"X","viewType":"Grid","config":{"f":1}}]}`}},
{name: "dual failure", write: upsertByKeyStep{err: errors.New("write failed")}, readBack: upsertByKeyStep{err: errors.New("read failed")}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: preflight}, tc.write, tc.readBack}}
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
"--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"f":1}`, "--yes")
if err == nil || out != "" {
t.Fatalf("read-back failure = output:%q err:%v", out, err)
}
})
}
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: preflight}, {text: `{"updated":1}`}, {text: `{"views":[{"viewId":"v","viewName":"X","viewType":"Grid","config":{"f":1}}]}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
"--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"f":1}`, "--yes")
if err != nil || !strings.Contains(out, `"action": "update"`) {
t.Fatalf("update success = output:%q err:%v", out, err)
}
}
func TestCrossPlatformCoverageViewPresetPureMatching(t *testing.T) {
if presetViewMatches(map[string]any{"viewType": "Kanban", "config": map[string]any{}}, "Grid", map[string]any{}) {
t.Fatal("type mismatch must fail")
}
if mapContains(map[string]any{}, map[string]any{"missing": 1}) {
t.Fatal("missing key must fail")
}
if mapContains(map[string]any{"nested": 1}, map[string]any{"nested": map[string]any{"x": 1}}) {
t.Fatal("nested type mismatch must fail")
}
if mapContains(map[string]any{"nested": map[string]any{}}, map[string]any{"nested": map[string]any{"x": 1}}) {
t.Fatal("nested value mismatch must fail")
}
if !mapContains(map[string]any{"nested": map[string]any{"x": 1, "extra": true}}, map[string]any{"nested": map[string]any{"x": 1}}) {
t.Fatal("nested subset must match")
}
if mapContains(map[string]any{"x": 1}, map[string]any{"x": 2}) {
t.Fatal("scalar mismatch must fail")
}
}
func TestCrossPlatformCoverageWorkflowDeployValidationAndFailureStagesE2E(t *testing.T) {
cases := []struct {
name string
dsl string
extra []string
steps []upsertByKeyStep
}{
{name: "invalid DSL", dsl: `{`},
{name: "wrong DSL version", dsl: `{"version":"v0"}`},
{name: "update preflight error", dsl: workflowDSLFixture, extra: []string{"--workflow-id", "w"}, steps: []upsertByKeyStep{{err: errors.New("preflight failed")}}},
{name: "update target not found", dsl: workflowDSLFixture, extra: []string{"--workflow-id", "w"}, steps: []upsertByKeyStep{{text: `{"flowId":"other"}`}}},
{name: "publish missing ID", dsl: workflowDSLFixture, steps: []upsertByKeyStep{{text: `{"valid":true}`}}},
{name: "publish ID wrong type", dsl: workflowDSLFixture, steps: []upsertByKeyStep{{text: `{"valid":true,"flowId":1}`}}},
{name: "read-back error", dsl: workflowDSLFixture, steps: []upsertByKeyStep{{text: `{"valid":true,"flowId":"w"}`}, {err: errors.New("read failed")}}},
{name: "read-back wrong ID", dsl: workflowDSLFixture, steps: []upsertByKeyStep{{text: `{"valid":true,"flowId":"w"}`}, {text: `{"flowId":"other"}`}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
args := []string{"--base-id", "base", "--dsl", tc.dsl}
args = append(args, tc.extra...)
args = append(args, "--yes")
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+workflow-deploy", args...)
if err == nil || out != "" {
t.Fatalf("workflow failure = output:%q err:%v", out, err)
}
})
}
caller := &upsertByKeyCaller{dryRun: true}
out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--enable", "--dry-run", "--yes")
if err != nil || !strings.Contains(out, `"status": "planned"`) || !strings.Contains(out, "enable and verify") {
t.Fatalf("workflow dry run = output:%q err:%v", out, err)
}
}
func TestCrossPlatformCoverageWorkflowEnableErrorCanStillBeVerifiedE2E(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"valid":true,"flowId":"w"}`}, {text: `{"flowId":"w"}`},
{err: errors.New("enable reply failed")}, {text: `{"list":[{"flowId":"w","enabled":true}]}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--enable", "--yes")
if err != nil || !strings.Contains(out, "enable response error") {
t.Fatalf("enable recovery = output:%q err:%v", out, err)
}
}
func TestCrossPlatformCoverageWorkflowListFailureShapesE2E(t *testing.T) {
for name, step := range map[string]upsertByKeyStep{
"list error": {err: errors.New("list failed")},
"missing list": {text: `{}`},
"not present": {text: `{"list":[]}`},
} {
t.Run(name, func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"valid":true,"flowId":"w"}`}, {text: `{"flowId":"w"}`}, {text: `{"enabled":true}`}, step,
}}
out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--enable", "--yes")
if err == nil || out != "" {
t.Fatalf("list failure = output:%q err:%v", out, err)
}
})
}
}
func TestCrossPlatformCoverageWorkflowListPaginationAndBoundE2E(t *testing.T) {
page := make([]any, 100)
for index := range page {
page[index] = map[string]any{"flowId": fmt.Sprintf("other-%d", index)}
}
caller := &upsertByKeyCaller{callFn: func(index int, _, tool string, _ map[string]any) (string, error) {
switch index {
case 0:
return `{"valid":true,"flowId":"w"}`, nil
case 1:
return `{"flowId":"w"}`, nil
case 2:
return `{"enabled":true}`, nil
case 3:
return mustJSONText(t, map[string]any{"list": page}), nil
default:
return `{"list":[{"flowId":"w","status":"ACTIVE"}]}`, nil
}
}}
out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--enable", "--yes")
if err != nil || !strings.Contains(out, `"running": true`) || len(caller.calls) != 5 {
t.Fatalf("paginated list = output:%q err:%v calls:%d", out, err, len(caller.calls))
}
fullPage := mustJSONText(t, map[string]any{"list": page})
caller = &upsertByKeyCaller{callFn: func(index int, _, _ string, _ map[string]any) (string, error) {
if index == 0 {
return `{"valid":true,"flowId":"w"}`, nil
}
if index == 1 {
return `{"flowId":"w"}`, nil
}
if index == 2 {
return `{"enabled":true}`, nil
}
return fullPage, nil
}}
out, err = runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--enable", "--yes")
if err == nil || out != "" || len(caller.calls) != 103 {
t.Fatalf("workflow list bound = output:%q err:%v calls:%d", out, err, len(caller.calls))
}
}
func TestCrossPlatformCoverageWorkflowPureResponseHelpers(t *testing.T) {
if _, _, _, err := workflowPublishResult(nil); err == nil {
t.Fatal("nil publish result must fail")
}
if value, found := findBoolByKeys(map[string]any{"data": []any{map[string]any{"enabled": true}}}, "enabled"); !found || !value {
t.Fatal("nested bool not found")
}
if _, found := findBoolByKeys([]any{"value"}, "enabled"); found {
t.Fatal("unrelated bool unexpectedly found")
}
for _, workflow := range []map[string]any{
{"status": "enabled"}, {"state": "active"}, {"isEnabled": true},
} {
if !workflowIsRunning(workflow) {
t.Fatalf("workflow should be running: %#v", workflow)
}
}
if workflowIsRunning(map[string]any{"status": "STOP", "enabled": false}) {
t.Fatal("stopped workflow must not be running")
}
}
@@ -0,0 +1,147 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package aitable
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/shortcut"
)
const reviewedAITableShortcutInterfaceReason = "Reviewed AI Table Shortcut adapter: the executable CLI owns validation, orchestration, output projection, and confirmation; no single pinned MCP interface represents the complete command contract."
// reviewedAITableShortcutContractCommands is the exact migration ledger for
// the 53 executable AI Table shortcuts that were hidden only because they
// predated typed Contract declarations. New shortcuts must declare Safety and
// Contract directly and must never be added to this migration ledger.
var reviewedAITableShortcutContractCommands = map[string]struct{}{
"+advperm-disable": {},
"+advperm-enable": {},
"+attachment-upload": {},
"+base-copy": {},
"+base-delete": {},
"+base-get-primary-doc-id": {},
"+base-update": {},
"+chart-delete": {},
"+chart-share-get": {},
"+chart-share-update": {},
"+chart-update": {},
"+dashboard-arrange": {},
"+dashboard-delete": {},
"+dashboard-share-get": {},
"+dashboard-share-update": {},
"+dashboard-update": {},
"+export-data": {},
"+field-delete": {},
"+field-update": {},
"+form-delete": {},
"+form-field-hide": {},
"+form-field-update": {},
"+form-share-update": {},
"+form-update": {},
"+import-data": {},
"+import-upload": {},
"+record-delete": {},
"+record-primary-doc-create": {},
"+record-primary-doc-get": {},
"+record-update": {},
"+record-upsert": {},
"+role-create": {},
"+role-delete": {},
"+role-get": {},
"+role-update": {},
"+section-create": {},
"+section-delete": {},
"+section-move-node": {},
"+section-rename": {},
"+section-reorder": {},
"+table-delete": {},
"+table-update": {},
"+view-delete": {},
"+view-duplicate": {},
"+view-lock": {},
"+view-set-fill-color-rule": {},
"+view-set-frozen-cols": {},
"+view-set-row-height": {},
"+view-update": {},
"+workflow-disable": {},
"+workflow-enable": {},
"+workflow-get": {},
"+workflow-list": {},
}
func withReviewedAITableShortcutContracts(values ...shortcut.Shortcut) []shortcut.Shortcut {
out := make([]shortcut.Shortcut, len(values))
for index, value := range values {
out[index] = value
if !value.Contract.Empty() {
continue
}
if _, reviewed := reviewedAITableShortcutContractCommands[value.Command]; !reviewed {
continue
}
out[index].Safety = reviewedAITableShortcutSafety(value.Risk)
out[index].Contract = reviewedAITableShortcutContract(value)
}
return out
}
func reviewedAITableShortcutSafety(risk shortcut.Risk) contract.SafetySpec {
switch risk {
case shortcut.RiskWrite:
return contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
}
case shortcut.RiskHighWrite:
return contract.SafetySpec{
Effect: "destructive", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
}
default:
return contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
}
}
}
func reviewedAITableShortcutContract(value shortcut.Shortcut) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(value.Command, "+"), "-", "_")
cliPath := "aitable " + value.Command
description := strings.TrimSpace(value.Intent)
if description == "" {
description = strings.TrimSpace(value.Description)
}
examples := append([]string(nil), value.Tips...)
if len(examples) > 2 {
examples = examples[:2]
}
return corecmd.ContractDecl{
Title: value.Description,
Description: description,
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeComposite,
Availability: contract.InterfaceAvailable,
Reason: reviewedAITableShortcutInterfaceReason,
},
Selection: contract.SelectionSpec{
AgentSummary: value.Description,
UseWhen: []string{description},
AvoidWhen: []string{
"只需未经适配的底层参数或原始响应时改用对应 aitable 原子命令;电子表格单元格操作改用 sheet",
},
Examples: examples,
},
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: name,
CanonicalPath: "aitable." + name,
CLIPath: cliPath,
PrimaryCLIPath: cliPath,
},
}
}
@@ -0,0 +1,34 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageAITableLegacyContractLedgerIsExact(t *testing.T) {
if got, want := len(reviewedAITableShortcutContractCommands), 53; got != want {
t.Fatalf("legacy contract migration ledger = %d, want %d", got, want)
}
for command := range reviewedAITableShortcutContractCommands {
found := false
for _, item := range shortcut.All() {
if item.Service == "aitable" && item.Command == command {
found = true
if item.Contract.Empty() {
t.Errorf("%s: migration did not deliver Contract", command)
}
if item.Safety.Confirmation == "" {
t.Errorf("%s: migration did not deliver explicit Safety", command)
}
break
}
}
if !found {
t.Errorf("stale migration ledger entry %s", command)
}
}
}
+244
View File
@@ -0,0 +1,244 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"fmt"
"strings"
"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 TableCopy = shortcut.Shortcut{
Service: "aitable",
Command: "+table-copy",
Product: serverMain,
Description: "跨 Base 同步复制一张表的可创建字段结构,并可同步复制全部记录",
Intent: "当服务端没有 table copy/task 接口、但你需要在目标 Base 重建一张表时使用;本地编排字段分片、fieldId 映射、记录分片和读回验证。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: aitableCompositeContract(
"+table-copy",
"跨 Base 同步复制一张表的可创建字段结构,并可同步复制全部记录",
"当服务端没有 table copy/task 接口、但你需要在目标 Base 重建一张表时使用;本地编排字段分片、fieldId 映射、记录分片和读回验证。",
"复制整个 Base 用 base copy;需要复制公式、查找引用、关联字段或所有视图时先用 schema snapshot 手工处理这些无法安全重映射的依赖",
`dws aitable +table-copy --source-base-id B1 --source-table-id T1 --target-base-id B2 --new-name "任务副本"`,
),
Flags: []shortcut.Flag{
{Name: "source-base-id", Type: shortcut.FlagString, Desc: "源 Base ID", Required: true},
{Name: "source-table-id", Type: shortcut.FlagString, Desc: "源 Table ID", Required: true},
{Name: "target-base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true},
{Name: "new-name", Type: shortcut.FlagString, Desc: "目标表名", Required: true},
{Name: "include-records", Type: shortcut.FlagBool, Desc: "复制全部记录;默认只复制可安全重建的字段结构"},
{Name: "max-records", Type: shortcut.FlagInt, Default: "10000", Desc: "复制记录的写前上限,1-10000"},
},
Tips: []string{`dws aitable +table-copy --source-base-id B1 --source-table-id T1 --target-base-id B2 --new-name "任务副本"`},
Execute: func(rt *shortcut.RuntimeContext) error {
return executeTableCopy(rt)
},
}
var tableCopyUnsupportedTypes = map[string]string{
"creator": "system field", "lastModifier": "system field", "createdTime": "system field", "lastModifiedTime": "system field",
"formula": "formula references field names and may be unsupported by the service",
"filterUp": "lookup configuration contains source field/table IDs",
"lookup": "lookup configuration contains source field IDs",
"unidirectionalLink": "link configuration contains a source table ID",
"bidirectionalLink": "link configuration contains a source table ID and creates a reverse field",
}
func executeTableCopy(rt *shortcut.RuntimeContext) error {
sourceBase, sourceTable := rt.Str("source-base-id"), rt.Str("source-table-id")
targetBase := rt.Str("target-base-id")
maxRecords := rt.Int("max-records")
if maxRecords < 1 || maxRecords > maxCompositeRecordRun {
return apperrors.NewValidation(fmt.Sprintf("--max-records 必须在 1..%d", maxCompositeRecordRun))
}
sourceFieldsData, err := rt.CallMCPData(serverMain, "get_fields", map[string]any{"baseId": sourceBase, "tableId": sourceTable})
if err != nil {
return err
}
sourceFields, found := findNamedObjectList(sourceFieldsData, "fields", "fieldList")
if !found {
return fmt.Errorf("source get_fields response is missing the fields collection")
}
createFields := make([]any, 0, len(sourceFields))
copiedSourceFields := make([]map[string]any, 0, len(sourceFields))
warnings := make([]string, 0)
for _, field := range sourceFields {
fieldID := stringValue(field, "fieldId", "id")
name := stringValue(field, "fieldName", "name")
typeName := stringValue(field, "type", "fieldType")
if fieldID == "" || name == "" || typeName == "" {
return fmt.Errorf("source field is missing fieldId, fieldName, or type")
}
if reason := tableCopyUnsupportedTypes[normalizedFieldType(typeName)]; reason != "" {
warnings = append(warnings, fmt.Sprintf("skipped field %q (%s): %s", name, typeName, reason))
continue
}
declaration := map[string]any{"fieldName": name, "type": typeName}
for _, key := range []string{"config", "aiConfig"} {
if value, exists := field[key]; exists {
declaration[key] = value
}
}
createFields = append(createFields, declaration)
copiedSourceFields = append(copiedSourceFields, field)
}
var sourceRecords []map[string]any
if rt.Bool("include-records") {
sourceRecords, err = queryAllRecords(rt, map[string]any{"baseId": sourceBase, "tableId": sourceTable}, maxRecords)
if err != nil {
return err
}
}
result := newCompositeResult("table_copy")
result.RequestedCount = len(sourceRecords)
result.Resolved = map[string]any{"sourceBaseId": sourceBase, "sourceTableId": sourceTable, "targetBaseId": targetBase}
result.Warnings = warnings
result.Plan = []compositeStep{{Index: 1, Name: "create target table", Tool: "create_table", Status: "planned", Count: len(createFields)}}
if len(sourceRecords) > 0 {
result.Plan = append(result.Plan, compositeStep{Index: 2, Name: "copy records", Tool: "create_records", Status: "planned", Count: len(sourceRecords)})
}
if rt.DryRun() {
result.Status = "planned"
result.Executed = false
result.Result = map[string]any{"fieldCount": len(createFields), "recordCount": len(sourceRecords)}
return rt.Output(result)
}
initialEnd := minInt(15, len(createFields))
createData, err := rt.CallMCPWriteDataStrict(serverMain, "create_table", map[string]any{
"baseId": targetBase, "tableName": rt.Str("new-name"), "fields": createFields[:initialEnd],
})
targetTable := findStringByKeys(createData, "tableId", "sheetId")
if err != nil || targetTable == "" {
if err == nil {
err = fmt.Errorf("create_table response is missing target tableId")
}
result.Status = "unknown"
result.Checkpoint = map[string]any{"nextStep": "locate the target table by exact name before retrying"}
return compositeError(result, err, false)
}
result.Resolved["targetTableId"] = targetTable
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "create_table", "baseId": targetBase, "tableId": targetTable})
for offset := initialEnd; offset < len(createFields); offset += 15 {
end := minInt(offset+15, len(createFields))
_, fieldErr := rt.CallMCPWriteDataStrict(serverMain, "create_fields", map[string]any{"baseId": targetBase, "tableId": targetTable, "fields": createFields[offset:end]})
if fieldErr != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("create_fields offset %d returned an error; final schema verification decides success: %v", offset, fieldErr))
}
}
targetFieldsData, err := rt.CallMCPData(serverMain, "get_fields", map[string]any{"baseId": targetBase, "tableId": targetTable})
targetFields, found := findNamedObjectList(targetFieldsData, "fields", "fieldList")
if err != nil || !found || !containsAllFieldNames(targetFields, createFields) {
if err == nil {
err = fmt.Errorf("target field read-back does not contain the copied field set")
}
result.Status = "partial_success"
result.Checkpoint = map[string]any{"targetTableId": targetTable, "step": "repair target fields"}
return compositeError(result, err, false)
}
result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: 1, Name: "create target table", Tool: "create_table", Status: "completed", Count: len(createFields), Result: map[string]any{"tableId": targetTable}})
fieldMap, err := mapCopiedFieldIDs(copiedSourceFields, targetFields)
if err != nil {
result.Status = "partial_success"
result.Checkpoint = map[string]any{"targetTableId": targetTable, "step": "resolve field mapping"}
return compositeError(result, err, false)
}
createdCount := 0
for offset := 0; offset < len(sourceRecords); offset += recordBatchSize {
end := minInt(offset+recordBatchSize, len(sourceRecords))
batch := make([]map[string]any, 0, end-offset)
wire := make([]any, 0, end-offset)
for _, sourceRecord := range sourceRecords[offset:end] {
sourceCells, ok := sourceRecord["cells"].(map[string]any)
if !ok {
return compositeError(result, fmt.Errorf("source record %s is missing cells", recordID(sourceRecord)), false)
}
targetCells := map[string]any{}
for sourceFieldID, value := range sourceCells {
if targetFieldID := fieldMap[sourceFieldID]; targetFieldID != "" {
targetCells[targetFieldID] = value
}
}
record := map[string]any{"cells": targetCells}
batch = append(batch, record)
wire = append(wire, record)
}
writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, "create_records", map[string]any{"baseId": targetBase, "tableId": targetTable, "records": wire})
createdIDs := createdRecordIDs(writeData)
if len(createdIDs) == 0 {
if returned, found := findRecords(writeData); found {
createdIDs = recordIDs(returned)
}
}
if len(createdIDs) != len(batch) {
if writeErr == nil {
writeErr = fmt.Errorf("create_records returned %d record IDs for %d copied records", len(createdIDs), len(batch))
}
result.Status = "partial_success"
result.CompletedCount = createdCount
result.FailedCount = len(sourceRecords) - createdCount
result.Checkpoint = map[string]any{"targetTableId": targetTable, "nextRecordOffset": offset}
return compositeError(result, writeErr, false)
}
actual, verifyErr := queryRecordsByIDs(rt, targetBase, targetTable, createdIDs)
if verifyErr == nil {
verifyErr = matchCreatedCells(batch, actual)
}
if verifyErr != nil {
result.Status = "partial_success"
result.CompletedCount = createdCount
result.FailedCount = len(sourceRecords) - createdCount
result.Checkpoint = map[string]any{"targetTableId": targetTable, "nextRecordOffset": offset}
return compositeError(result, verifyErr, false)
}
createdCount = end
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "create_records", "offset": offset, "recordIds": createdIDs})
}
result.CompletedCount = createdCount
result.Verification = map[string]any{"status": "verified", "fieldCount": len(createFields), "recordCount": createdCount}
result.Result = map[string]any{"targetBaseId": targetBase, "targetTableId": targetTable, "fieldCount": len(createFields), "recordCount": createdCount}
return rt.Output(result)
}
func mapCopiedFieldIDs(source, target []map[string]any) (map[string]string, error) {
targetByName := map[string]string{}
for _, field := range target {
name := stringValue(field, "fieldName", "name")
id := stringValue(field, "fieldId", "id")
if name != "" && id != "" {
if targetByName[name] != "" {
return nil, fmt.Errorf("target has duplicate field name %q", name)
}
targetByName[name] = id
}
}
out := map[string]string{}
for _, field := range source {
name := stringValue(field, "fieldName", "name")
sourceID := stringValue(field, "fieldId", "id")
if targetID := targetByName[name]; targetID != "" {
out[sourceID] = targetID
} else {
return nil, fmt.Errorf("target is missing copied field %q", name)
}
}
return out, nil
}
func normalizedFieldType(value string) string {
for key := range tableCopyUnsupportedTypes {
if strings.EqualFold(key, value) {
return key
}
}
return value
}
@@ -0,0 +1,173 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"encoding/json"
"errors"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
func TestCrossPlatformCoverageRecordBulkPatchPaginatesChunksAndVerifiesE2E(t *testing.T) {
records := updateFixtureRecords(0, 101, "旧")
firstPage := map[string]any{"records": records[:100], "hasMore": true, "nextCursor": "next"}
secondPage := map[string]any{"records": records[100:], "hasMore": false}
patchedFirst := updateFixtureRecords(0, 100, "新")
patchedSecond := updateFixtureRecords(100, 1, "新")
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: mustJSONText(t, firstPage)},
{text: mustJSONText(t, secondPage)},
{text: `{"updatedCount":100}`},
{text: recordListJSON(t, patchedFirst)},
{text: `{"updatedCount":1}`},
{text: recordListJSON(t, patchedSecond)},
}}
out, err := runAITableCompositeCLI(t, caller, "+record-bulk-patch",
"--base-id", "base", "--table-id", "table", "--all", "--patch", `{"fldStatus":"新"}`, "--yes")
if err != nil {
t.Fatalf("bulk patch error = %v", err)
}
for _, want := range []string{`"verifiedCount": 101`, `"processedCount": 101`, `"batchCount": 2`} {
if !strings.Contains(out, want) {
t.Fatalf("bulk patch output missing %s: %s", want, out)
}
}
if len(caller.calls) != 6 || caller.calls[1].args["cursor"] != "next" || caller.calls[2].tool != "update_records" {
t.Fatalf("bulk patch calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageRecordBulkPatchPaginatesNestedCursorShapeE2E(t *testing.T) {
records := updateFixtureRecords(0, 2, "旧")
patched := updateFixtureRecords(0, 2, "新")
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: mustJSONText(t, map[string]any{"data": map[string]any{"records": records[:1], "hasMore": true, "cursor": "legacy-next"}})},
{text: mustJSONText(t, map[string]any{"data": map[string]any{"records": records[1:], "hasMore": false}})},
{text: `{"updatedCount":2}`},
{text: recordListJSON(t, patched)},
}}
out, err := runAITableCompositeCLI(t, caller, "+record-bulk-patch",
"--base-id", "base", "--table-id", "table", "--all", "--patch", `{"fldStatus":"新"}`, "--yes")
if err != nil || !strings.Contains(out, `"verifiedCount": 2`) {
t.Fatalf("nested cursor bulk patch = output:%q err:%v", out, err)
}
if len(caller.calls) != 4 || caller.calls[1].args["cursor"] != "legacy-next" || caller.calls[2].tool != "update_records" {
t.Fatalf("nested cursor calls = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageRecordBulkPatchSelectionBoundsAndEmptyE2E(t *testing.T) {
t.Run("explicit no matches is success", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"records":[]}`}}}
out, err := runAITableCompositeCLI(t, caller, "+record-bulk-patch",
"--base-id", "base", "--table-id", "table", "--query", "none", "--patch", `{"fld":"x"}`, "--yes")
if err != nil || len(caller.calls) != 1 || !strings.Contains(out, `"status": "verified_no_matches"`) {
t.Fatalf("empty patch = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("missing selector stops before reads", func(t *testing.T) {
caller := &upsertByKeyCaller{}
out, err := runAITableCompositeCLI(t, caller, "+record-bulk-patch",
"--base-id", "base", "--table-id", "table", "--patch", `{"fld":"x"}`, "--yes")
if err == nil || out != "" || len(caller.calls) != 0 {
t.Fatalf("missing selector = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("max matches stops before writes", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: recordListJSON(t, updateFixtureRecords(0, 2, "旧"))}}}
out, err := runAITableCompositeCLI(t, caller, "+record-bulk-patch",
"--base-id", "base", "--table-id", "table", "--all", "--patch", `{"fld":"x"}`, "--max-matches", "1", "--yes")
if err == nil || out != "" || len(caller.calls) != 1 {
t.Fatalf("bounded patch = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
}
func mustJSONText(t *testing.T, value any) string {
t.Helper()
raw, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func sourceFieldFixture() map[string]any {
return map[string]any{"fieldId": "sf1", "fieldName": "状态", "type": "text"}
}
func targetFieldFixture() map[string]any {
return map[string]any{"fieldId": "tf1", "fieldName": "状态", "type": "text"}
}
func TestCrossPlatformCoverageTableCopyStructureAndRecordsE2E(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: mustJSONText(t, map[string]any{"fields": []any{sourceFieldFixture()}})},
{text: `{"records":[{"recordId":"sr1","cells":{"sf1":"完成"}}]}`},
{text: `{"data":{"tableId":"target-table"}}`},
{text: mustJSONText(t, map[string]any{"fields": []any{targetFieldFixture()}})},
{text: `{"createdRecords":[{"recordId":"tr1"}]}`},
{text: `{"records":[{"recordId":"tr1","cells":{"tf1":"完成"}}]}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+table-copy",
"--source-base-id", "source-base", "--source-table-id", "source-table",
"--target-base-id", "target-base", "--new-name", "任务副本", "--include-records", "--yes")
if err != nil {
t.Fatalf("table copy error = %v", err)
}
for _, want := range []string{`"targetTableId": "target-table"`, `"fieldCount": 1`, `"recordCount": 1`, `"status": "verified"`} {
if !strings.Contains(out, want) {
t.Fatalf("table copy output missing %s: %s", want, out)
}
}
if len(caller.calls) != 6 || caller.calls[4].tool != "create_records" {
t.Fatalf("table copy calls = %#v", caller.calls)
}
written := caller.calls[4].args["records"].([]any)[0].(map[string]any)["cells"].(map[string]any)
if written["tf1"] != "完成" || written["sf1"] != nil {
t.Fatalf("mapped record cells = %#v", written)
}
}
func TestCrossPlatformCoverageTableCopyUnknownResponsesAreNotSuccessE2E(t *testing.T) {
t.Run("missing target table id", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: mustJSONText(t, map[string]any{"fields": []any{sourceFieldFixture()}})},
{text: `{}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+table-copy",
"--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--yes")
if err == nil || out != "" {
t.Fatalf("missing target id = output:%q err:%v", out, err)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" || typed.Retryable {
t.Fatalf("missing target id error = %#v", err)
}
})
t.Run("created records without ids", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: mustJSONText(t, map[string]any{"fields": []any{sourceFieldFixture()}})},
{text: `{"records":[{"recordId":"sr1","cells":{"sf1":"完成"}}]}`},
{text: `{"tableId":"target"}`},
{text: mustJSONText(t, map[string]any{"fields": []any{targetFieldFixture()}})},
{text: `{}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+table-copy",
"--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--include-records", "--yes")
if err == nil || out != "" {
t.Fatalf("missing record ids = output:%q err:%v", out, err)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "aitable_composite_partial_success" {
t.Fatalf("missing record ids error = %#v", err)
}
})
}
@@ -0,0 +1,172 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"errors"
"fmt"
"strings"
"testing"
)
func copyFields(count int, prefix string) []any {
fields := make([]any, 0, count)
for index := 0; index < count; index++ {
fields = append(fields, map[string]any{
"fieldId": fmt.Sprintf("%s%d", prefix, index),
"fieldName": fmt.Sprintf("F%d", index),
"type": "text",
})
}
return fields
}
func TestCrossPlatformCoverageTableCopyValidationAndDryRunE2E(t *testing.T) {
t.Run("invalid maximum", func(t *testing.T) {
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+table-copy",
"--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--max-records", "0", "--yes")
if err == nil || out != "" {
t.Fatalf("invalid maximum = output:%q err:%v", out, err)
}
})
t.Run("source read error", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{err: errors.New("source fields failed")}}}
out, err := runAITableCompositeCLI(t, caller, "+table-copy",
"--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--yes")
if err == nil || out != "" {
t.Fatalf("source read = output:%q err:%v", out, err)
}
})
t.Run("source fields collection missing", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{}`}}}
out, err := runAITableCompositeCLI(t, caller, "+table-copy",
"--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--yes")
if err == nil || out != "" {
t.Fatalf("source fields collection = output:%q err:%v", out, err)
}
})
t.Run("invalid source field", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"fields":[{"fieldName":"F","type":"text"}]}`}}}
out, err := runAITableCompositeCLI(t, caller, "+table-copy",
"--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--yes")
if err == nil || out != "" {
t.Fatalf("invalid source field = output:%q err:%v", out, err)
}
})
t.Run("dry run skips unsupported and preserves config", func(t *testing.T) {
payload := map[string]any{"fields": []any{
map[string]any{"fieldId": "skip", "fieldName": "Formula", "type": "FORMULA"},
map[string]any{"fieldId": "keep", "fieldName": "Text", "type": "text", "config": map[string]any{"x": 1}, "aiConfig": map[string]any{"y": 2}},
}}
caller := &upsertByKeyCaller{dryRun: true, steps: []upsertByKeyStep{{text: mustJSONText(t, payload)}}}
out, err := runAITableCompositeCLI(t, caller, "+table-copy",
"--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--dry-run", "--yes")
if err != nil || !strings.Contains(out, `"status": "planned"`) || !strings.Contains(out, "skipped field") {
t.Fatalf("dry run = output:%q err:%v", out, err)
}
})
t.Run("source records query error", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"fields":[]}`}, {err: errors.New("records failed")}}}
out, err := runAITableCompositeCLI(t, caller, "+table-copy",
"--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--include-records", "--yes")
if err == nil || out != "" {
t.Fatalf("source records = output:%q err:%v", out, err)
}
})
}
func TestCrossPlatformCoverageTableCopySchemaFailureStagesE2E(t *testing.T) {
source := mustJSONText(t, map[string]any{"fields": copyFields(1, "s")})
cases := []struct {
name string
steps []upsertByKeyStep
}{
{name: "target fields error", steps: []upsertByKeyStep{{text: source}, {text: `{"tableId":"target"}`}, {err: errors.New("target fields failed")}}},
{name: "target fields mismatch", steps: []upsertByKeyStep{{text: source}, {text: `{"tableId":"target"}`}, {text: `{"fields":[]}`}}},
{name: "target mapping duplicate", steps: []upsertByKeyStep{
{text: source}, {text: `{"tableId":"target"}`},
{text: `{"fields":[{"fieldId":"t1","fieldName":"F0","type":"text"},{"fieldId":"t2","fieldName":"F0","type":"text"}]}`},
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+table-copy",
"--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--yes")
if err == nil || out != "" {
t.Fatalf("schema failure = output:%q err:%v", out, err)
}
})
}
}
func TestCrossPlatformCoverageTableCopyFieldChunkErrorRecoveredE2E(t *testing.T) {
sourceFields := copyFields(16, "s")
targetFields := copyFields(16, "t")
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: mustJSONText(t, map[string]any{"fields": sourceFields})},
{text: `{"tableId":"target"}`},
{err: errors.New("create fields reply failed")},
{text: mustJSONText(t, map[string]any{"fields": targetFields})},
}}
out, err := runAITableCompositeCLI(t, caller, "+table-copy",
"--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--yes")
if err != nil || !strings.Contains(out, "create_fields offset") || !strings.Contains(out, `"fieldCount": 16`) {
t.Fatalf("field chunk recovery = output:%q err:%v", out, err)
}
}
func TestCrossPlatformCoverageTableCopyRecordFailureStagesE2E(t *testing.T) {
source := mustJSONText(t, map[string]any{"fields": []any{sourceFieldFixture()}})
target := mustJSONText(t, map[string]any{"fields": []any{targetFieldFixture()}})
cases := []struct {
name string
record string
after []upsertByKeyStep
}{
{name: "source record missing cells", record: `{"records":[{"recordId":"source"}]}`},
{name: "created IDs read-back error", record: `{"records":[{"recordId":"source","cells":{"sf1":"v"}}]}`, after: []upsertByKeyStep{
{text: `{"createdRecords":[{"recordId":"target-record"}]}`}, {err: errors.New("target record read failed")},
}},
{name: "created records mismatch", record: `{"records":[{"recordId":"source","cells":{"sf1":"v"}}]}`, after: []upsertByKeyStep{
{text: `{"createdRecords":[{"recordId":"target-record"}]}`}, {text: `{"records":[{"recordId":"target-record","cells":{"tf1":"wrong"}}]}`},
}},
{name: "fallback records list", record: `{"records":[{"recordId":"source","cells":{"sf1":"v"}}]}`, after: []upsertByKeyStep{
{text: `{"records":[{"recordId":"target-record"}]}`}, {text: `{"records":[{"recordId":"target-record","cells":{"tf1":"v"}}]}`},
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
steps := []upsertByKeyStep{{text: source}, {text: tc.record}, {text: `{"tableId":"target"}`}, {text: target}}
steps = append(steps, tc.after...)
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: steps}, "+table-copy",
"--source-base-id", "b1", "--source-table-id", "t1", "--target-base-id", "b2", "--new-name", "copy", "--include-records", "--yes")
if tc.name == "fallback records list" {
if err != nil || !strings.Contains(out, `"recordCount": 1`) {
t.Fatalf("fallback success = output:%q err:%v", out, err)
}
return
}
if err == nil || out != "" {
t.Fatalf("record failure = output:%q err:%v", out, err)
}
})
}
}
func TestCrossPlatformCoverageTableCopyPureHelpers(t *testing.T) {
if _, err := mapCopiedFieldIDs(nil, []map[string]any{{"fieldId": "a", "fieldName": "same"}, {"fieldId": "b", "fieldName": "same"}}); err == nil {
t.Fatal("duplicate target field must fail")
}
if _, err := mapCopiedFieldIDs([]map[string]any{{"fieldId": "s", "fieldName": "missing"}}, nil); err == nil {
t.Fatal("missing target field must fail")
}
if normalizedFieldType("FoRmUlA") != "formula" || normalizedFieldType("custom") != "custom" {
t.Fatal("field type normalization mismatch")
}
}
+187
View File
@@ -0,0 +1,187 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"fmt"
"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/aitabletarget"
)
// URLResolve parses a documented DingTalk AI Table URL into stable target IDs.
var URLResolve = shortcut.Shortcut{
Service: "aitable",
Command: "+url-resolve",
Product: serverMain,
Description: "解析 AI 表格 URL 中的 baseId/tableId/viewId/recordId",
Intent: "已有钉钉 AI 表格链接、需要提取后续命令使用的稳定 ID 时使用;默认只做严格本地解析,--verify 会调用对应只读接口确认最深层目标真实存在。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "shortcut_url_resolve",
CanonicalPath: "aitable.shortcut_url_resolve",
CLIPath: "aitable +url-resolve",
PrimaryCLIPath: "aitable +url-resolve",
},
Description: "解析 AI 表格 URL 中的 baseId/tableId/viewId/recordId。",
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeComposite,
Availability: contract.InterfaceAvailable,
Reason: "The command performs strict local URL decoding and can optionally verify the deepest target through a read-only AI Table interface.",
},
Selection: contract.SelectionSpec{
AgentSummary: "解析 AI 表格 URL 中的 baseId/tableId/viewId/recordId",
UseWhen: []string{"已有钉钉 AI 表格链接、需要提取后续命令使用的稳定 ID 时使用;默认只做严格本地解析,--verify 会调用对应只读接口确认最深层目标真实存在。"},
AvoidWhen: []string{"只有资源名称而没有 URL 时改用 +resolve-base / +resolve-table;普通文档 URL 用 doc/drive"},
Examples: []string{
"dws aitable +url-resolve --url https://alidocs.dingtalk.com/i/nodes/BASE_ID",
"dws aitable +url-resolve --url 'https://alidocs.dingtalk.com/i/nodes/BASE_ID?iframeQuery=sheetId%3DTABLE_ID%26viewId%3DVIEW_ID' --verify",
},
},
Parameters: []contract.ParamDecl{
{Name: "url", Description: "钉钉 AI 表格节点 URL"},
{Name: "verify", Description: "通过只读接口验证最深层目标存在"},
},
},
Flags: []shortcut.Flag{
{Name: "url", Type: shortcut.FlagString, Desc: "AI 表格 URL", Required: true},
{Name: "verify", Type: shortcut.FlagBool, Default: "false", Desc: "调用只读接口验证最深层目标存在"},
},
Tips: []string{
`dws aitable +url-resolve --url "https://alidocs.dingtalk.com/i/nodes/BASE_ID?iframeQuery=sheetId%3DTABLE_ID"`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
target, err := aitabletarget.ParseURL(rt.Str("url"))
if err != nil {
return err
}
output := map[string]any{
"status": "resolved",
"target": target,
"verified": false,
"verificationStatus": "not_requested",
}
if rt.Bool("verify") {
verification, err := verifyURLTarget(rt, target)
if err != nil {
return err
}
output["verified"] = true
output["verificationStatus"] = "verified"
output["verification"] = verification
}
return rt.Output(output)
},
}
func verifyURLTarget(rt *shortcut.RuntimeContext, target aitabletarget.Target) (map[string]any, error) {
var (
tool string
params map[string]any
idKeys []string
expectedID string
)
switch target.Kind {
case "record":
tool = "query_records"
params = map[string]any{"baseId": target.BaseID, "tableId": target.TableID, "recordIds": []string{target.RecordID}}
idKeys, expectedID = []string{"recordId", "record_id", "id"}, target.RecordID
case "view":
tool = "get_views"
params = map[string]any{"baseId": target.BaseID, "tableId": target.TableID, "viewIds": []string{target.ViewID}}
idKeys, expectedID = []string{"viewId", "view_id", "id"}, target.ViewID
case "table":
tool = "get_tables"
params = map[string]any{"baseId": target.BaseID, "tableIds": []string{target.TableID}}
idKeys, expectedID = []string{"tableId", "table_id", "id"}, target.TableID
default:
tool = "get_base"
params = map[string]any{"baseId": target.BaseID}
idKeys, expectedID = []string{"baseId", "base_id", "id"}, target.BaseID
}
data, err := rt.CallMCPData(serverMain, tool, params)
if err != nil {
return nil, err
}
matched := responseContainsID(data, expectedID, idKeys...)
if target.Kind == "base" && !matched {
matched = responseHasAnyKey(data, "baseName", "base_name", "tables", "dashboards")
}
if !matched {
return nil, apperrors.NewAPI(fmt.Sprintf("%s did not prove that %s %q exists", tool, target.Kind, expectedID),
apperrors.WithOperation("aitable/"+tool),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("target_verification"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
apperrors.WithReason("target_verification_failed"),
apperrors.WithHint("接口返回成功但缺少目标 ID/资源结构,不能把未知响应当作已验证"),
apperrors.WithDetails(map[string]any{"target": target}),
)
}
return map[string]any{
"tool": tool,
"entityType": target.Kind,
"id": expectedID,
"status": "verified",
}, nil
}
func responseContainsID(value any, expected string, keys ...string) bool {
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
for _, candidateKey := range keys {
if key == candidateKey {
if text, ok := child.(string); ok && strings.TrimSpace(text) == expected {
return true
}
}
}
if responseContainsID(child, expected, keys...) {
return true
}
}
case []any:
for _, child := range typed {
if responseContainsID(child, expected, keys...) {
return true
}
}
}
return false
}
func responseHasAnyKey(value any, keys ...string) bool {
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
for _, candidateKey := range keys {
if key == candidateKey {
return true
}
}
if responseHasAnyKey(child, keys...) {
return true
}
}
case []any:
for _, child := range typed {
if responseHasAnyKey(child, keys...) {
return true
}
}
}
return false
}
@@ -0,0 +1,152 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"bytes"
"context"
"errors"
"reflect"
"testing"
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"
"github.com/spf13/cobra"
)
type urlResolveE2ECaller struct {
results []*edition.ToolResult
errors []error
calls []aitableURLCall
}
type aitableURLCall struct {
product string
tool string
args map[string]any
}
func (c *urlResolveE2ECaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
cloned := make(map[string]any, len(args))
for key, value := range args {
cloned[key] = value
}
c.calls = append(c.calls, aitableURLCall{product: product, tool: tool, args: cloned})
index := len(c.calls) - 1
if index < len(c.errors) && c.errors[index] != nil {
return nil, c.errors[index]
}
if index >= len(c.results) {
return nil, errors.New("unexpected URL resolver tool call")
}
return c.results[index], nil
}
func (*urlResolveE2ECaller) Format() string { return "json" }
func (*urlResolveE2ECaller) DryRun() bool { return false }
func (*urlResolveE2ECaller) Fields() string { return "" }
func (*urlResolveE2ECaller) JQ() string { return "" }
func urlResult(text string) *edition.ToolResult {
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}
}
func runURLResolveCLI(t *testing.T, caller *urlResolveE2ECaller, args ...string) (string, error) {
t.Helper()
helpers.InitDepsForTest(t, caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
root.AddCommand(shortcut.Commands()...)
stdout := &bytes.Buffer{}
root.SetOut(stdout)
root.SetErr(&bytes.Buffer{})
root.SetArgs(append([]string{"aitable", "+url-resolve"}, args...))
err := root.Execute()
return stdout.String(), err
}
func TestCrossPlatformCoverageURLResolveCLIParseOnlyE2E(t *testing.T) {
caller := &urlResolveE2ECaller{}
out, err := runURLResolveCLI(t, caller,
"--url", "https://alidocs.dingtalk.com/i/nodes/base-1?iframeQuery=sheetId%3Dtable-1%26viewId%3Dview-1",
)
if err != nil {
t.Fatalf("URL resolver returned error: %v", err)
}
for _, want := range []string{`"status": "resolved"`, `"baseId": "base-1"`, `"tableId": "table-1"`, `"viewId": "view-1"`, `"verified": false`} {
if !bytes.Contains([]byte(out), []byte(want)) {
t.Fatalf("output missing %s:\n%s", want, out)
}
}
if len(caller.calls) != 0 {
t.Fatalf("parse-only resolver made MCP calls: %#v", caller.calls)
}
}
func TestCrossPlatformCoverageURLResolveCLIVerifiesDeepestTargetE2E(t *testing.T) {
caller := &urlResolveE2ECaller{results: []*edition.ToolResult{
urlResult(`{"data":{"tables":[{"tableId":"table-1","tableName":"任务"}]}}`),
}}
out, err := runURLResolveCLI(t, caller,
"--url", "https://alidocs.dingtalk.com/i/nodes/base-1?iframeQuery=sheetId%3Dtable-1",
"--verify",
)
if err != nil {
t.Fatalf("verified URL resolver returned error: %v", err)
}
if !bytes.Contains([]byte(out), []byte(`"verificationStatus": "verified"`)) || !bytes.Contains([]byte(out), []byte(`"tool": "get_tables"`)) {
t.Fatalf("verified output = %s", out)
}
if len(caller.calls) != 1 || caller.calls[0].product != "aitable" || caller.calls[0].tool != "get_tables" {
t.Fatalf("verification calls = %#v", caller.calls)
}
wantArgs := map[string]any{"baseId": "base-1", "tableIds": []string{"table-1"}}
if !reflect.DeepEqual(caller.calls[0].args, wantArgs) {
t.Fatalf("verification args = %#v, want %#v", caller.calls[0].args, wantArgs)
}
}
func TestCrossPlatformCoverageURLResolveCLIDoesNotMistakeUnknownForVerified(t *testing.T) {
tests := []struct {
name string
result *edition.ToolResult
}{
{name: "nil result", result: nil},
{name: "empty content", result: &edition.ToolResult{}},
{name: "empty text", result: urlResult("")},
{name: "unknown object", result: urlResult(`{"success":true}`)},
{name: "legal empty table list", result: urlResult(`{"data":{"tables":[]}}`)},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &urlResolveE2ECaller{results: []*edition.ToolResult{test.result}}
out, err := runURLResolveCLI(t, caller,
"--url", "https://alidocs.dingtalk.com/i/nodes/base-1?iframeQuery=sheetId%3Dtable-1",
"--verify",
)
if err == nil {
t.Fatalf("%s was treated as verified; output=%s", test.name, out)
}
if out != "" {
t.Fatalf("%s emitted success output: %q", test.name, out)
}
})
}
}
func TestCrossPlatformCoverageURLResolveCLIInvalidURLFailsBeforeMCP(t *testing.T) {
caller := &urlResolveE2ECaller{}
out, err := runURLResolveCLI(t, caller, "--url", "https://example.com/i/nodes/base", "--verify")
if err == nil || out != "" || len(caller.calls) != 0 {
t.Fatalf("invalid URL result = output:%q err:%v calls:%#v", out, err, caller.calls)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "invalid_aitable_url" {
t.Fatalf("invalid URL error = %#v", err)
}
}
+179
View File
@@ -0,0 +1,179 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"fmt"
"reflect"
"strings"
"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 ViewPresetApply = shortcut.Shortcut{
Service: "aitable",
Command: "+view-preset-apply",
Product: serverMain,
Description: "按视图精确名称幂等创建或更新预设,并读回校验类型和 config",
Intent: "当你要把 Grid/Kanban/Gantt/Calendar/Gallery 的固定筛选、排序、分组或可见列预设部署到一张表时使用;同名唯一则更新,无同名则创建。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent",
},
Contract: aitableCompositeContract(
"+view-preset-apply",
"按视图精确名称幂等创建或更新预设,并读回校验类型和 config",
"当你要把 Grid/Kanban/Gantt/Calendar/Gallery 的固定筛选、排序、分组或可见列预设部署到一张表时使用;同名唯一则更新,无同名则创建。",
"只做一次性新建可用 view create;同名视图不唯一或现有视图类型不同必须人工处理",
`dws aitable +view-preset-apply --base-id B --table-id T --name "待处理" --view-type Grid --config '{"visibleFieldIds":["fld1"]}'`,
),
Flags: []shortcut.Flag{
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
{Name: "table-id", Type: shortcut.FlagString, Desc: "Table ID", Required: true},
{Name: "name", Type: shortcut.FlagString, Desc: "预设视图精确名称", Required: true},
{Name: "view-type", Type: shortcut.FlagString, Desc: "视图类型", Required: true, Enum: []string{"Grid", "Kanban", "Gantt", "Calendar", "Gallery"}},
{Name: "config", Type: shortcut.FlagString, Desc: "目标 config JSON 对象", Required: true},
},
Tips: []string{`dws aitable +view-preset-apply --base-id B --table-id T --name "待处理" --view-type Grid --config '{"visibleFieldIds":["fld1"]}'`},
Execute: func(rt *shortcut.RuntimeContext) error {
return executeViewPresetApply(rt)
},
}
func executeViewPresetApply(rt *shortcut.RuntimeContext) error {
config, err := parseJSONObject("config", rt.Str("config"))
if err != nil {
return err
}
if len(config) == 0 {
return apperrors.NewValidation("--config 必须是非空 JSON 对象")
}
baseID, tableID := rt.Str("base-id"), rt.Str("table-id")
name, viewType := strings.TrimSpace(rt.Str("name")), rt.Str("view-type")
preflight, err := rt.CallMCPData(serverMain, "get_views", map[string]any{"baseId": baseID, "tableId": tableID})
if err != nil {
return err
}
views, found := findNamedObjectList(preflight, "views", "viewList")
if !found {
return fmt.Errorf("get_views preflight is missing the views collection")
}
matches := viewsByExactName(views, name)
if len(matches) > 1 {
return apperrors.NewValidation(fmt.Sprintf("精确名称 %q 匹配到 %d 个视图,拒绝选择", name, len(matches)), apperrors.WithReason("target_ambiguous"), apperrors.WithExecutionStarted(false))
}
action, tool := "create", "create_view"
params := map[string]any{"baseId": baseID, "tableId": tableID, "viewName": name, "viewType": viewType, "config": config}
viewID := ""
if len(matches) == 1 {
action, tool = "update", "update_view"
viewID = stringValue(matches[0], "viewId", "id")
if viewID == "" {
return fmt.Errorf("matched view is missing viewId")
}
actualType := stringValue(matches[0], "viewType", "type")
if actualType != "" && actualType != viewType {
return apperrors.NewValidation(fmt.Sprintf("同名视图类型为 %s,不能原地改为 %s", actualType, viewType), apperrors.WithReason("target_type_conflict"), apperrors.WithExecutionStarted(false))
}
params = map[string]any{"baseId": baseID, "tableId": tableID, "viewId": viewID, "newViewName": name, "config": config}
if presetViewMatches(matches[0], viewType, config) {
result := newCompositeResult("view_preset_apply")
result.Status = "unchanged"
result.Executed = false
result.Resolved = map[string]any{"action": "unchanged", "viewId": viewID, "name": name}
result.Verification = map[string]any{"status": "verified", "viewId": viewID}
return rt.Output(result)
}
}
result := newCompositeResult("view_preset_apply")
result.Resolved = map[string]any{"action": action, "name": name, "viewId": viewID}
result.Plan = []compositeStep{{Index: 1, Name: action + " view preset", Tool: tool, Status: "planned", Arguments: params}}
if rt.DryRun() {
result.Status = "planned"
result.Executed = false
return rt.Output(result)
}
writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, tool, params)
if action == "create" {
viewID = findStringByKeys(writeData, "viewId")
}
readBack, verifyErr := rt.CallMCPData(serverMain, "get_views", map[string]any{"baseId": baseID, "tableId": tableID})
verifiedViews, found := findNamedObjectList(readBack, "views", "viewList")
if verifyErr == nil && !found {
verifyErr = fmt.Errorf("get_views read-back is missing the views collection")
}
verifiedMatches := viewsByExactName(verifiedViews, name)
if verifyErr == nil && len(verifiedMatches) != 1 {
verifyErr = fmt.Errorf("view read-back matched %d exact-name views, want 1", len(verifiedMatches))
}
if verifyErr == nil {
actualID := stringValue(verifiedMatches[0], "viewId", "id")
if actualID == "" || (viewID != "" && actualID != viewID) {
verifyErr = fmt.Errorf("view read-back identity mismatch: got %q, response %q", actualID, viewID)
} else if !presetViewMatches(verifiedMatches[0], viewType, config) {
verifyErr = fmt.Errorf("view read-back does not contain the declared type/config")
} else {
viewID = actualID
}
}
if verifyErr != nil {
result.Status = "unknown"
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": tool, "viewId": viewID, "name": name})
if writeErr != nil {
result.Warnings = append(result.Warnings, "write response error: "+writeErr.Error())
}
return compositeError(result, verifyErr, action == "update")
}
result.Resolved["viewId"] = viewID
result.CompletedCount = 1
result.Verification = map[string]any{"status": "verified", "viewId": viewID, "viewType": viewType}
result.Result = map[string]any{"action": action, "viewId": viewID, "view": verifiedMatches[0]}
if writeErr != nil {
result.Status = "recovered"
result.Warnings = append(result.Warnings, "write response was an error, but the exact view preset was proven by read-back")
}
return rt.Output(result)
}
func viewsByExactName(views []map[string]any, name string) []map[string]any {
out := make([]map[string]any, 0)
for _, view := range views {
if stringValue(view, "viewName", "name", "title") == name {
out = append(out, view)
}
}
return out
}
func presetViewMatches(view map[string]any, viewType string, config map[string]any) bool {
actualType := stringValue(view, "viewType", "type")
if actualType != "" && actualType != viewType {
return false
}
actualConfig, ok := view["config"].(map[string]any)
return ok && mapContains(actualConfig, config)
}
func mapContains(actual, expected map[string]any) bool {
for key, want := range expected {
got, exists := actual[key]
if !exists {
return false
}
wantMap, wantIsMap := want.(map[string]any)
gotMap, gotIsMap := got.(map[string]any)
if wantIsMap {
if !gotIsMap || !mapContains(gotMap, wantMap) {
return false
}
continue
}
if !reflect.DeepEqual(got, want) {
return false
}
}
return true
}
@@ -0,0 +1,173 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"errors"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
const workflowDSLFixture = `{"version":"workflow-dsl/v1","name":"提醒","nodes":[]}`
func TestCrossPlatformCoverageViewPresetCreateUpdateAndVerificationE2E(t *testing.T) {
t.Run("create and verify exact config", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"views":[]}`},
{text: `{"data":{"viewId":"v1"}}`},
{text: `{"views":[{"viewId":"v1","viewName":"待处理","viewType":"Grid","config":{"visibleFieldIds":["f1"],"extra":true}}]}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
"--base-id", "base", "--table-id", "table", "--name", "待处理", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f1"]}`, "--yes")
if err != nil || !strings.Contains(out, `"viewId": "v1"`) || !strings.Contains(out, `"status": "verified"`) {
t.Fatalf("view preset create = output:%q err:%v", out, err)
}
if len(caller.calls) != 3 || caller.calls[1].tool != "create_view" {
t.Fatalf("view create calls = %#v", caller.calls)
}
})
t.Run("unchanged preset makes no write", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"views":[{"viewId":"v1","viewName":"待处理","viewType":"Grid","config":{"visibleFieldIds":["f1"]}}]}`}}}
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
"--base-id", "base", "--table-id", "table", "--name", "待处理", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f1"]}`, "--yes")
if err != nil || len(caller.calls) != 1 || !strings.Contains(out, `"status": "unchanged"`) || !strings.Contains(out, `"executed": false`) {
t.Fatalf("unchanged view = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("empty write response recovers only from read-back", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"views":[]}`}, {text: ""},
{text: `{"views":[{"viewId":"v2","viewName":"看板","viewType":"Kanban","config":{"groupFieldId":"f1"}}]}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
"--base-id", "base", "--table-id", "table", "--name", "看板", "--view-type", "Kanban", "--config", `{"groupFieldId":"f1"}`, "--yes")
if err != nil || !strings.Contains(out, `"status": "recovered"`) {
t.Fatalf("recovered view = output:%q err:%v", out, err)
}
})
}
func TestCrossPlatformCoverageViewPresetAmbiguousOrMismatchedIsNotSuccessE2E(t *testing.T) {
t.Run("duplicate name stops before write", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"views":[{"viewId":"v1","viewName":"X"},{"viewId":"v2","viewName":"X"}]}`}}}
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
"--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f1"]}`, "--yes")
if err == nil || out != "" || len(caller.calls) != 1 {
t.Fatalf("ambiguous view = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("read-back config mismatch is unknown", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"views":[]}`}, {text: `{"viewId":"v1"}`},
{text: `{"views":[{"viewId":"v1","viewName":"X","viewType":"Grid","config":{"visibleFieldIds":["wrong"]}}]}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
"--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f1"]}`, "--yes")
if err == nil || out != "" {
t.Fatalf("mismatched view = output:%q err:%v", out, err)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" || typed.Retryable {
t.Fatalf("mismatched view error = %#v", err)
}
})
}
func TestCrossPlatformCoverageWorkflowDeployCreateEnableAndVerifyE2E(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"data":{"valid":true,"flowId":"w1","issues":[]}}`},
{text: `{"data":{"flowId":"w1","flowSchema":{"name":"提醒"}}}`},
{text: `{"workflowId":"w1","enabled":true}`},
{text: `{"data":{"list":[{"flowId":"w1","name":"提醒","status":"RUNNING"}]}}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--enable", "--yes")
if err != nil {
t.Fatalf("workflow deploy error = %v", err)
}
for _, want := range []string{`"workflowId": "w1"`, `"valid": true`, `"running": true`, `"status": "verified"`} {
if !strings.Contains(out, want) {
t.Fatalf("workflow output missing %s: %s", want, out)
}
}
if len(caller.calls) != 4 || caller.calls[0].product != serverMain || caller.calls[2].product != serverHelper || caller.calls[3].tool != "list_workflows" {
t.Fatalf("workflow call routing = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageWorkflowDeployUpdateAndInvalidResponsesE2E(t *testing.T) {
t.Run("update preflights and reads back", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"flowId":"w1","flowSchema":{}}`},
{text: `{"valid":true,"flowId":"w1"}`},
{text: `{"flowId":"w1","flowSchema":{"name":"提醒"}}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--workflow-id", "w1", "--dsl", workflowDSLFixture, "--yes")
if err != nil || !strings.Contains(out, `"action": "update"`) || len(caller.calls) != 3 || caller.calls[1].tool != "update_workflow" {
t.Fatalf("workflow update = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("status success with valid false is rejected", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"status":"success","data":{"valid":false,"flowId":"w1","issues":[{"message":"bad dsl"}]}}`}}}
out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--yes")
if err == nil || out != "" || len(caller.calls) != 1 {
t.Fatalf("invalid workflow = output:%q err:%v calls:%#v", out, err, caller.calls)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "aitable_composite_rejected" {
t.Fatalf("invalid workflow error = %#v", err)
}
})
for name, response := range map[string]string{
"conflicting valid": `{"valid":true,"data":{"valid":false,"flowId":"w1"}}`,
"conflicting id": `{"valid":true,"flowId":"w1","data":{"workflowId":"w2"}}`,
"valid wrong type": `{"valid":"true","flowId":"w1"}`,
} {
t.Run(name, func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: response}}}
out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--yes")
if err == nil || out != "" || len(caller.calls) != 1 {
t.Fatalf("conflicting workflow response = output:%q err:%v calls:%#v", out, err, caller.calls)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "aitable_composite_rejected" || typed.Retryable {
t.Fatalf("conflicting workflow response error = %#v", err)
}
})
}
t.Run("empty create response is unknown", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: ""}}}
out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--yes")
if err == nil || out != "" {
t.Fatalf("empty workflow = output:%q err:%v", out, err)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" || typed.Retryable {
t.Fatalf("empty workflow error = %#v", err)
}
})
t.Run("enable reply is not enough when list says STOP", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"valid":true,"flowId":"w1"}`}, {text: `{"flowId":"w1"}`},
{text: `{"enabled":true,"workflowId":"w1"}`},
{text: `{"list":[{"flowId":"w1","status":"STOP"}]}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+workflow-deploy", "--base-id", "base", "--dsl", workflowDSLFixture, "--enable", "--yes")
if err == nil || out != "" {
t.Fatalf("stopped workflow = output:%q err:%v", out, err)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "aitable_composite_partial_success" || typed.Retryable {
t.Fatalf("stopped workflow error = %#v", err)
}
})
}
@@ -0,0 +1,243 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitable
import (
"fmt"
"strings"
"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 WorkflowDeploy = shortcut.Shortcut{
Service: "aitable",
Command: "+workflow-deploy",
Product: serverMain,
Description: "创建或更新完整 workflow-dsl/v1,强制检查 valid/flowId,并可启用后验证 RUNNING 状态",
Intent: "当你要一次发布并验证自动化工作流时使用;workflow-id 为空则创建,提供则更新,--enable 还会检查 list 中最终运行状态。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown",
},
Contract: aitableCompositeContract(
"+workflow-deploy",
"创建或更新完整 workflow-dsl/v1,强制检查 valid/flowId,并可启用后验证 RUNNING 状态",
"当你要一次发布并验证自动化工作流时使用;workflow-id 为空则创建,提供则更新,--enable 还会检查 list 中最终运行状态。",
"只查看定义用 workflow get;只启停已有流程用 enable/disable;创建回包不确定时不要盲目重试",
`dws aitable +workflow-deploy --base-id B --dsl '{"version":"workflow-dsl/v1","name":"提醒","nodes":[]}' --enable`,
),
Flags: []shortcut.Flag{
{Name: "base-id", Type: shortcut.FlagString, Desc: "Base ID", Required: true},
{Name: "workflow-id", Type: shortcut.FlagString, Desc: "已有 Workflow ID;为空表示创建"},
{Name: "dsl", Type: shortcut.FlagString, Desc: "完整 workflow-dsl/v1 JSON 对象", Required: true},
{Name: "locale", Type: shortcut.FlagString, Default: "zh-CN", Desc: "发布 locale"},
{Name: "enable", Type: shortcut.FlagBool, Desc: "发布后启用并从 workflow list 验证 RUNNING"},
},
Tips: []string{`dws aitable +workflow-deploy --base-id B --dsl '{"version":"workflow-dsl/v1","name":"提醒","nodes":[]}' --enable`},
Execute: func(rt *shortcut.RuntimeContext) error {
return executeWorkflowDeploy(rt)
},
}
func executeWorkflowDeploy(rt *shortcut.RuntimeContext) error {
dsl, err := parseJSONObject("dsl", rt.Str("dsl"))
if err != nil {
return err
}
if stringValue(dsl, "version") != "workflow-dsl/v1" {
return apperrors.NewValidation("--dsl.version 必须是 workflow-dsl/v1")
}
baseID, workflowID := rt.Str("base-id"), strings.TrimSpace(rt.Str("workflow-id"))
action, tool := "create", "create_workflow"
params := map[string]any{"baseId": baseID, "dsl": dsl, "locale": rt.Str("locale")}
if workflowID != "" {
action, tool = "update", "update_workflow"
params["workflowId"] = workflowID
preflight, preflightErr := rt.CallMCPData(serverHelper, "get_workflow", map[string]any{"baseId": baseID, "workflowId": workflowID})
if preflightErr != nil {
return preflightErr
}
if !deepContainsString(preflight, workflowID) {
return apperrors.NewValidation("workflow get preflight does not identify --workflow-id", apperrors.WithReason("target_not_found"), apperrors.WithExecutionStarted(false))
}
}
result := newCompositeResult("workflow_deploy")
result.Resolved = map[string]any{"action": action, "baseId": baseID, "workflowId": workflowID, "enable": rt.Bool("enable")}
result.Plan = []compositeStep{{Index: 1, Name: action + " workflow", Tool: tool, Status: "planned"}}
if rt.Bool("enable") {
result.Plan = append(result.Plan, compositeStep{Index: 2, Name: "enable and verify workflow", Tool: "enable_workflow", Status: "planned"})
}
if rt.DryRun() {
result.Status = "planned"
result.Executed = false
return rt.Output(result)
}
writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, tool, params)
if writeErr != nil {
result.Status = "unknown"
result.Checkpoint = map[string]any{"nextStep": "resolve workflow existence and definition before retrying", "workflowId": workflowID}
return compositeError(result, writeErr, false)
}
valid, validFound, responseID, publishErr := workflowPublishResult(writeData)
if publishErr != nil {
result.Status = "rejected"
result.Result = writeData
return compositeError(result, publishErr, false)
}
if !validFound || !valid || responseID == "" || (workflowID != "" && responseID != workflowID) {
result.Status = "rejected"
result.Result = writeData
return compositeError(result, fmt.Errorf("%s response must contain valid=true and the expected flowId", tool), false)
}
workflowID = responseID
result.Resolved["workflowId"] = workflowID
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": tool, "workflowId": workflowID})
readBack, verifyErr := rt.CallMCPData(serverHelper, "get_workflow", map[string]any{"baseId": baseID, "workflowId": workflowID})
if verifyErr != nil || !deepContainsString(readBack, workflowID) {
if verifyErr == nil {
verifyErr = fmt.Errorf("get_workflow read-back does not identify workflowId %s", workflowID)
}
result.Status = "partial_success"
result.Checkpoint = map[string]any{"workflowId": workflowID, "nextStep": "verify published workflow"}
return compositeError(result, verifyErr, action == "update")
}
if rt.Bool("enable") {
enableData, enableErr := rt.CallMCPWriteDataStrict(serverHelper, "enable_workflow", map[string]any{"baseId": baseID, "workflowId": workflowID})
if enableErr != nil {
result.Warnings = append(result.Warnings, "enable response error: "+enableErr.Error())
}
workflow, listErr := readWorkflowFromList(rt, baseID, workflowID)
if listErr != nil || !workflowIsRunning(workflow) {
if listErr == nil {
listErr = fmt.Errorf("workflow list does not show %s as RUNNING", workflowID)
}
result.Status = "partial_success"
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "enable_workflow", "workflowId": workflowID, "response": enableData})
result.Checkpoint = map[string]any{"workflowId": workflowID, "nextStep": "inspect and enable workflow"}
return compositeError(result, listErr, action == "update")
}
result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: 2, Name: "enable and verify workflow", Tool: "enable_workflow", Status: "completed", Result: workflow})
}
result.CompletedCount = 1
result.CompletedSteps = append([]compositeStep{{Index: 1, Name: action + " workflow", Tool: tool, Status: "completed", Result: writeData}}, result.CompletedSteps...)
result.Verification = map[string]any{"status": "verified", "valid": true, "workflowId": workflowID, "running": rt.Bool("enable")}
result.Result = map[string]any{"action": action, "workflowId": workflowID, "valid": true}
return rt.Output(result)
}
// workflowPublishResult reads only the reviewed response envelopes rather than
// recursively accepting an unrelated nested valid/flowId field. Conflicting
// values are protocol errors; an outer "success" marker never overrides
// valid=false.
func workflowPublishResult(data map[string]any) (valid bool, validFound bool, workflowID string, err error) {
var visit func(map[string]any) error
visit = func(object map[string]any) error {
if raw, exists := object["valid"]; exists {
value, ok := raw.(bool)
if !ok {
return fmt.Errorf("workflow publish valid must be boolean, got %T", raw)
}
if validFound && valid != value {
return fmt.Errorf("workflow publish response contains conflicting valid values")
}
valid, validFound = value, true
}
for _, key := range []string{"flowId", "workflowId"} {
raw, exists := object[key]
if !exists {
continue
}
value, ok := raw.(string)
value = strings.TrimSpace(value)
if !ok || value == "" {
return fmt.Errorf("workflow publish %s must be a non-empty string", key)
}
if workflowID != "" && workflowID != value {
return fmt.Errorf("workflow publish response contains conflicting workflow IDs")
}
workflowID = value
}
for _, envelope := range []string{"data", "result", "response"} {
if nested, ok := object[envelope].(map[string]any); ok {
if err := visit(nested); err != nil {
return err
}
}
}
return nil
}
if data == nil {
return false, false, "", fmt.Errorf("workflow publish response is empty")
}
if err := visit(data); err != nil {
return false, false, "", err
}
return valid, validFound, workflowID, nil
}
func findBoolByKeys(value any, keys ...string) (bool, bool) {
keySet := map[string]bool{}
for _, key := range keys {
keySet[strings.ToLower(key)] = true
}
var walk func(any) (bool, bool)
walk = func(current any) (bool, bool) {
switch typed := current.(type) {
case map[string]any:
for key, child := range typed {
if keySet[strings.ToLower(key)] {
if value, ok := child.(bool); ok {
return value, true
}
}
}
for _, child := range typed {
if value, found := walk(child); found {
return value, true
}
}
case []any:
for _, child := range typed {
if value, found := walk(child); found {
return value, true
}
}
}
return false, false
}
return walk(value)
}
func readWorkflowFromList(rt *shortcut.RuntimeContext, baseID, workflowID string) (map[string]any, error) {
for offset := 0; offset < 10000; offset += 100 {
data, err := rt.CallMCPData(serverHelper, "list_workflows", map[string]any{"baseId": baseID, "limit": 100, "offset": offset})
if err != nil {
return nil, err
}
workflows, found := findNamedObjectList(data, "workflows", "list", "items")
if !found {
return nil, fmt.Errorf("list_workflows response is missing the workflow list")
}
for _, workflow := range workflows {
if stringValue(workflow, "flowId", "workflowId", "id") == workflowID {
return workflow, nil
}
}
if len(workflows) < 100 {
return nil, fmt.Errorf("workflow %s is not present in list_workflows", workflowID)
}
}
return nil, fmt.Errorf("workflow list exceeded 10000 entries")
}
func workflowIsRunning(workflow map[string]any) bool {
status := strings.ToUpper(stringValue(workflow, "status", "state"))
if status == "RUNNING" || status == "ENABLED" || status == "ACTIVE" {
return true
}
enabled, found := findBoolByKeys(workflow, "enabled", "isEnabled")
return found && enabled
}
+438
View File
@@ -0,0 +1,438 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
// Package aitabletarget provides deterministic AI Table URL and name
// resolution shared by read and write shortcuts. It never guesses among
// multiple candidates and treats an incomplete/unknown response as an error.
package aitabletarget
import (
"fmt"
"net/url"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
const (
maxResolutionPages = 50
aliDocsHost = "alidocs.dingtalk.com"
)
// Reader is the read-only transport required for name resolution.
type Reader interface {
CallMCPData(product, tool string, params map[string]any) (map[string]any, error)
}
// Target is the normalized identity carried by an AI Table URL.
type Target struct {
SchemaVersion string `json:"schemaVersion"`
Source string `json:"source"`
Kind string `json:"kind"`
BaseID string `json:"baseId"`
TableID string `json:"tableId,omitempty"`
ViewID string `json:"viewId,omitempty"`
RecordID string `json:"recordId,omitempty"`
}
// Candidate is a stable ID/name pair returned by name resolution.
type Candidate struct {
ID string `json:"id"`
Name string `json:"name"`
}
// Resolution is returned only after exactly one candidate is selected.
type Resolution struct {
Status string `json:"status"`
EntityType string `json:"entityType"`
Query string `json:"query"`
MatchType string `json:"matchType"`
Selected Candidate `json:"selected"`
}
// ParseURL parses the documented DingTalk AI Table URL forms:
//
// https://alidocs.dingtalk.com/i/nodes/{baseId}
// https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}%26viewId%3D{viewId}
func ParseURL(raw string) (Target, error) {
raw = strings.TrimSpace(raw)
parsed, err := url.ParseRequestURI(raw)
if err != nil {
return Target{}, invalidURL(raw, fmt.Sprintf("URL 解析失败: %v", err))
}
if !strings.EqualFold(parsed.Scheme, "https") || !strings.EqualFold(parsed.Hostname(), aliDocsHost) || parsed.User != nil {
return Target{}, invalidURL(raw, "仅接受 https://alidocs.dingtalk.com 的无凭据 URL")
}
segments := strings.Split(strings.Trim(parsed.EscapedPath(), "/"), "/")
if len(segments) != 3 || segments[0] != "i" || segments[1] != "nodes" {
return Target{}, invalidURL(raw, "路径必须是 /i/nodes/{baseId}")
}
baseID, err := url.PathUnescape(segments[2])
if err != nil || !validID(baseID) {
return Target{}, invalidURL(raw, "URL 路径中的 baseId 无效")
}
values := parsed.Query()
nested, err := parseIframeQuery(values.Get("iframeQuery"))
if err != nil {
return Target{}, invalidURL(raw, err.Error())
}
for key, nestedValues := range nested {
for _, value := range nestedValues {
values.Add(key, value)
}
}
tableID, err := uniqueQueryID(values, "sheetId", "tableId")
if err != nil {
return Target{}, invalidURL(raw, err.Error())
}
viewID, err := uniqueQueryID(values, "viewId")
if err != nil {
return Target{}, invalidURL(raw, err.Error())
}
recordID, err := uniqueQueryID(values, "recordId")
if err != nil {
return Target{}, invalidURL(raw, err.Error())
}
if viewID != "" && tableID == "" {
return Target{}, invalidURL(raw, "viewId 缺少所属 sheetId/tableId")
}
if recordID != "" && tableID == "" {
return Target{}, invalidURL(raw, "recordId 缺少所属 sheetId/tableId")
}
kind := "base"
if tableID != "" {
kind = "table"
}
if viewID != "" {
kind = "view"
}
if recordID != "" {
kind = "record"
}
return Target{
SchemaVersion: "aitable.target.v1",
Source: "url",
Kind: kind,
BaseID: strings.TrimSpace(baseID),
TableID: tableID,
ViewID: viewID,
RecordID: recordID,
}, nil
}
func parseIframeQuery(raw string) (url.Values, error) {
if strings.TrimSpace(raw) == "" {
return url.Values{}, nil
}
value := raw
for attempt := 0; attempt < 2 && !strings.Contains(value, "="); attempt++ {
decoded, err := url.QueryUnescape(value)
if err != nil {
return nil, fmt.Errorf("iframeQuery 解码失败: %w", err)
}
if decoded == value {
break
}
value = decoded
}
values, err := url.ParseQuery(value)
if err != nil {
return nil, fmt.Errorf("iframeQuery 不是合法查询串: %w", err)
}
return values, nil
}
func uniqueQueryID(values url.Values, keys ...string) (string, error) {
unique := map[string]struct{}{}
for _, key := range keys {
for _, raw := range values[key] {
value := strings.TrimSpace(raw)
if value == "" {
continue
}
if !validID(value) {
return "", fmt.Errorf("%s 包含无效 ID", key)
}
unique[value] = struct{}{}
}
}
if len(unique) > 1 {
return "", fmt.Errorf("%s 出现互相冲突的多个值", strings.Join(keys, "/"))
}
for value := range unique {
return value, nil
}
return "", nil
}
func validID(value string) bool {
value = strings.TrimSpace(value)
if value == "" || len(value) > 512 || strings.ContainsAny(value, "/?#") {
return false
}
for _, r := range value {
if r <= 0x20 || r == 0x7f {
return false
}
}
return true
}
// ResolveBaseName resolves one Base by exact name. A substring match is used
// only when allowFuzzy is true. Zero/multiple candidates and incomplete pages
// are structured non-zero errors.
func ResolveBaseName(reader Reader, name string, allowFuzzy bool) (Resolution, error) {
query := strings.TrimSpace(name)
if query == "" {
return Resolution{}, apperrors.NewValidation("Base 名称不能为空")
}
all := make([]Candidate, 0)
cursor := ""
seen := map[string]bool{}
for page := 1; page <= maxResolutionPages; page++ {
params := map[string]any{"query": query}
if cursor != "" {
params["cursor"] = cursor
}
data, err := reader.CallMCPData("aitable", "search_bases", params)
if err != nil {
return Resolution{}, err
}
items, found, listErr := findObjectList(data, "bases", "items", "list", "result", "records")
if listErr != nil {
return Resolution{}, invalidResponse("base", query, listErr.Error())
}
if !found {
return Resolution{}, invalidResponse("base", query, "search_bases 响应缺少候选列表")
}
for index, item := range items {
id := firstString(item, "baseId", "base_id", "id")
candidateName := firstString(item, "baseName", "name", "title")
if id == "" || candidateName == "" {
return Resolution{}, invalidResponse("base", query,
fmt.Sprintf("search_bases 候选 %d 缺少 baseId 或名称", index))
}
all = append(all, Candidate{ID: id, Name: candidateName})
}
next, hasMore, hasMoreKnown := pagination(data)
if next == "" {
if hasMoreKnown && hasMore {
return Resolution{}, incomplete("base", query, all, "search_bases 声明有后续页但没有 cursor")
}
return selectCandidate("base", query, dedupe(all), allowFuzzy)
}
if seen[next] || next == cursor {
return Resolution{}, incomplete("base", query, all, "search_bases cursor 停滞或成环")
}
seen[next] = true
cursor = next
}
return Resolution{}, incomplete("base", query, all, fmt.Sprintf("达到 %d 页安全上限", maxResolutionPages))
}
// ResolveTableName resolves one table within a known Base using get_tables.
func ResolveTableName(reader Reader, baseID, name string, allowFuzzy bool) (Resolution, error) {
baseID = strings.TrimSpace(baseID)
query := strings.TrimSpace(name)
if !validID(baseID) || query == "" {
return Resolution{}, apperrors.NewValidation("解析数据表名称需要合法 baseId 和非空名称")
}
data, err := reader.CallMCPData("aitable", "get_tables", map[string]any{"baseId": baseID})
if err != nil {
return Resolution{}, err
}
items, found, listErr := findObjectList(data, "tables", "items", "list", "result", "records")
if listErr != nil {
return Resolution{}, invalidResponse("table", query, listErr.Error())
}
if !found {
return Resolution{}, invalidResponse("table", query, "get_tables 响应缺少 tables 列表")
}
candidates := make([]Candidate, 0, len(items))
for index, item := range items {
id := firstString(item, "tableId", "table_id", "id")
candidateName := firstString(item, "tableName", "name", "title")
if id == "" || candidateName == "" {
return Resolution{}, invalidResponse("table", query,
fmt.Sprintf("get_tables 候选 %d 缺少 tableId 或名称", index))
}
candidates = append(candidates, Candidate{ID: id, Name: candidateName})
}
return selectCandidate("table", query, dedupe(candidates), allowFuzzy)
}
func selectCandidate(entityType, query string, candidates []Candidate, allowFuzzy bool) (Resolution, error) {
exact := make([]Candidate, 0)
for _, candidate := range candidates {
if strings.EqualFold(strings.TrimSpace(candidate.Name), query) {
exact = append(exact, candidate)
}
}
selected := exact
matchType := "exact"
if len(selected) == 0 && allowFuzzy {
matchType = "fuzzy"
needle := strings.ToLower(query)
for _, candidate := range candidates {
if strings.Contains(strings.ToLower(candidate.Name), needle) {
selected = append(selected, candidate)
}
}
}
if len(selected) == 0 {
return Resolution{}, resolutionError("not_found", entityType, query, candidates)
}
if len(selected) > 1 {
return Resolution{}, resolutionError("ambiguous", entityType, query, selected)
}
return Resolution{
Status: "resolved", EntityType: entityType, Query: query,
MatchType: matchType, Selected: selected[0],
}, nil
}
func findObjectList(data map[string]any, keys ...string) ([]map[string]any, bool, error) {
if data == nil {
return nil, false, nil
}
invalid := make([]string, 0)
for _, key := range keys {
value, exists := data[key]
if !exists {
continue
}
if list, ok := value.([]any); ok {
out := make([]map[string]any, 0, len(list))
for index, row := range list {
object, ok := row.(map[string]any)
if !ok {
return nil, false, fmt.Errorf("候选列表 %s[%d] 必须是对象,got %T", key, index, row)
}
out = append(out, object)
}
return out, true, nil
}
if object, ok := value.(map[string]any); ok {
if list, found, err := findObjectList(object, keys...); found || err != nil {
return list, found, err
}
continue
}
invalid = append(invalid, fmt.Sprintf("%s=%T", key, value))
}
for _, envelope := range []string{"data", "result", "response"} {
if object, ok := data[envelope].(map[string]any); ok {
if list, found, err := findObjectList(object, keys...); found || err != nil {
return list, found, err
}
}
}
if len(invalid) > 0 {
return nil, false, fmt.Errorf("候选列表字段类型无效: %v", invalid)
}
return nil, false, nil
}
func firstString(values map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := values[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func pagination(data map[string]any) (cursor string, hasMore bool, hasMoreKnown bool) {
if data == nil {
return "", false, false
}
for _, key := range []string{"nextCursor", "cursor"} {
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
cursor = strings.TrimSpace(value)
break
}
}
if value, ok := data["hasMore"].(bool); ok {
hasMore, hasMoreKnown = value, true
}
for _, key := range []string{"data", "result", "page", "pagination", "meta"} {
if nested, ok := data[key].(map[string]any); ok {
next, more, known := pagination(nested)
if cursor == "" {
cursor = next
}
if !hasMoreKnown && known {
hasMore, hasMoreKnown = more, true
}
}
}
return cursor, hasMore, hasMoreKnown
}
func dedupe(values []Candidate) []Candidate {
seen := map[string]bool{}
out := make([]Candidate, 0, len(values))
for _, value := range values {
if value.ID == "" || seen[value.ID] {
continue
}
seen[value.ID] = true
out = append(out, value)
}
return out
}
func invalidURL(raw, reason string) error {
return apperrors.NewValidation("无法解析 AI 表格 URL",
apperrors.WithReason("invalid_aitable_url"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithHint(reason),
apperrors.WithDetails(map[string]any{"url": raw}),
)
}
func resolutionError(status, entityType, query string, candidates []Candidate) error {
return apperrors.NewValidation(fmt.Sprintf("%s target %s: %q", entityType, status, query),
apperrors.WithReason("target_"+status),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithDetails(map[string]any{
"status": status, "entityType": entityType, "query": query,
"candidates": candidates,
}),
)
}
func incomplete(entityType, query string, candidates []Candidate, cause string) error {
return apperrors.NewAPI("目标解析候选集不完整,不能安全选择",
apperrors.WithReason("target_incomplete"),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(true),
apperrors.WithHint(cause),
apperrors.WithDetails(map[string]any{
"status": "incomplete", "entityType": entityType, "query": query,
"candidates": dedupe(candidates),
}),
)
}
func invalidResponse(entityType, query, cause string) error {
return apperrors.NewAPI("目标解析接口返回未知结构,不能把它当作未找到",
apperrors.WithReason("target_invalid_response"),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("response_validation"),
apperrors.WithExecutionStarted(false),
apperrors.WithRetryable(false),
apperrors.WithHint(cause),
apperrors.WithDetails(map[string]any{"entityType": entityType, "query": query}),
)
}
@@ -0,0 +1,111 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitabletarget
import (
"errors"
"fmt"
"net/url"
"strings"
"testing"
)
func TestCrossPlatformCoverageParseAITableURLRemainingEdges(t *testing.T) {
for _, raw := range []string{
"https://alidocs.dingtalk.com/i/nodes/base%2Fchild",
"https://alidocs.dingtalk.com/i/nodes/base?iframeQuery=%25",
"https://alidocs.dingtalk.com/i/nodes/base?viewId=one&viewId=two&sheetId=table",
"https://alidocs.dingtalk.com/i/nodes/base?recordId=one&recordId=two&sheetId=table",
} {
if _, err := ParseURL(raw); err == nil {
t.Fatalf("ParseURL(%q) succeeded", raw)
}
}
if _, err := parseIframeQuery("%"); err == nil {
t.Fatal("invalid iframe escape succeeded")
}
if values, err := parseIframeQuery("preview"); err != nil || !values.Has("preview") {
t.Fatalf("plain iframe query = %#v, %v", values, err)
}
if _, err := parseIframeQuery("key=%zz"); err == nil {
t.Fatal("invalid iframe query value succeeded")
}
values := url.Values{"id": {" ", "bad/id"}}
if _, err := uniqueQueryID(values, "id"); err == nil {
t.Fatal("invalid query ID succeeded")
}
if validID("ok\x7fno") {
t.Fatal("control character ID succeeded")
}
}
func TestCrossPlatformCoverageResolveBaseRemainingFailures(t *testing.T) {
if _, err := ResolveBaseName(&resolverReader{}, " ", false); err == nil {
t.Fatal("empty base name succeeded")
}
reader := &resolverReader{steps: []resolverStep{{err: errors.New("search failed")}}}
if _, err := ResolveBaseName(reader, "name", false); err == nil {
t.Fatal("search error was swallowed")
}
reader = &resolverReader{steps: []resolverStep{
{data: map[string]any{"bases": []any{}, "nextCursor": "same"}},
{data: map[string]any{"bases": []any{}, "nextCursor": "same"}},
}}
if _, err := ResolveBaseName(reader, "name", false); errorReason(err) != "target_incomplete" {
t.Fatalf("cursor cycle = %v", err)
}
steps := make([]resolverStep, maxResolutionPages)
for index := range steps {
steps[index] = resolverStep{data: map[string]any{"bases": []any{}, "nextCursor": fmt.Sprintf("cursor-%d", index)}}
}
reader = &resolverReader{steps: steps}
if _, err := ResolveBaseName(reader, "name", false); errorReason(err) != "target_incomplete" || len(reader.calls) != maxResolutionPages {
t.Fatalf("page bound = err:%v calls:%d", err, len(reader.calls))
}
}
func TestCrossPlatformCoverageResolveTableRemainingFailures(t *testing.T) {
for _, pair := range [][2]string{{"bad/id", "name"}, {"base", " "}} {
if _, err := ResolveTableName(&resolverReader{}, pair[0], pair[1], false); err == nil {
t.Fatalf("invalid table args %#v succeeded", pair)
}
}
reader := &resolverReader{steps: []resolverStep{{err: errors.New("tables failed")}}}
if _, err := ResolveTableName(reader, "base", "name", false); err == nil {
t.Fatal("table error was swallowed")
}
reader = &resolverReader{steps: []resolverStep{{data: map[string]any{"tables": "bad"}}}}
if _, err := ResolveTableName(reader, "base", "name", false); errorReason(err) != "target_invalid_response" {
t.Fatalf("invalid table list = %v", err)
}
reader = &resolverReader{steps: []resolverStep{{data: map[string]any{"success": true}}}}
if _, err := ResolveTableName(reader, "base", "name", false); errorReason(err) != "target_invalid_response" {
t.Fatalf("missing table list = %v", err)
}
}
func TestCrossPlatformCoverageResolverPureShapeHelpers(t *testing.T) {
if list, found, err := findObjectList(nil, "items"); err != nil || found || list != nil {
t.Fatalf("nil list = %#v, %v, %v", list, found, err)
}
if _, _, err := findObjectList(map[string]any{"items": "bad"}, "items"); err == nil {
t.Fatal("invalid list field succeeded")
}
if list, found, err := findObjectList(map[string]any{"items": map[string]any{"items": []any{}}}, "items"); err != nil || !found || len(list) != 0 {
t.Fatalf("nested keyed list = %#v, %v, %v", list, found, err)
}
cursor, more, known := pagination(nil)
if cursor != "" || more || known {
t.Fatalf("nil pagination = %q, %v, %v", cursor, more, known)
}
cursor, more, known = pagination(map[string]any{"cursor": "outer", "data": map[string]any{"nextCursor": "inner", "hasMore": true}})
if cursor != "outer" || !more || !known {
t.Fatalf("nested pagination = %q, %v, %v", cursor, more, known)
}
got := dedupe([]Candidate{{}, {ID: "one", Name: "first"}, {ID: "one", Name: "duplicate"}, {ID: "two", Name: "second"}})
if len(got) != 2 || strings.Join([]string{got[0].ID, got[1].ID}, ",") != "one,two" {
t.Fatalf("dedupe = %#v", got)
}
}
@@ -0,0 +1,219 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package aitabletarget
import (
"errors"
"reflect"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
type resolverStep struct {
data map[string]any
err error
}
type resolverReader struct {
steps []resolverStep
calls []map[string]any
tools []string
server []string
}
func (r *resolverReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
cloned := make(map[string]any, len(params))
for key, value := range params {
cloned[key] = value
}
r.calls = append(r.calls, cloned)
r.tools = append(r.tools, tool)
r.server = append(r.server, product)
index := len(r.calls) - 1
if index >= len(r.steps) {
return nil, errors.New("unexpected resolver call")
}
return r.steps[index].data, r.steps[index].err
}
func TestCrossPlatformCoverageParseAITableURL(t *testing.T) {
tests := []struct {
name string
url string
want Target
}{
{
name: "base",
url: "https://alidocs.dingtalk.com/i/nodes/base-1",
want: Target{SchemaVersion: "aitable.target.v1", Source: "url", Kind: "base", BaseID: "base-1"},
},
{
name: "table and view nested",
url: "https://alidocs.dingtalk.com/i/nodes/base-1?iframeQuery=sheetId%3Dtable-1%26viewId%3Dview-1",
want: Target{SchemaVersion: "aitable.target.v1", Source: "url", Kind: "view", BaseID: "base-1", TableID: "table-1", ViewID: "view-1"},
},
{
name: "double encoded iframe",
url: "https://alidocs.dingtalk.com/i/nodes/base-1?iframeQuery=sheetId%253Dtable-1%2526recordId%253Drecord-1",
want: Target{SchemaVersion: "aitable.target.v1", Source: "url", Kind: "record", BaseID: "base-1", TableID: "table-1", RecordID: "record-1"},
},
{
name: "direct table query",
url: "https://alidocs.dingtalk.com/i/nodes/base-1?tableId=table-1",
want: Target{SchemaVersion: "aitable.target.v1", Source: "url", Kind: "table", BaseID: "base-1", TableID: "table-1"},
},
{
name: "unrelated iframe query stays base",
url: "https://alidocs.dingtalk.com/i/nodes/base-1?iframeQuery=preview%3Dcompact",
want: Target{SchemaVersion: "aitable.target.v1", Source: "url", Kind: "base", BaseID: "base-1"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := ParseURL(test.url)
if err != nil {
t.Fatalf("ParseURL() error = %v", err)
}
if !reflect.DeepEqual(got, test.want) {
t.Fatalf("ParseURL() = %#v, want %#v", got, test.want)
}
})
}
}
func TestCrossPlatformCoverageParseAITableURLRejectsUnsafeOrAmbiguousInput(t *testing.T) {
for _, raw := range []string{
"",
"http://alidocs.dingtalk.com/i/nodes/base",
"https://@alidocs.dingtalk.com/i/nodes/base",
"https://example.com/i/nodes/base",
"https://alidocs.dingtalk.com/i/nodes/",
"https://alidocs.dingtalk.com/i/nodes/base/extra",
"https://alidocs.dingtalk.com/i/nodes/base?viewId=view",
"https://alidocs.dingtalk.com/i/nodes/base?recordId=record",
"https://alidocs.dingtalk.com/i/nodes/base?sheetId=one&tableId=two",
} {
if _, err := ParseURL(raw); err == nil {
t.Errorf("ParseURL(%q) succeeded, want error", raw)
}
}
}
func TestCrossPlatformCoverageResolveBaseNameExactPaginationE2E(t *testing.T) {
reader := &resolverReader{steps: []resolverStep{
{data: map[string]any{"data": map[string]any{
"bases": []any{map[string]any{"baseId": "b1", "baseName": "项目管理归档"}},
"nextCursor": "next",
"hasMore": true,
}}},
{data: map[string]any{"data": map[string]any{
"bases": []any{map[string]any{"baseId": "b2", "baseName": "项目管理"}},
"hasMore": false,
}}},
}}
got, err := ResolveBaseName(reader, "项目管理", false)
if err != nil {
t.Fatalf("ResolveBaseName() error = %v", err)
}
if got.MatchType != "exact" || got.Selected.ID != "b2" {
t.Fatalf("resolution = %#v", got)
}
if len(reader.calls) != 2 || reader.calls[1]["cursor"] != "next" {
t.Fatalf("pagination calls = %#v", reader.calls)
}
}
func TestCrossPlatformCoverageResolveNameFuzzyIsExplicitAndAmbiguityFails(t *testing.T) {
data := map[string]any{"bases": []any{
map[string]any{"baseId": "b1", "baseName": "项目管理归档"},
map[string]any{"baseId": "b2", "baseName": "财务"},
}}
reader := &resolverReader{steps: []resolverStep{{data: data}}}
if _, err := ResolveBaseName(reader, "项目", false); errorReason(err) != "target_not_found" {
t.Fatalf("fuzzy-disabled error = %v", err)
}
reader = &resolverReader{steps: []resolverStep{{data: data}}}
got, err := ResolveBaseName(reader, "项目", true)
if err != nil || got.MatchType != "fuzzy" || got.Selected.ID != "b1" {
t.Fatalf("fuzzy resolution = %#v, %v", got, err)
}
reader = &resolverReader{steps: []resolverStep{{data: map[string]any{"bases": []any{
map[string]any{"baseId": "b1", "baseName": "项目"},
map[string]any{"baseId": "b2", "baseName": "项目"},
}}}}}
if _, err := ResolveBaseName(reader, "项目", false); errorReason(err) != "target_ambiguous" {
t.Fatalf("ambiguous error = %v", err)
}
}
func TestCrossPlatformCoverageResolveNameDistinguishesEmptyFromUnknown(t *testing.T) {
reader := &resolverReader{steps: []resolverStep{{data: map[string]any{"bases": []any{}}}}}
if _, err := ResolveBaseName(reader, "missing", false); errorReason(err) != "target_not_found" {
t.Fatalf("legal empty list error = %v", err)
}
reader = &resolverReader{steps: []resolverStep{{data: map[string]any{"success": true}}}}
if _, err := ResolveBaseName(reader, "missing", false); errorReason(err) != "target_invalid_response" {
t.Fatalf("unknown response error = %v", err)
}
reader = &resolverReader{steps: []resolverStep{{data: map[string]any{"bases": []any{}, "hasMore": true}}}}
if _, err := ResolveBaseName(reader, "missing", false); errorReason(err) != "target_incomplete" {
t.Fatalf("incomplete response error = %v", err)
}
for name, data := range map[string]map[string]any{
"wrong collection type": {"bases": map[string]any{}},
"scalar candidate": {"bases": []any{"bad"}},
"candidate missing id": {"bases": []any{map[string]any{"baseName": "missing"}}},
} {
t.Run(name, func(t *testing.T) {
reader := &resolverReader{steps: []resolverStep{{data: data}}}
if _, err := ResolveBaseName(reader, "missing", false); errorReason(err) != "target_invalid_response" {
t.Fatalf("malformed candidate response error = %v", err)
}
})
}
}
func TestCrossPlatformCoverageResolveTableNameE2E(t *testing.T) {
reader := &resolverReader{steps: []resolverStep{{data: map[string]any{"data": map[string]any{"tables": []any{
map[string]any{"tableId": "t1", "tableName": "任务"},
map[string]any{"tableId": "t2", "tableName": "任务归档"},
}}}}}}
got, err := ResolveTableName(reader, "base", "任务", false)
if err != nil || got.Selected.ID != "t1" || got.MatchType != "exact" {
t.Fatalf("table resolution = %#v, %v", got, err)
}
if reader.server[0] != "aitable" || reader.tools[0] != "get_tables" || reader.calls[0]["baseId"] != "base" {
t.Fatalf("table resolution call = %v/%v %#v", reader.server, reader.tools, reader.calls)
}
}
func TestCrossPlatformCoverageResolveTableNameMalformedCandidateIsInvalidResponse(t *testing.T) {
for name, data := range map[string]map[string]any{
"explicit empty is not found": {"tables": []any{}},
"scalar candidate": {"tables": []any{1}},
"candidate missing name": {"tables": []any{map[string]any{"tableId": "t1"}}},
} {
t.Run(name, func(t *testing.T) {
reader := &resolverReader{steps: []resolverStep{{data: data}}}
_, err := ResolveTableName(reader, "base", "missing", false)
want := "target_invalid_response"
if name == "explicit empty is not found" {
want = "target_not_found"
}
if errorReason(err) != want {
t.Fatalf("resolution error = %v, want reason %s", err, want)
}
})
}
}
func errorReason(err error) string {
var typed *apperrors.Error
if errors.As(err, &typed) {
return typed.Reason
}
return ""
}
@@ -0,0 +1,92 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package builtin_test
import (
"encoding/json"
"os"
"sort"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageAITableSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
raw, err := os.ReadFile("../semantic_catalog_aitable.json")
if err != nil {
t.Fatal(err)
}
var source chatSemanticCatalogFixture
if err := json.Unmarshal(raw, &source); err != nil {
t.Fatal(err)
}
if source.Service != "aitable" {
t.Fatalf("semantic catalog service = %q, want aitable", source.Service)
}
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service != "aitable" {
continue
}
if _, duplicate := registered[item.Command]; duplicate {
t.Fatalf("duplicate registered AI Table Shortcut %s", item.Command)
}
registered[item.Command] = item
}
if len(registered) != 92 || len(source.Shortcuts) != 92 {
t.Fatalf("registered/catalog = %d/%d, want 92/92", len(registered), len(source.Shortcuts))
}
var missing, stale []string
for command, item := range registered {
record, ok := source.Shortcuts[command]
if !ok {
missing = append(missing, command)
continue
}
if !record.Reviewed || !item.SemanticReviewed {
t.Errorf("%s: semantic decision is not reviewed", command)
}
if strings.TrimSpace(record.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
t.Errorf("%s: semantic delta was not delivered exactly", command)
}
if item.Disposition != record.Disposition {
t.Errorf("%s: disposition = %q, want %q", command, item.Disposition, record.Disposition)
}
if item.Risk != record.Risk {
t.Errorf("%s: runtime risk = %q, reviewed risk = %q", command, item.Risk, record.Risk)
}
if !record.Public || item.Hidden || !shortcut.InPublicCatalog("aitable", command) {
t.Errorf("%s: reviewed available AI Table Shortcut must be public", command)
}
if item.Contract.Empty() {
t.Errorf("%s: public AI Table Shortcut has empty Contract", command)
continue
}
if strings.TrimSpace(item.Safety.Effect) == "" || strings.TrimSpace(item.Safety.Risk) == "" ||
strings.TrimSpace(item.Safety.Confirmation) == "" || strings.TrimSpace(item.Safety.Idempotency) == "" {
t.Errorf("%s: public AI Table Shortcut has incomplete explicit Safety: %#v", command, item.Safety)
}
wantCLIPath := "aitable " + command
if item.Contract.Identity.CLIPath != wantCLIPath || item.Contract.Identity.PrimaryCLIPath != wantCLIPath {
t.Errorf("%s: identity paths = %q/%q, want %q", command,
item.Contract.Identity.CLIPath, item.Contract.Identity.PrimaryCLIPath, wantCLIPath)
}
if len(item.Contract.Selection.UseWhen) == 0 || len(item.Contract.Selection.AvoidWhen) == 0 {
t.Errorf("%s: selection contract must contain positive and negative routing", command)
}
}
for command := range source.Shortcuts {
if _, ok := registered[command]; !ok {
stale = append(stale, command)
}
}
sort.Strings(missing)
sort.Strings(stale)
if len(missing) > 0 || len(stale) > 0 {
t.Fatalf("semantic catalog mismatch: missing=%v stale=%v", missing, stale)
}
}
+502 -35
View File
@@ -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)
}
})
}
}
+238 -37
View File
@@ -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).
@@ -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)
}
})
}
}
+559 -41
View File
@@ -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)
}
})
}
}
+59 -9
View File
@@ -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.
+60 -4
View File
@@ -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")
+4 -4
View File
@@ -360,12 +360,12 @@ var Import = shortcut.Shortcut{
Service: "doc",
Command: "+import",
Product: productDoc,
Description: "上传本地文件并等待转换成在线文档对象",
Intent: "当用户要把工作区内的 doc/docx/xls/xlsx/md/txt/xmind/mark 文件导入为钉钉在线对象,并可指定目标文件夹或知识库时使用。",
Description: "上传本地文件并等待转换成在线文档对象;白名单外格式自动改走文件上传原样入库",
Intent: "当用户要把工作区内的 doc/docx/xls/xlsx/md/txt/xmind/mark 文件导入为钉钉在线对象,并可指定目标文件夹或知识库时使用。白名单外格式(html/pdf 等)不报错,自动按原文件上传入库,结果带 fallback=upload、converted=false 标记。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"},
Contract: docContract("+import", "上传本地文件并等待转换成在线文档对象",
"当用户要把工作区内的 doc/docx/xls/xlsx/md/txt/xmind/mark 文件导入为钉钉在线对象,并可指定目标文件夹或知识库时使用。",
Contract: docContract("+import", "上传本地文件并等待转换成在线文档对象;白名单外格式自动改走文件上传原样入库",
"当用户要把工作区内的 doc/docx/xls/xlsx/md/txt/xmind/mark 文件导入为钉钉在线对象,并可指定目标文件夹或知识库时使用。白名单外格式(html/pdf 等)不报错,自动按原文件上传入库,结果带 fallback=upload、converted=false 标记。",
[]string{`dws doc +import --file ./report.docx --folder <FOLDER_ID>`, `dws doc +import --file ./notes.md --workspace <WORKSPACE_ID> --name "会议纪要"`}),
Flags: []shortcut.Flag{
{Name: "file", Type: shortcut.FlagString, Desc: "本地文件路径", Required: true},
@@ -6,35 +6,98 @@ package shortcut
// command discovery and skill generation.
func generatedPublicShortcutCatalog() map[string]struct{} {
return map[string]struct{}{
"aitable\u0000+advperm-disable": {},
"aitable\u0000+advperm-enable": {},
"aitable\u0000+attachment-put": {},
"aitable\u0000+attachment-remove": {},
"aitable\u0000+attachment-upload": {},
"aitable\u0000+base-bootstrap": {},
"aitable\u0000+base-copy": {},
"aitable\u0000+base-delete": {},
"aitable\u0000+base-get": {},
"aitable\u0000+base-get-primary-doc-id": {},
"aitable\u0000+base-list": {},
"aitable\u0000+base-schema-snapshot": {},
"aitable\u0000+base-search": {},
"aitable\u0000+base-update": {},
"aitable\u0000+chart-delete": {},
"aitable\u0000+chart-get": {},
"aitable\u0000+chart-share-get": {},
"aitable\u0000+chart-share-update": {},
"aitable\u0000+chart-update": {},
"aitable\u0000+chart-widgets-example": {},
"aitable\u0000+dashboard-arrange": {},
"aitable\u0000+dashboard-config-example": {},
"aitable\u0000+dashboard-delete": {},
"aitable\u0000+dashboard-get": {},
"aitable\u0000+dashboard-share-get": {},
"aitable\u0000+dashboard-share-update": {},
"aitable\u0000+dashboard-update": {},
"aitable\u0000+export-data": {},
"aitable\u0000+field-delete": {},
"aitable\u0000+field-get": {},
"aitable\u0000+field-update": {},
"aitable\u0000+find-record": {},
"aitable\u0000+form-delete": {},
"aitable\u0000+form-field-hide": {},
"aitable\u0000+form-field-list": {},
"aitable\u0000+form-field-update": {},
"aitable\u0000+form-list": {},
"aitable\u0000+form-share-get": {},
"aitable\u0000+form-share-update": {},
"aitable\u0000+form-update": {},
"aitable\u0000+import-data": {},
"aitable\u0000+import-upload": {},
"aitable\u0000+list-tables": {},
"aitable\u0000+record-bulk-patch": {},
"aitable\u0000+record-delete": {},
"aitable\u0000+record-history-list": {},
"aitable\u0000+record-primary-doc-create": {},
"aitable\u0000+record-primary-doc-get": {},
"aitable\u0000+record-query": {},
"aitable\u0000+record-query-empty": {},
"aitable\u0000+record-share-links": {},
"aitable\u0000+record-share-url": {},
"aitable\u0000+record-update": {},
"aitable\u0000+record-upsert": {},
"aitable\u0000+record-upsert-by-key": {},
"aitable\u0000+resolve-base": {},
"aitable\u0000+resolve-table": {},
"aitable\u0000+role-create": {},
"aitable\u0000+role-delete": {},
"aitable\u0000+role-get": {},
"aitable\u0000+role-list": {},
"aitable\u0000+role-update": {},
"aitable\u0000+section-create": {},
"aitable\u0000+section-delete": {},
"aitable\u0000+section-list-empty": {},
"aitable\u0000+section-list-nodes": {},
"aitable\u0000+section-move-node": {},
"aitable\u0000+section-rename": {},
"aitable\u0000+section-reorder": {},
"aitable\u0000+table-copy": {},
"aitable\u0000+table-delete": {},
"aitable\u0000+table-get": {},
"aitable\u0000+table-update": {},
"aitable\u0000+template-search": {},
"aitable\u0000+url-resolve": {},
"aitable\u0000+view-delete": {},
"aitable\u0000+view-duplicate": {},
"aitable\u0000+view-get": {},
"aitable\u0000+view-get-frozen-cols": {},
"aitable\u0000+view-get-lock": {},
"aitable\u0000+view-get-row-height": {},
"aitable\u0000+view-lock": {},
"aitable\u0000+view-preset-apply": {},
"aitable\u0000+view-set-fill-color-rule": {},
"aitable\u0000+view-set-frozen-cols": {},
"aitable\u0000+view-set-row-height": {},
"aitable\u0000+view-update": {},
"aitable\u0000+workflow-deploy": {},
"aitable\u0000+workflow-disable": {},
"aitable\u0000+workflow-enable": {},
"aitable\u0000+workflow-get": {},
"aitable\u0000+workflow-list": {},
"attendance\u0000+check-record": {},
"attendance\u0000+check-result": {},
"attendance\u0000+get-adjustment-rule": {},
+36 -1
View File
@@ -155,7 +155,8 @@ func (rt *RuntimeContext) CallMCPData(product, tool string, params map[string]an
// 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.
// accidentally perform writes while rendering a preview. For compatibility with
// existing write shortcuts, an empty text acknowledgement remains an empty map.
func (rt *RuntimeContext) CallMCPWriteData(product, tool string, params map[string]any) (map[string]any, error) {
if rt.DryRun() {
return nil, dryRunWriteError(product, tool)
@@ -163,6 +164,16 @@ func (rt *RuntimeContext) CallMCPWriteData(product, tool string, params map[stri
return rt.callMCPData(product, tool, params)
}
// CallMCPWriteDataStrict dispatches a write tool call whose contract requires a
// non-empty JSON business result. An empty acknowledgement is reported as an
// unknown remote effect so callers can verify it independently before success.
func (rt *RuntimeContext) CallMCPWriteDataStrict(product, tool string, params map[string]any) (map[string]any, error) {
if rt.DryRun() {
return nil, dryRunWriteError(product, tool)
}
return rt.callMCPWriteData(product, tool, params)
}
func dryRunWriteError(product, tool string) error {
return apperrors.NewValidation(fmt.Sprintf(
"--dry-run 下禁止执行未明确分类为只读的工具 %s/%s;请在 shortcut 中输出 preview 后返回",
@@ -209,6 +220,30 @@ func (rt *RuntimeContext) callMCPReadData(product, tool string, params map[strin
return out, nil
}
func (rt *RuntimeContext) callMCPWriteData(product, tool string, params map[string]any) (map[string]any, error) {
if params == nil {
params = map[string]any{}
}
text, err := helpers.CallMCPToolTextOnServer(product, tool, params)
if err != nil {
return nil, err
}
if strings.TrimSpace(text) == "" {
return nil, apperrors.NewAPI("MCP write tool returned no business result; the remote effect is unknown",
apperrors.WithOperation(product+"/"+tool),
apperrors.WithOrigin("mcp"),
apperrors.WithFailureStage("response_validation"),
apperrors.WithRetryable(false),
apperrors.WithReason("empty_tool_response"),
)
}
var out map[string]any
if err := json.Unmarshal([]byte(text), &out); err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err))
}
return out, nil
}
// Output prints a (typically reshaped/projected) payload honouring the root
// --format/--jq/--fields flags. Multi-step shortcuts use it to emit a clean,
// composed result instead of the raw MCP response — the output-projection
+32
View File
@@ -4,8 +4,12 @@
package shortcut
import (
"errors"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/spf13/cobra"
)
@@ -16,3 +20,31 @@ func TestCrossPlatformCoverageRuntimeContextForTest(t *testing.T) {
t.Fatalf("RuntimeContextForTest = %#v", rt)
}
}
func TestCrossPlatformCoverageRuntimeWriteDataRemainingBranches(t *testing.T) {
caller := &runtimeReadCoverageCaller{text: `not-json`}
old := helpers.GetCaller()
t.Cleanup(func() { helpers.InitDeps(old) })
helpers.InitDeps(caller)
rt := &RuntimeContext{}
if _, err := rt.callMCPWriteData("aitable", "update_records", nil); err == nil || !strings.Contains(err.Error(), "解析") {
t.Fatalf("invalid write JSON = %v", err)
}
if caller.args == nil {
t.Fatal("nil write parameters were not normalized")
}
caller.text = ""
legacy, err := rt.CallMCPWriteData("chat", "send_personal_message", nil)
if err != nil || legacy == nil || len(legacy) != 0 {
t.Fatalf("legacy empty acknowledgement = %#v, %v", legacy, err)
}
if _, err = rt.CallMCPWriteDataStrict("aitable", "update_records", nil); err == nil {
t.Fatal("strict empty acknowledgement was accepted")
} else {
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "empty_tool_response" || !typed.RetryableSet || typed.Retryable {
t.Fatalf("strict empty acknowledgement error = %#v", err)
}
}
}
+4
View File
@@ -16,6 +16,9 @@ var semanticCatalogJSON []byte
//go:embed semantic_catalog_doc.json
var docSemanticCatalogJSON []byte
//go:embed semantic_catalog_aitable.json
var aitableSemanticCatalogJSON []byte
type semanticCatalogFile struct {
Version int `json:"version"`
Service string `json:"service"`
@@ -36,6 +39,7 @@ type semanticCatalogRecord struct {
var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
semanticCatalogJSON,
docSemanticCatalogJSON,
aitableSemanticCatalogJSON,
)
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
+1 -1
View File
@@ -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
@@ -0,0 +1,112 @@
{
"version": 1,
"service": "aitable",
"default_availability": "available",
"shortcuts": {
"+base-list": {"disposition":"semantic_adapter","semantic_delta":"把最近访问 Base 响应稳定投影为 baseId/baseName 列表,作为未知 baseId 时的浏览入口。","risk":"read","public":true,"reviewed":true},
"+base-search": {"disposition":"semantic_adapter","semantic_delta":"按名称关键词定位 Base,并稳定投影可继续操作的 baseId/baseName。","risk":"read","public":true,"reviewed":true},
"+base-get": {"disposition":"schema_leaf","semantic_delta":"保留 Base 目录读取的 Shortcut 兼容入口;底层 leaf 已提供同等读取能力。","risk":"read","public":true,"reviewed":true},
"+base-get-primary-doc-id": {"disposition":"schema_leaf","semantic_delta":"已知 base/table/record 时读取主键文档 dentryUuid 的一对一入口。","risk":"read","public":true,"reviewed":true},
"+base-update": {"disposition":"schema_leaf","semantic_delta":"更新 Base 名称和备注的一对一写入,复用统一确认门。","risk":"write","public":true,"reviewed":true},
"+base-delete": {"disposition":"schema_leaf","semantic_delta":"不可逆删除整个 Base 的兼容入口,显式发布高风险确认事实。","risk":"high-risk-write","public":true,"reviewed":true},
"+base-copy": {"disposition":"semantic_adapter","semantic_delta":"统一完整复制与仅结构复制意图,并把目标目录映射为可执行参数。","risk":"write","public":true,"reviewed":true},
"+base-schema-snapshot": {"disposition":"primary_smart","semantic_delta":"完整读取 Base、tables、fields、views,逐层区分合法空集合与缺失响应契约。","risk":"read","public":true,"reviewed":true},
"+base-bootstrap": {"disposition":"primary_smart","semantic_delta":"声明式创建 Base、表和字段,字段自动分片,逐层读回验证并报告部分副作用。","risk":"write","public":true,"reviewed":true},
"+url-resolve": {"disposition":"primary_smart","semantic_delta":"严格解析 alidocs AI 表格 URL 的 base/table/view/record ID,并可通过最深层只读接口验证目标存在。","risk":"read","public":true,"reviewed":true},
"+table-get": {"disposition":"semantic_adapter","semantic_delta":"批量读取表级信息、字段和视图目录,为后续 Schema 与记录操作提供 ID。","risk":"read","public":true,"reviewed":true},
"+table-copy": {"disposition":"primary_smart","semantic_delta":"在没有服务端 table-copy task 的情况下,本地重建安全字段、映射 fieldId,并可分片复制及读回验证全部记录。","risk":"write","public":true,"reviewed":true},
"+table-update": {"disposition":"schema_leaf","semantic_delta":"统一表名、备注和行命名规则的 PATCH 风格更新。","risk":"write","public":true,"reviewed":true},
"+table-delete": {"disposition":"schema_leaf","semantic_delta":"不可逆删除数据表的一对一入口,显式发布高风险确认事实。","risk":"high-risk-write","public":true,"reviewed":true},
"+field-get": {"disposition":"semantic_adapter","semantic_delta":"批量获取字段完整类型配置,避免仅返回名称的损失投影。","risk":"read","public":true,"reviewed":true},
"+field-update": {"disposition":"semantic_adapter","semantic_delta":"统一字段名称、类型配置和 AI 配置的增量更新输入。","risk":"write","public":true,"reviewed":true},
"+field-delete": {"disposition":"schema_leaf","semantic_delta":"不可逆删除字段的一对一入口,显式发布数据丢失风险。","risk":"high-risk-write","public":true,"reviewed":true},
"+record-query": {"disposition":"semantic_adapter","semantic_delta":"统一 ID、筛选、关键词和分页查询,并投影稳定的 records/count/cursor 结果。","risk":"read","public":true,"reviewed":true},
"+record-update": {"disposition":"primary_smart","semantic_delta":"自动按 100 条分片更新并逐条读回验证;失败返回可续跑的 nextOffset。","risk":"write","public":true,"reviewed":true},
"+record-delete": {"disposition":"primary_smart","semantic_delta":"自动按 100 条分片删除;每批仅在所有目标 recordId 读回均不存在时成功,失败提供 nextOffset。","risk":"high-risk-write","public":true,"reviewed":true},
"+record-query-empty": {"disposition":"semantic_adapter","semantic_delta":"扫描记录并按用户字段为空的语义过滤空行,避免调用方手工判断 cells。","risk":"read","public":true,"reviewed":true},
"+record-history-list": {"disposition":"schema_leaf","semantic_delta":"按 recordId 查询变更历史的一对一只读入口。","risk":"read","public":true,"reviewed":true},
"+record-share-url": {"disposition":"semantic_adapter","semantic_delta":"将最多 20 个 recordId 统一映射为可分享链接列表。","risk":"read","public":true,"reviewed":true},
"+record-upsert": {"disposition":"primary_smart","semantic_delta":"按 recordId 拆分 create/update,自动按 100 条分片;创建必须返回新 recordId,全部结果读回验证。","risk":"write","public":true,"reviewed":true},
"+record-upsert-by-key": {"disposition":"primary_smart","semantic_delta":"先按唯一 fieldId/value 完整查询,0 条创建、1 条更新、2 条以上停止,并读回验证最终 cells。","risk":"write","public":true,"reviewed":true},
"+record-bulk-patch": {"disposition":"primary_smart","semantic_delta":"先完整解析显式选择范围和 max-matches,再将同一 cells patch 自动分片写入并逐条读回验证。","risk":"high-risk-write","public":true,"reviewed":true},
"+record-primary-doc-get": {"disposition":"schema_leaf","semantic_delta":"读取记录主键文档 nodeId 的一对一入口。","risk":"read","public":true,"reviewed":true},
"+record-primary-doc-create": {"disposition":"semantic_adapter","semantic_delta":"以幂等意图为 primaryDoc 字段创建关联文档。","risk":"write","public":true,"reviewed":true},
"+template-search": {"disposition":"semantic_adapter","semantic_delta":"按名称关键词搜索 AI 表格模板并返回可用于创建的 templateId。","risk":"read","public":true,"reviewed":true},
"+attachment-upload": {"disposition":"schema_leaf","semantic_delta":"申请附件 OSS 直传地址和 fileToken;只负责上传准备,不伪造文件已上传。","risk":"write","public":true,"reviewed":true},
"+attachment-put": {"disposition":"primary_smart","semantic_delta":"完整执行 prepare、HTTP PUT、attachment cell 覆盖和读回验证;空写回包只有最终附件可证明时成功。","risk":"write","public":true,"reviewed":true},
"+attachment-remove": {"disposition":"primary_smart","semantic_delta":"清空或按文件名移除附件;若读回缺剩余 fileToken,会在写前明确停止而不破坏其他附件。","risk":"high-risk-write","public":true,"reviewed":true},
"+view-get": {"disposition":"semantic_adapter","semantic_delta":"读取视图完整列顺序、筛选、排序和分组配置。","risk":"read","public":true,"reviewed":true},
"+view-update": {"disposition":"semantic_adapter","semantic_delta":"统一视图名称、描述、可见列、筛选、排序和分组配置更新。","risk":"write","public":true,"reviewed":true},
"+view-preset-apply": {"disposition":"primary_smart","semantic_delta":"按精确名称幂等创建或更新视图预设,拒绝同名歧义和类型漂移,并读回校验 config。","risk":"write","public":true,"reviewed":true},
"+view-duplicate": {"disposition":"schema_leaf","semantic_delta":"复制现有视图配置并生成新视图的一对一入口。","risk":"write","public":true,"reviewed":true},
"+view-delete": {"disposition":"schema_leaf","semantic_delta":"不可逆删除视图的一对一入口,显式发布高风险确认事实。","risk":"high-risk-write","public":true,"reviewed":true},
"+view-get-lock": {"disposition":"schema_leaf","semantic_delta":"读取视图锁定状态的一对一入口。","risk":"read","public":true,"reviewed":true},
"+view-lock": {"disposition":"semantic_adapter","semantic_delta":"用默认锁定与 --off 解锁统一表达视图锁状态切换。","risk":"write","public":true,"reviewed":true},
"+view-get-frozen-cols": {"disposition":"schema_leaf","semantic_delta":"读取 Grid 视图冻结列数的一对一入口。","risk":"read","public":true,"reviewed":true},
"+view-set-frozen-cols": {"disposition":"semantic_adapter","semantic_delta":"设置冻结列数并以 0 明确表达取消冻结。","risk":"write","public":true,"reviewed":true},
"+view-get-row-height": {"disposition":"schema_leaf","semantic_delta":"读取视图行高像素值的一对一入口。","risk":"read","public":true,"reviewed":true},
"+view-set-row-height": {"disposition":"semantic_adapter","semantic_delta":"把行高限制为服务支持的 32/56/88/128 像素档位后写入。","risk":"write","public":true,"reviewed":true},
"+view-set-fill-color-rule": {"disposition":"semantic_adapter","semantic_delta":"以全量覆盖语义设置条件填色规则,并用空数组明确清空。","risk":"write","public":true,"reviewed":true},
"+form-list": {"disposition":"semantic_adapter","semantic_delta":"从表的视图中稳定筛出表单视图并返回表单目录。","risk":"read","public":true,"reviewed":true},
"+form-delete": {"disposition":"schema_leaf","semantic_delta":"不可逆删除表单视图的一对一入口。","risk":"high-risk-write","public":true,"reviewed":true},
"+form-update": {"disposition":"semantic_adapter","semantic_delta":"统一更新表单标题和描述。","risk":"write","public":true,"reviewed":true},
"+form-field-list": {"disposition":"semantic_adapter","semantic_delta":"列出表单当前字段顺序、可见性、必填和描述配置。","risk":"read","public":true,"reviewed":true},
"+form-field-update": {"disposition":"semantic_adapter","semantic_delta":"统一更新表单字段必填状态与描述。","risk":"write","public":true,"reviewed":true},
"+form-field-hide": {"disposition":"semantic_adapter","semantic_delta":"用隐藏/显示布尔语义切换表单字段可见性。","risk":"write","public":true,"reviewed":true},
"+form-share-get": {"disposition":"schema_leaf","semantic_delta":"读取当前视图分享表单配置的一对一入口。","risk":"read","public":true,"reviewed":true},
"+form-share-update": {"disposition":"semantic_adapter","semantic_delta":"统一开启或关闭指定视图的分享表单。","risk":"write","public":true,"reviewed":true},
"+workflow-enable": {"disposition":"schema_leaf","semantic_delta":"启用指定自动化工作流的一对一入口。","risk":"write","public":true,"reviewed":true},
"+workflow-disable": {"disposition":"schema_leaf","semantic_delta":"禁用工作流会影响业务自动化,显式发布高风险确认事实。","risk":"high-risk-write","public":true,"reviewed":true},
"+workflow-get": {"disposition":"schema_leaf","semantic_delta":"读取单个自动化工作流详情的一对一入口。","risk":"read","public":true,"reviewed":true},
"+workflow-list": {"disposition":"schema_leaf","semantic_delta":"分页列出 Base 自动化工作流的一对一入口。","risk":"read","public":true,"reviewed":true},
"+workflow-deploy": {"disposition":"primary_smart","semantic_delta":"创建或更新完整 workflow-dsl/v1,强制 valid=true/flowId,并可启用后从 list 验证 RUNNING。","risk":"write","public":true,"reviewed":true},
"+dashboard-config-example": {"disposition":"schema_leaf","semantic_delta":"返回 dashboard config 的服务端结构示例,供后续写入构造合法参数。","risk":"read","public":true,"reviewed":true},
"+dashboard-get": {"disposition":"semantic_adapter","semantic_delta":"读取仪表盘详情及 charts summary,作为图表操作导航入口。","risk":"read","public":true,"reviewed":true},
"+dashboard-update": {"disposition":"schema_leaf","semantic_delta":"更新仪表盘配置的一对一入口。","risk":"write","public":true,"reviewed":true},
"+dashboard-delete": {"disposition":"schema_leaf","semantic_delta":"级联删除仪表盘及图表,显式发布不可逆高风险事实。","risk":"high-risk-write","public":true,"reviewed":true},
"+dashboard-arrange": {"disposition":"semantic_adapter","semantic_delta":"触发服务端智能布局重排并保留统一写入确认。","risk":"write","public":true,"reviewed":true},
"+dashboard-share-get": {"disposition":"schema_leaf","semantic_delta":"读取仪表盘分享配置的一对一入口。","risk":"read","public":true,"reviewed":true},
"+dashboard-share-update": {"disposition":"semantic_adapter","semantic_delta":"统一开启、关闭和设置仪表盘分享类型。","risk":"write","public":true,"reviewed":true},
"+chart-widgets-example": {"disposition":"schema_leaf","semantic_delta":"返回所有图表类型的 widget config 示例,供构造合法图表配置。","risk":"read","public":true,"reviewed":true},
"+chart-get": {"disposition":"schema_leaf","semantic_delta":"读取指定图表完整配置的一对一入口。","risk":"read","public":true,"reviewed":true},
"+chart-update": {"disposition":"semantic_adapter","semantic_delta":"统一更新图表配置或布局,并要求显式 config 输入。","risk":"write","public":true,"reviewed":true},
"+chart-delete": {"disposition":"schema_leaf","semantic_delta":"删除图表及布局项,显式发布不可逆高风险事实。","risk":"high-risk-write","public":true,"reviewed":true},
"+chart-share-get": {"disposition":"schema_leaf","semantic_delta":"读取图表分享配置的一对一入口。","risk":"read","public":true,"reviewed":true},
"+chart-share-update": {"disposition":"semantic_adapter","semantic_delta":"统一开启、关闭和设置图表分享类型。","risk":"write","public":true,"reviewed":true},
"+export-data": {"disposition":"primary_smart","semantic_delta":"统一创建导出任务与按 taskId 续等,直到得到可下载的导出结果或明确失败。","risk":"read","public":true,"reviewed":true},
"+import-upload": {"disposition":"schema_leaf","semantic_delta":"申请导入 OSS 直传地址和 importId;只负责上传准备,不伪造导入完成。","risk":"write","public":true,"reviewed":true},
"+import-data": {"disposition":"semantic_adapter","semantic_delta":"统一将已上传文件导入新表或追加到已有表的目标语义。","risk":"write","public":true,"reviewed":true},
"+advperm-enable": {"disposition":"schema_leaf","semantic_delta":"开启 Base 高级权限总开关的一对一入口。","risk":"write","public":true,"reviewed":true},
"+advperm-disable": {"disposition":"schema_leaf","semantic_delta":"关闭高级权限会使自定义角色失效,显式发布高风险事实。","risk":"high-risk-write","public":true,"reviewed":true},
"+role-list": {"disposition":"schema_leaf","semantic_delta":"列出 Base 全部自定义角色的一对一入口。","risk":"read","public":true,"reviewed":true},
"+role-get": {"disposition":"schema_leaf","semantic_delta":"读取单个角色完整权限配置的一对一入口。","risk":"read","public":true,"reviewed":true},
"+role-create": {"disposition":"semantic_adapter","semantic_delta":"统一角色名称、成员与权限配置的创建输入。","risk":"write","public":true,"reviewed":true},
"+role-update": {"disposition":"semantic_adapter","semantic_delta":"按 PATCH 语义增量更新角色配置,未提供字段保持不变。","risk":"write","public":true,"reviewed":true},
"+role-delete": {"disposition":"schema_leaf","semantic_delta":"不可逆删除自定义角色的一对一入口。","risk":"high-risk-write","public":true,"reviewed":true},
"+section-create": {"disposition":"semantic_adapter","semantic_delta":"在 Base 目录树创建用于组织表和仪表盘的文件夹。","risk":"write","public":true,"reviewed":true},
"+section-rename": {"disposition":"schema_leaf","semantic_delta":"重命名目录文件夹的一对一入口。","risk":"write","public":true,"reviewed":true},
"+section-delete": {"disposition":"schema_leaf","semantic_delta":"不可逆删除目录文件夹的一对一入口。","risk":"high-risk-write","public":true,"reviewed":true},
"+section-reorder": {"disposition":"semantic_adapter","semantic_delta":"按当前父目录语义调整文件夹展示顺序。","risk":"write","public":true,"reviewed":true},
"+section-list-empty": {"disposition":"semantic_adapter","semantic_delta":"扫描目录树并只返回没有子节点的空文件夹。","risk":"read","public":true,"reviewed":true},
"+section-list-nodes": {"disposition":"semantic_adapter","semantic_delta":"列出当前版本全部 nsheet 节点,提供表、仪表盘和文件夹统一目录。","risk":"read","public":true,"reviewed":true},
"+section-move-node": {"disposition":"semantic_adapter","semantic_delta":"统一移动任意 nsheet 节点到文件夹或 Base 根目录,并可指定位置。","risk":"write","public":true,"reviewed":true},
"+find-record": {"disposition":"semantic_adapter","semantic_delta":"将关键词查询投影为稳定的 recordId/cells 结果,适合先定位再写入。","risk":"read","public":true,"reviewed":true},
"+list-tables": {"disposition":"semantic_adapter","semantic_delta":"把 Base 目录稳定投影为 tableId/tableName 列表,隐藏非表节点。","risk":"read","public":true,"reviewed":true},
"+record-share-links": {"disposition":"primary_smart","semantic_delta":"对超过 20 条 recordId 去重分片调用并合并分享链接及逐批结果。","risk":"read","public":true,"reviewed":true},
"+resolve-base": {"disposition":"primary_smart","semantic_delta":"按名称搜索并要求唯一匹配,0 或多结果时显式失败而不猜 baseId。","risk":"read","public":true,"reviewed":true},
"+resolve-table": {"disposition":"primary_smart","semantic_delta":"在 Base 内按名称要求唯一匹配,0 或多结果时显式失败而不猜 tableId。","risk":"read","public":true,"reviewed":true}
}
}
+1 -1
View File
@@ -10,7 +10,7 @@
"+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},
"+import": {"disposition":"primary_smart","semantic_delta":"一体化创建会话、上传、确认转换并轮询导入结果;白名单外格式自动改走文件上传原样入库(fallback=upload、converted=false)。","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},
+11 -6
View File
@@ -671,12 +671,17 @@ func TestCrossPlatformCoverageCallMCPWriteDataRejectsDryRun(t *testing.T) {
}
rt := &RuntimeContext{cmd: cmd, shortcut: Shortcut{Service: "calendar"}}
_, err := rt.CallMCPWriteData("calendar", "create_calendar_event", map[string]any{"summary": "x"})
if err == nil {
t.Fatal("expected dry-run write guard error")
}
if !strings.Contains(err.Error(), "calendar/create_calendar_event") {
t.Fatalf("error = %q, want tool name", err.Error())
for name, call := range map[string]func(string, string, map[string]any) (map[string]any, error){
"legacy": rt.CallMCPWriteData,
"strict": rt.CallMCPWriteDataStrict,
} {
_, err := call("calendar", "create_calendar_event", map[string]any{"summary": "x"})
if err == nil {
t.Fatalf("%s: expected dry-run write guard error", name)
}
if !strings.Contains(err.Error(), "calendar/create_calendar_event") {
t.Fatalf("%s: error = %q, want tool name", name, err.Error())
}
}
}
@@ -0,0 +1,124 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package smart
import (
"bytes"
"context"
"errors"
"testing"
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"
"github.com/spf13/cobra"
)
type aitableResolverCaller struct {
text string
calls int
tool string
args map[string]any
}
func (c *aitableResolverCaller) CallTool(_ context.Context, _, tool string, args map[string]any) (*edition.ToolResult, error) {
c.calls++
c.tool = tool
c.args = args
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: c.text}}}, nil
}
func (*aitableResolverCaller) Format() string { return "json" }
func (*aitableResolverCaller) DryRun() bool { return false }
func (*aitableResolverCaller) Fields() string { return "" }
func (*aitableResolverCaller) JQ() string { return "" }
func runAITableResolverCLI(t *testing.T, caller *aitableResolverCaller, args ...string) (string, error) {
t.Helper()
helpers.InitDepsForTest(t, caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
root.AddCommand(shortcut.Commands()...)
stdout := &bytes.Buffer{}
root.SetOut(stdout)
root.SetErr(&bytes.Buffer{})
root.SetArgs(args)
err := root.Execute()
return stdout.String(), err
}
func TestCrossPlatformCoverageResolveBaseCLIExactMatchE2E(t *testing.T) {
caller := &aitableResolverCaller{text: `{"data":{"bases":[{"baseId":"b1","baseName":"项目归档"},{"baseId":"b2","baseName":"项目"}],"hasMore":false}}`}
out, err := runAITableResolverCLI(t, caller, "aitable", "+resolve-base", "--name", "项目")
if err != nil {
t.Fatalf("resolve base CLI error = %v", err)
}
for _, want := range []string{`"resolved": true`, `"matchType": "exact"`, `"baseId": "b2"`} {
if !bytes.Contains([]byte(out), []byte(want)) {
t.Fatalf("output missing %s: %s", want, out)
}
}
if caller.tool != "search_bases" || caller.args["query"] != "项目" {
t.Fatalf("resolver call = %s %#v", caller.tool, caller.args)
}
}
func TestCrossPlatformCoverageResolveBaseCLIAmbiguityIsFailureE2E(t *testing.T) {
caller := &aitableResolverCaller{text: `{"bases":[{"baseId":"b1","baseName":"项目"},{"baseId":"b2","baseName":"项目"}]}`}
out, err := runAITableResolverCLI(t, caller, "aitable", "+resolve-base", "--name", "项目")
if err == nil || out != "" {
t.Fatalf("ambiguous resolver = output:%q err:%v", out, err)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "target_ambiguous" {
t.Fatalf("ambiguous resolver error = %#v", err)
}
}
func TestCrossPlatformCoverageResolveTableCLIFuzzyRequiresOptInE2E(t *testing.T) {
payload := `{"tables":[{"tableId":"t1","tableName":"任务归档"}]}`
caller := &aitableResolverCaller{text: payload}
out, err := runAITableResolverCLI(t, caller, "aitable", "+resolve-table", "--base", "base", "--name", "任务")
if err == nil || out != "" {
t.Fatalf("implicit fuzzy resolver = output:%q err:%v", out, err)
}
caller = &aitableResolverCaller{text: payload}
out, err = runAITableResolverCLI(t, caller, "aitable", "+resolve-table", "--base", "base", "--name", "任务", "--fuzzy")
if err != nil || !bytes.Contains([]byte(out), []byte(`"matchType": "fuzzy"`)) || !bytes.Contains([]byte(out), []byte(`"tableId": "t1"`)) {
t.Fatalf("explicit fuzzy resolver = output:%q err:%v", out, err)
}
}
func TestCrossPlatformCoverageResolveCLIUnknownResponseIsNotNotFoundE2E(t *testing.T) {
for name, payload := range map[string]string{
"empty response": "",
"missing collection": `{"success":true}`,
"wrong collection type": `{"bases":{}}`,
"malformed candidate": `{"bases":[{"baseName":"项目"}]}`,
} {
t.Run(name, func(t *testing.T) {
caller := &aitableResolverCaller{text: payload}
out, err := runAITableResolverCLI(t, caller, "aitable", "+resolve-base", "--name", "项目")
if err == nil || out != "" {
t.Fatalf("unknown response = output:%q err:%v", out, err)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "target_invalid_response" {
t.Fatalf("unknown response error = %#v", err)
}
})
}
caller := &aitableResolverCaller{text: `{"bases":[]}`}
out, err := runAITableResolverCLI(t, caller, "aitable", "+resolve-base", "--name", "项目")
if err == nil || out != "" {
t.Fatalf("explicit empty candidates = output:%q err:%v", out, err)
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "target_not_found" {
t.Fatalf("explicit empty candidates error = %#v", err)
}
}
+212 -45
View File
@@ -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.
+316 -44
View File
@@ -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,
+334 -6
View File
@@ -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)
}
}
+192 -29
View File
@@ -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

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