Compare commits
106
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bb8c8b586 | ||
|
|
48e522ec00 | ||
|
|
0e0007d7b7 | ||
|
|
176b217139 | ||
|
|
5c8fd0a48c | ||
|
|
7490bb95c5 | ||
|
|
b186e59a01 | ||
|
|
a92f54df3d | ||
|
|
307c9e797b | ||
|
|
07ce090eeb | ||
|
|
201949aec1 | ||
|
|
8eeabd1419 | ||
|
|
6035d43899 | ||
|
|
ba375b40fa | ||
|
|
0a063662a0 | ||
|
|
4148a90bf5 | ||
|
|
262248d08d | ||
|
|
e7955b5891 | ||
|
|
efb172dcd6 | ||
|
|
6572010922 | ||
|
|
0cbb1b8d30 | ||
|
|
6738d0f29e | ||
|
|
db6addfc0e | ||
|
|
384b067ead | ||
|
|
c32d10bd43 | ||
|
|
17f153a070 | ||
|
|
959bc4c1a8 | ||
|
|
9f80006b3b | ||
|
|
e530aea14d | ||
|
|
32515729af | ||
|
|
2a7ab22e85 | ||
|
|
757f45df31 | ||
|
|
ef71673c69 | ||
|
|
af0086c9a8 | ||
|
|
28f75dec0a | ||
|
|
15d5be6000 | ||
|
|
ebfba82ebc | ||
|
|
ec9897678f | ||
|
|
af6e566abd | ||
|
|
4ea298ec61 | ||
|
|
7aba24b690 | ||
|
|
60e278f32f | ||
|
|
7acbec2615 | ||
|
|
6811a12330 | ||
|
|
c54a9e1e5e | ||
|
|
a15fb22671 | ||
|
|
a1712337a8 | ||
|
|
d65ad9aa2e | ||
|
|
5d25a10223 | ||
|
|
7688f95d2b | ||
|
|
c2ee691db7 | ||
|
|
368c879f45 | ||
|
|
1920552ab0 | ||
|
|
f1b5330a4e | ||
|
|
0d5c6d92e3 | ||
|
|
f9ccff963f | ||
|
|
49a17b4375 | ||
|
|
b9e7ff8c55 | ||
|
|
b94e21331d | ||
|
|
637a6f2f68 | ||
|
|
ae9ba333a0 | ||
|
|
6dbc8399df | ||
|
|
24cdb85d71 | ||
|
|
d75b744a35 | ||
|
|
7134f33e1d | ||
|
|
88f7ea5679 | ||
|
|
c0b562cc07 | ||
|
|
37fbb110e3 | ||
|
|
7564496ea0 | ||
|
|
70d58648c8 | ||
|
|
665b79d8da | ||
|
|
32e7a80889 | ||
|
|
5e6b588b5e | ||
|
|
fae21ec9f2 | ||
|
|
779fd82a88 | ||
|
|
3e60b83881 | ||
|
|
66a676b6aa | ||
|
|
23bbbccb7e | ||
|
|
868d9ff2b0 | ||
|
|
cb2f240c0c | ||
|
|
d800060e06 | ||
|
|
8d8206f791 | ||
|
|
15c2bd50b8 | ||
|
|
711d557b93 | ||
|
|
6102e9fc68 | ||
|
|
fd61868393 | ||
|
|
ae426f37de | ||
|
|
4dbfaa4cef | ||
|
|
f1e95d763d | ||
|
|
9140015c42 | ||
|
|
de418c5696 | ||
|
|
5ec5b9811c | ||
|
|
378eaa377e | ||
|
|
d4368be1c3 | ||
|
|
9733acfb5a | ||
|
|
b1d422dcfd | ||
|
|
1231e500a3 | ||
|
|
c508968814 | ||
|
|
10943629d6 | ||
|
|
9fd9ae7a95 | ||
|
|
5b0198b2ec | ||
|
|
dc854acb9b | ||
|
|
d054e3dc01 | ||
|
|
95536c3e97 | ||
|
|
1bfedbd4d2 | ||
|
|
cfaf161fc7 |
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
@@ -111,6 +111,7 @@ jobs:
|
||||
filename.startsWith('internal/app/') ||
|
||||
filename.startsWith('internal/cli/') ||
|
||||
filename.startsWith('internal/cobracmd/') ||
|
||||
filename.startsWith('internal/corecmd/') ||
|
||||
filename.startsWith('internal/helpers/') ||
|
||||
filename.startsWith('internal/i18n/') ||
|
||||
filename.startsWith('internal/interfacesnapshot/') ||
|
||||
@@ -432,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
|
||||
@@ -1385,6 +1390,13 @@ jobs:
|
||||
BASE_REF="$COMPATIBILITY_STABLE_REF"
|
||||
fi
|
||||
|
||||
- name: Check complete CLI command compatibility
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
run: |
|
||||
./scripts/policy/check-command-compatibility.sh \
|
||||
--base-ref "$COMPATIBILITY_BASE_REF" \
|
||||
--stable-ref "$COMPATIBILITY_STABLE_REF"
|
||||
|
||||
- name: Check complete Schema compatibility
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+48
-11
@@ -6,23 +6,59 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- **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.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **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.
|
||||
|
||||
## [1.0.57-beta.4] - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Expanded open CLI workflows** (#887) — adds calendar event-instance
|
||||
queries, Drive latest-file selection, Markdown diff, Mail calendar/export/
|
||||
share-to-chat workflows, and Minutes hot-word, permission, and audio-memo
|
||||
operations, with matching Schema and cross-platform coverage.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Multi-skill framework alignment** (#887) — folds long-tail skills into
|
||||
`dingtalk-misc`, renames the shared package to `dingtalk-shared`, removes
|
||||
stale Preview guidance, and reorganizes shared recipes and routing for more
|
||||
predictable Agent selection.
|
||||
- **Bounded Agent Schema delivery** (#887) — keeps compact and wire projections
|
||||
focused on executable contract facts, retires stale MCP metadata candidates,
|
||||
and teaches Agents to prefer `dws schema --compact` for bounded context.
|
||||
|
||||
### Deprecated
|
||||
|
||||
- **`dws recovery` CLI** — keeps a visible Deprecated compatibility stub for
|
||||
plan/execute/finalize that returns an explicit “不再支持” notice. The
|
||||
`internal/recovery` package is removed; Skills must not teach this path.
|
||||
Transport errors keep generic retry/auth guidance without pointing callers
|
||||
at recovery.
|
||||
- **Recovery and discovery-cache compatibility surfaces** (#887) — keeps
|
||||
visible Deprecated `dws recovery` and `dws cache` compatibility stubs while
|
||||
retiring their former recovery engine and dynamic discovery-cache behavior.
|
||||
Recovery plan/execute/finalize now return an explicit “不再支持” notice, and
|
||||
Skills no longer teach either retired workflow.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Mail share-to-chat confirmation** (#887) — requires explicit confirmation
|
||||
before the first remote write, while preserving the confirmed sign-retry
|
||||
flow and covering both direct-success and retry responses.
|
||||
|
||||
## [1.0.57] - 2026-08-06
|
||||
|
||||
This stable release promotes the fully delivered `v1.0.57-beta.3` baseline.
|
||||
It includes reviewed document shortcuts and chat reply mentions, together with
|
||||
the v1.0.57 beta-line command-contract, document, chat, OA, Wiki, compatibility,
|
||||
and CI reliability improvements validated through the prerelease channel.
|
||||
This stable release promotes the fully delivered `v1.0.57-beta.4` baseline.
|
||||
It includes the v1.0.57 beta-line command-contract, document, chat, OA, Wiki,
|
||||
and compatibility improvements, plus the multi-skill framework alignment and
|
||||
expanded calendar, Drive, Markdown, Mail, and Minutes workflows validated in
|
||||
the final prerelease.
|
||||
|
||||
- **Promote v1.0.57-beta.3** — publishes the validated prerelease baseline as
|
||||
the stable `v1.0.57` release without adding post-beta product changes.
|
||||
- **Promote v1.0.57-beta.4** — publishes the final validated prerelease
|
||||
baseline as stable `v1.0.57` without adding post-beta product changes.
|
||||
|
||||
## [1.0.57-beta.3] - 2026-08-06
|
||||
|
||||
@@ -100,6 +136,7 @@ and compatibility and CI reliability fixes.
|
||||
### Added
|
||||
|
||||
- **Document-embedded whiteboard workflows** — adds `doc whiteboard insert` for confirmed creation and part-ID verification, `whiteboard query/update` for structured OpenNodes reads and confirmed writes, and `doc media upload` for preparing node-bound Vector/SVG resources. The public adapter uses an explicit helper-only whiteboard endpoint, validates update envelopes locally, decodes `resultJson`, and publishes the full command, Schema, Skill, and safety contract migrated from `dws-wukong@e2da8ab947c6`.
|
||||
- **Robot image and file messages** — extends `chat message send-by-bot` with public image URL delivery through `--msg-type image --image-url` and local-file upload/send through `--msg-type file --file-path`, while preserving Markdown as the default message type.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.57-beta.3"
|
||||
version "1.0.57-beta.4"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-darwin-arm64.tar.gz"
|
||||
sha256 "b1ea300a76654751ea33540d8a244b0c81b5df3947786980f95a5a19362a097a"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "ff363e258d463732e4dc02aa71ac1b5b1f05c25107c32ddcc5ae11d14931a782"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-darwin-amd64.tar.gz"
|
||||
sha256 "68b5f6e38bec994fa4db4bef5db1629c7bce799bb9837c447008c3325fc886e3"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "3eafcc4c27931b8457f3611a12ce4ae727f7bd65356fbef80958699defa09acf"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-linux-arm64.tar.gz"
|
||||
sha256 "350e74f1a2611975e476e113e50264975a98185c11ee889a83d9480c0f10181b"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "49eabb4d2c419d8d1fb0fcf71d9c318ef49cf3d198431df1bcbaa71589e11383"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-linux-amd64.tar.gz"
|
||||
sha256 "2c27a9a884650a7a60545d9447f1b966667216e94c333ebf9d69f9b2ead96e04"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "fb3903485fe494e1fadb67ec87b2fef19cf575c72b7df25f0b93f80f8b36273f"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-skills.zip"
|
||||
sha256 "abaa8feaa3c61fff048cfd1139e1fc5b91c329eb666d2eb852797a3f5c4c0cac"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-skills.zip"
|
||||
sha256 "6bd11363dbd2ce79627f49bc80b8a92f9c79fca083a6bd3ff9bc5c4833594fb9"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCli < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.56"
|
||||
version "1.0.57"
|
||||
license "Apache-2.0"
|
||||
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-darwin-arm64.tar.gz"
|
||||
sha256 "5c6003fe484aa36cc00820a574186652467b9d075f19c159cf807e57590256ba"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-arm64.tar.gz"
|
||||
sha256 "c01c28dc13948a70fca905207073dc8dbd22f7ba7fc90e68b3316eb9a9c98e88"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-darwin-amd64.tar.gz"
|
||||
sha256 "969b005a10682c2a1a828fa112165b5b0cd8ceeed8d22110ef7f39402cc36804"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-amd64.tar.gz"
|
||||
sha256 "d7baa218beefc851c6a933b456055195f8272984ce008d7e0122bdfc5dad94ea"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-linux-arm64.tar.gz"
|
||||
sha256 "530c5ea7ddc7de320d9c2471fbd33752a723d00c9665f49321c7580e8392c756"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-arm64.tar.gz"
|
||||
sha256 "0bbe9c233a3ff585077bae1ac5000937c32d967846d14cc44c46f98d49b95ae2"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-linux-amd64.tar.gz"
|
||||
sha256 "675fa42727ac9a549c6710b82e1980cd0f795363d71d5116a4e69771b7c5470e"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-amd64.tar.gz"
|
||||
sha256 "f113ce3654f21d1f9ecc7c196f815aeafbca54d377a347b244a15116c5cba698"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-skills.zip"
|
||||
sha256 "3d57794e4660a089209ce3962571d16ca0d46141e973c9993257a301cce0e097"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-skills.zip"
|
||||
sha256 "0c9667209cf30761427a8f9348149cbbf1e397aa3c25587e99f205bc7525e101"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -151,9 +151,13 @@ generate-schema:
|
||||
@set -e; \
|
||||
concepts_guard=$$(mktemp); \
|
||||
concepts_schema_guard=$$(mktemp); \
|
||||
trap 'rm -rf "$$concepts_guard" "$$concepts_schema_guard"' EXIT HUP INT TERM; \
|
||||
command_fallbacks_guard=$$(mktemp); \
|
||||
command_fallbacks_schema_guard=$$(mktemp); \
|
||||
trap 'rm -f "$$concepts_guard" "$$concepts_schema_guard" "$$command_fallbacks_guard" "$$command_fallbacks_schema_guard"' EXIT HUP INT TERM; \
|
||||
cp internal/cli/param_concepts.json "$$concepts_guard"; \
|
||||
cp internal/cli/param_concepts.schema.json "$$concepts_schema_guard"; \
|
||||
cp internal/cli/command_path_fallbacks.json "$$command_fallbacks_guard"; \
|
||||
cp internal/cli/command_path_fallbacks.schema.json "$$command_fallbacks_schema_guard"; \
|
||||
$(GO) generate ./internal/cli; \
|
||||
rm -rf internal/cli/schema_agent_metadata internal/cli/schema_agent_metadata_audit.json; \
|
||||
rm -f internal/cli/schema_meta_index.json; \
|
||||
@@ -169,6 +173,14 @@ generate-schema:
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
cmp -s internal/cli/command_path_fallbacks.json "$$command_fallbacks_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.json' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
cmp -s internal/cli/command_path_fallbacks.schema.json "$$command_fallbacks_schema_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.schema.json' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
if [ -e internal/cli/schema_hints ]; then \
|
||||
printf '%s\n' 'retired schema_hints/ must not reappear after generation' >&2; \
|
||||
exit 1; \
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
//go:embed testdata/shortcut_hallucination_badcases_20260728.json
|
||||
var shortcutHallucinationBadcases20260728JSON []byte
|
||||
|
||||
type shortcutHallucinationReplayCorpus struct {
|
||||
SourceReport string `json:"source_report"`
|
||||
SourceFile string `json:"source_file"`
|
||||
SourceDWSCommit string `json:"source_dws_commit"`
|
||||
Model string `json:"model"`
|
||||
ExpectedCount int `json:"expected_count"`
|
||||
Badcases []shortcutHallucinationReplayBadcase `json:"badcases"`
|
||||
}
|
||||
|
||||
type shortcutHallucinationReplayBadcase struct {
|
||||
ID string `json:"id"`
|
||||
CaseID string `json:"case_id"`
|
||||
Run int `json:"run"`
|
||||
CommandIndex int `json:"command_index"`
|
||||
Turn int `json:"turn"`
|
||||
SourcePath string `json:"source_path"`
|
||||
ExpectedOutcome string `json:"expected_outcome"`
|
||||
Raw string `json:"raw"`
|
||||
OriginalExitCode int `json:"original_exit_code"`
|
||||
IsHelp bool `json:"is_help"`
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageShortcutHallucinationBadcases20260728Replay(t *testing.T) {
|
||||
var corpus shortcutHallucinationReplayCorpus
|
||||
if err := json.Unmarshal(shortcutHallucinationBadcases20260728JSON, &corpus); err != nil {
|
||||
t.Fatalf("decode historical shortcut badcase corpus: %v", err)
|
||||
}
|
||||
if corpus.SourceReport != "param_hallucination_20260728_120943" ||
|
||||
corpus.SourceFile != "raw_dashscope_qwen3_7-max_20260728_120943.json" ||
|
||||
corpus.SourceDWSCommit != "a8e83e5" || corpus.Model != "dashscope/qwen3.7-max" {
|
||||
t.Fatalf("unexpected historical corpus provenance: %#v", corpus)
|
||||
}
|
||||
if corpus.ExpectedCount != 52 || len(corpus.Badcases) != corpus.ExpectedCount {
|
||||
t.Fatalf("historical shortcut badcases = %d, expected_count=%d; want 52", len(corpus.Badcases), corpus.ExpectedCount)
|
||||
}
|
||||
|
||||
wantOutcomeCounts := map[string]int{
|
||||
"rewrite": 8,
|
||||
"ambiguous": 44,
|
||||
}
|
||||
wantSourceCounts := map[string]int{
|
||||
"chat +group-member-list": 1,
|
||||
"chat +group-send-text": 1,
|
||||
"chat +list-group-bots": 1,
|
||||
"chat +list-robot": 1,
|
||||
"chat +list-robots": 1,
|
||||
"chat +members": 3,
|
||||
"chat +message-list": 1,
|
||||
"chat +read-single": 6,
|
||||
"chat +rename-group": 1,
|
||||
"chat +send": 4,
|
||||
"chat +send-by-bot": 3,
|
||||
"chat +send-dm": 2,
|
||||
"chat +send-file": 15,
|
||||
"chat +send-image": 3,
|
||||
"chat +send-media": 2,
|
||||
"chat +send-message": 3,
|
||||
"chat +send-single": 1,
|
||||
"chat +send-text": 2,
|
||||
"chat +send-to": 1,
|
||||
}
|
||||
rewriteTargets := map[string]string{
|
||||
"chat +members": "chat +group-members",
|
||||
"chat +group-member-list": "chat +group-members",
|
||||
"chat +list-group-bots": "chat +chat-bots",
|
||||
"chat +list-robot": "chat +chat-bots",
|
||||
"chat +list-robots": "chat +chat-bots",
|
||||
"chat +rename-group": "chat +chat-update",
|
||||
}
|
||||
|
||||
seenIDs := make(map[string]bool, len(corpus.Badcases))
|
||||
gotOutcomeCounts := make(map[string]int)
|
||||
gotSourceCounts := make(map[string]int)
|
||||
for _, badcase := range corpus.Badcases {
|
||||
badcase := badcase
|
||||
t.Run(badcase.ID, func(t *testing.T) {
|
||||
if badcase.ID == "" || seenIDs[badcase.ID] {
|
||||
t.Fatalf("missing or duplicate historical badcase id %q", badcase.ID)
|
||||
}
|
||||
seenIDs[badcase.ID] = true
|
||||
gotOutcomeCounts[badcase.ExpectedOutcome]++
|
||||
gotSourceCounts[badcase.SourcePath]++
|
||||
|
||||
args := historicalShortcutBadcaseArgv(t, badcase.Raw)
|
||||
if len(args) < 2 || strings.Join(args[:2], " ") != badcase.SourcePath {
|
||||
t.Fatalf("raw argv source = %v, fixture source_path=%q", args, badcase.SourcePath)
|
||||
}
|
||||
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
switch badcase.ExpectedOutcome {
|
||||
case "rewrite":
|
||||
assertHistoricalShortcutRewrite(t, badcase, rewriteTargets[badcase.SourcePath], ctx, err)
|
||||
case "ambiguous":
|
||||
assertHistoricalShortcutReason(t, badcase, ctx, err, "ambiguous_command_fallback")
|
||||
entry, ok := cli.LookupCommandPathFallback(badcase.SourcePath)
|
||||
if !ok || entry.Mode != cli.CommandPathFallbackAmbiguous || len(entry.Candidates) < 2 {
|
||||
t.Fatalf("ambiguous fallback table entry = %#v, %v", entry, ok)
|
||||
}
|
||||
case "unknown_shortcut":
|
||||
assertHistoricalShortcutReason(t, badcase, ctx, err, "unknown_shortcut")
|
||||
if entry, ok := cli.LookupCommandPathFallback(badcase.SourcePath); ok {
|
||||
t.Fatalf("contract-incomplete path unexpectedly entered fallback table: %#v", entry)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unsupported expected_outcome %q", badcase.ExpectedOutcome)
|
||||
}
|
||||
})
|
||||
}
|
||||
if !reflect.DeepEqual(gotOutcomeCounts, wantOutcomeCounts) {
|
||||
t.Errorf("historical outcome counts = %v, want %v", gotOutcomeCounts, wantOutcomeCounts)
|
||||
}
|
||||
if !reflect.DeepEqual(gotSourceCounts, wantSourceCounts) {
|
||||
t.Errorf("historical source counts = %v, want %v", gotSourceCounts, wantSourceCounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageShortcutHallucinationMerged20260720Replay(t *testing.T) {
|
||||
tests := []struct {
|
||||
id string
|
||||
raw string
|
||||
occurrences int
|
||||
outcome string
|
||||
target string
|
||||
}{
|
||||
{
|
||||
id: "dws_im_v2_0013-search-group",
|
||||
raw: `dws chat +search-group --name "dws测试群02" --format json`,
|
||||
occurrences: 1,
|
||||
outcome: "official_alias",
|
||||
target: "chat +chat-search",
|
||||
},
|
||||
{
|
||||
id: "dws_oa_0017-list-processes",
|
||||
raw: `dws oa +list-processes --format json --limit 50`,
|
||||
occurrences: 2,
|
||||
outcome: "ambiguous",
|
||||
},
|
||||
}
|
||||
totalOccurrences := 0
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.id, func(t *testing.T) {
|
||||
totalOccurrences += test.occurrences
|
||||
args := historicalShortcutBadcaseArgv(t, test.raw)
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
badcase := shortcutHallucinationReplayBadcase{
|
||||
ID: test.id,
|
||||
SourcePath: strings.Join(args[:2], " "),
|
||||
ExpectedOutcome: test.outcome,
|
||||
Raw: test.raw,
|
||||
}
|
||||
switch test.outcome {
|
||||
case "rewrite":
|
||||
assertHistoricalShortcutRewrite(t, badcase, test.target, ctx, err)
|
||||
case "official_alias":
|
||||
assertHistoricalOfficialAlias(t, badcase, test.target, ctx, err)
|
||||
case "ambiguous":
|
||||
assertHistoricalShortcutReason(t, badcase, ctx, err, "ambiguous_command_fallback")
|
||||
default:
|
||||
t.Fatalf("unsupported merged expected outcome %q", test.outcome)
|
||||
}
|
||||
})
|
||||
}
|
||||
if totalOccurrences != 3 {
|
||||
t.Fatalf("merged shortcut hallucination occurrences = %d, want 3", totalOccurrences)
|
||||
}
|
||||
}
|
||||
|
||||
func assertHistoricalOfficialAlias(
|
||||
t *testing.T,
|
||||
badcase shortcutHallucinationReplayBadcase,
|
||||
wantTarget string,
|
||||
ctx *pipeline.Context,
|
||||
err error,
|
||||
) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("historical official alias %q returned error: %v", badcase.SourcePath, err)
|
||||
}
|
||||
if wantTarget == "" || ctx == nil || ctx.Command != "dws "+wantTarget {
|
||||
t.Fatalf("historical official alias context = %#v; want command dws %s", ctx, wantTarget)
|
||||
}
|
||||
for _, correction := range ctx.Corrections {
|
||||
if correction.Handler == "command-path-fallback" {
|
||||
t.Fatalf("historical official alias received fallback correction: %#v", ctx.Corrections)
|
||||
}
|
||||
}
|
||||
if entry, ok := cli.LookupCommandPathFallback(badcase.SourcePath); ok {
|
||||
t.Fatalf("official alias unexpectedly remains in fallback table: %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func historicalShortcutBadcaseArgv(t *testing.T, raw string) []string {
|
||||
t.Helper()
|
||||
command := strings.TrimSpace(raw)
|
||||
for _, suffix := range []string{" 2>&1 | head -50", " 2>&1"} {
|
||||
command = strings.TrimSuffix(command, suffix)
|
||||
}
|
||||
argv, err := cli.ParseAgentExampleArgv(command)
|
||||
if err != nil {
|
||||
t.Fatalf("parse historical raw command %q without a shell: %v", raw, err)
|
||||
}
|
||||
if len(argv) < 3 || argv[0] != "dws" {
|
||||
t.Fatalf("historical raw command did not produce dws argv: %q => %v", raw, argv)
|
||||
}
|
||||
return append([]string(nil), argv[1:]...)
|
||||
}
|
||||
|
||||
func assertHistoricalShortcutRewrite(
|
||||
t *testing.T,
|
||||
badcase shortcutHallucinationReplayBadcase,
|
||||
wantTarget string,
|
||||
ctx *pipeline.Context,
|
||||
err error,
|
||||
) {
|
||||
t.Helper()
|
||||
if wantTarget == "" {
|
||||
t.Fatalf("historical rewrite %q has no reviewed target", badcase.SourcePath)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws "+wantTarget {
|
||||
t.Fatalf("historical rewrite context = %#v, error=%v; want command dws %s", ctx, err, wantTarget)
|
||||
}
|
||||
found := false
|
||||
for _, correction := range ctx.Corrections {
|
||||
if correction.Handler == "command-path-fallback" && correction.Original == badcase.SourcePath && correction.Corrected == wantTarget {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("historical rewrite corrections = %#v; want %q -> %q", ctx.Corrections, badcase.SourcePath, wantTarget)
|
||||
}
|
||||
wantPrefix := strings.Fields(wantTarget)
|
||||
if len(ctx.Args) < len(wantPrefix) || !reflect.DeepEqual(ctx.Args[:len(wantPrefix)], wantPrefix) {
|
||||
t.Fatalf("historical rewrite argv = %v, want prefix %v", ctx.Args, wantPrefix)
|
||||
}
|
||||
if reason := structuredShortcutReplayReason(err); reason == "unknown_shortcut" || reason == "ambiguous_command_fallback" {
|
||||
t.Fatalf("historical rewrite returned command-resolution reason %q: %v", reason, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertHistoricalShortcutReason(
|
||||
t *testing.T,
|
||||
badcase shortcutHallucinationReplayBadcase,
|
||||
ctx *pipeline.Context,
|
||||
err error,
|
||||
wantReason string,
|
||||
) {
|
||||
t.Helper()
|
||||
if ctx == nil {
|
||||
t.Fatalf("historical %s badcase returned nil context: %v", wantReason, err)
|
||||
}
|
||||
if got := structuredShortcutReplayReason(err); got != wantReason {
|
||||
t.Fatalf("historical badcase %q reason = %q, error=%v; want %q (original exit=%d help=%v)", badcase.Raw, got, err, wantReason, badcase.OriginalExitCode, badcase.IsHelp)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(fmt.Sprint(err)), "unknown flag") {
|
||||
t.Fatalf("historical badcase %q regressed to unknown flag: %v", badcase.Raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
func structuredShortcutReplayReason(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
var structured *apperrors.Error
|
||||
if stderrors.As(err, &structured) {
|
||||
return structured.Reason
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
runtimeShortcutFallbackChildEnv = "DWS_RUNTIME_SHORTCUT_FALLBACK_CHILD"
|
||||
runtimeShortcutFallbackConfigEnv = "DWS_RUNTIME_SHORTCUT_FALLBACK_CONFIG_DIR"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeUserShortcutTakesPrecedenceOverReviewedFallback(t *testing.T) {
|
||||
if os.Getenv(runtimeShortcutFallbackChildEnv) == "1" {
|
||||
t.Setenv("DWS_CONFIG_DIR", os.Getenv(runtimeShortcutFallbackConfigEnv))
|
||||
engine := newPipelineEngine()
|
||||
root := NewRootCommandWithEngine(context.Background(), engine)
|
||||
runtimeCommand := exactAppCommand(root, "chat +members")
|
||||
if runtimeCommand == nil || !runtimeCommand.Runnable() || cmdutil.IsHintOnlyCommand(runtimeCommand) {
|
||||
var userDefined []string
|
||||
for _, candidate := range shortcut.All() {
|
||||
if candidate.UserDefined {
|
||||
userDefined = append(userDefined, candidate.Service+" "+candidate.Command)
|
||||
}
|
||||
}
|
||||
t.Fatalf("runtime shortcut was not mounted as an executable command: command=%#v user_defined=%v config=%q", runtimeCommand, userDefined, os.Getenv("DWS_CONFIG_DIR"))
|
||||
}
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
args := []string{"chat", "+members", "--sentinel", "fixture", "--mock", "--format", "json"}
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, engine, args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws chat +members" {
|
||||
t.Fatalf("runtime shortcut context = %#v", ctx)
|
||||
}
|
||||
assertNoCommandPathFallbackCorrection(t, ctx)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("runtime shortcut execute: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
configDir := t.TempDir()
|
||||
shortcutDir := filepath.Join(configDir, "shortcuts")
|
||||
if err := os.MkdirAll(shortcutDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
definition := `version: 1
|
||||
service: chat
|
||||
command: "+members"
|
||||
product: chat
|
||||
description: runtime members fixture
|
||||
execute:
|
||||
tool: fixture_user_members
|
||||
bind:
|
||||
sentinel: "${sentinel}"
|
||||
flags:
|
||||
- name: sentinel
|
||||
type: string
|
||||
required: true
|
||||
desc: fixture sentinel
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(shortcutDir, "chat.members.yaml"), []byte(definition), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command := exec.Command(os.Args[0], "-test.run=^TestCrossPlatformCoverageRuntimeUserShortcutTakesPrecedenceOverReviewedFallback$", "-test.count=1")
|
||||
command.Env = append(os.Environ(), runtimeShortcutFallbackConfigEnv+"="+configDir, runtimeShortcutFallbackChildEnv+"=1")
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("runtime shortcut child failed: %v\n%s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
if !strings.Contains(string(output), `"_tool": "fixture_user_members"`) {
|
||||
t.Fatalf("runtime shortcut child used the wrong command:\n%s", strings.TrimSpace(string(output)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedCommandFallbacksReachCanonicalDryRunPayload(t *testing.T) {
|
||||
_, canonicalQuery, canonicalQueryAttempts, err := executeParamAliasDryRunE2E(t,
|
||||
"chat", "+chat-search", "--query", "project", "--dry-run",
|
||||
)
|
||||
if err != nil || len(canonicalQueryAttempts) != 0 {
|
||||
t.Fatalf("canonical query preview = %#v, attempts=%v, error=%v", canonicalQuery, canonicalQueryAttempts, err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantCommand string
|
||||
wantPreview paramAliasDryRunPreview
|
||||
}{
|
||||
{
|
||||
name: "group search query",
|
||||
args: []string{"chat", "+group-search", "--query", "project", "--dry-run"},
|
||||
wantCommand: "dws chat +chat-search",
|
||||
wantPreview: canonicalQuery,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctx, preview, attempts, executeErr := executeParamAliasDryRunE2E(t, test.args...)
|
||||
if executeErr != nil {
|
||||
t.Fatalf("fallback execute error = %v", executeErr)
|
||||
}
|
||||
if len(attempts) != 0 {
|
||||
t.Fatalf("fallback crossed dry-run dispatch boundary: %#v", attempts)
|
||||
}
|
||||
if !reflect.DeepEqual(preview, test.wantPreview) {
|
||||
t.Fatalf("fallback preview = %#v, want canonical %#v", preview, test.wantPreview)
|
||||
}
|
||||
if ctx == nil || ctx.Command != test.wantCommand || len(ctx.Corrections) == 0 {
|
||||
t.Fatalf("fallback context = %#v, want command %q", ctx, test.wantCommand)
|
||||
}
|
||||
correction := ctx.Corrections[0]
|
||||
if correction.Handler != "command-path-fallback" || correction.Kind != "reviewed-fallback" {
|
||||
t.Fatalf("fallback correction = %#v", correction)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOfficialCommandAliasesBypassFallbackAndReachEquivalentPayload(t *testing.T) {
|
||||
_, canonicalQuery, canonicalQueryAttempts, err := executeParamAliasDryRunE2E(t,
|
||||
"chat", "+chat-search", "--query", "project", "--dry-run",
|
||||
)
|
||||
if err != nil || len(canonicalQueryAttempts) != 0 {
|
||||
t.Fatalf("canonical query preview = %#v, attempts=%v, error=%v", canonicalQuery, canonicalQueryAttempts, err)
|
||||
}
|
||||
_, canonicalKeyword, canonicalKeywordAttempts, err := executeParamAliasDryRunE2E(t,
|
||||
"chat", "+chat-search", "--keyword", "project", "--dry-run",
|
||||
)
|
||||
if err != nil || len(canonicalKeywordAttempts) != 0 {
|
||||
t.Fatalf("canonical keyword preview = %#v, attempts=%v, error=%v", canonicalKeyword, canonicalKeywordAttempts, err)
|
||||
}
|
||||
_, nativeCanonical, nativeCanonicalAttempts, err := executeParamAliasDryRunE2E(t,
|
||||
"chat", "search", "--query", "project", "--dry-run",
|
||||
)
|
||||
if err != nil || len(nativeCanonicalAttempts) != 0 {
|
||||
t.Fatalf("native canonical preview = %#v, attempts=%v, error=%v", nativeCanonical, nativeCanonicalAttempts, err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantCommand string
|
||||
wantPreview paramAliasDryRunPreview
|
||||
}{
|
||||
{
|
||||
name: "search group shortcut alias",
|
||||
args: []string{"chat", "+search-group", "--keyword", "project", "--dry-run"},
|
||||
wantCommand: "dws chat +chat-search",
|
||||
wantPreview: canonicalKeyword,
|
||||
},
|
||||
{
|
||||
name: "chat group search shortcut alias",
|
||||
args: []string{"chat", "+chat-group-search", "--query", "project", "--dry-run"},
|
||||
wantCommand: "dws chat +chat-search",
|
||||
wantPreview: canonicalQuery,
|
||||
},
|
||||
{
|
||||
name: "hidden native compatibility leaf",
|
||||
args: []string{"chat", "group", "search", "--query", "project", "--dry-run"},
|
||||
wantCommand: "dws chat group search",
|
||||
wantPreview: nativeCanonical,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctx, preview, attempts, executeErr := executeParamAliasDryRunE2E(t, test.args...)
|
||||
if executeErr != nil {
|
||||
t.Fatalf("official alias execute error = %v", executeErr)
|
||||
}
|
||||
if len(attempts) != 0 {
|
||||
t.Fatalf("official alias crossed dry-run dispatch boundary: %#v", attempts)
|
||||
}
|
||||
if !reflect.DeepEqual(preview, test.wantPreview) {
|
||||
t.Fatalf("official alias preview = %#v, want canonical %#v", preview, test.wantPreview)
|
||||
}
|
||||
if ctx == nil || ctx.Command != test.wantCommand {
|
||||
t.Fatalf("official alias context = %#v, want command %q", ctx, test.wantCommand)
|
||||
}
|
||||
assertNoCommandPathFallbackCorrection(t, ctx)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedReadFallbacksResolveCanonicalLeafBeforeParameterValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
target string
|
||||
}{
|
||||
{
|
||||
name: "members",
|
||||
args: []string{"chat", "+members", "--group", "Fixture Group"},
|
||||
target: "chat +group-members",
|
||||
},
|
||||
{
|
||||
name: "group member list",
|
||||
args: []string{"chat", "+group-member-list", "--group-name", "Fixture Group"},
|
||||
target: "chat +group-members",
|
||||
},
|
||||
{
|
||||
name: "list group bots",
|
||||
args: []string{"chat", "+list-group-bots", "--group", "cid-fixture"},
|
||||
target: "chat +chat-bots",
|
||||
},
|
||||
{
|
||||
name: "list robot",
|
||||
args: []string{"chat", "+list-robot", "--group", "cid-fixture"},
|
||||
target: "chat +chat-bots",
|
||||
},
|
||||
{
|
||||
name: "list robots",
|
||||
args: []string{"chat", "+list-robots", "--group", "cid-fixture"},
|
||||
target: "chat +chat-bots",
|
||||
},
|
||||
{
|
||||
name: "bot list",
|
||||
args: []string{"chat", "+bot-list", "--group", "cid-fixture"},
|
||||
target: "chat +chat-bots",
|
||||
},
|
||||
{
|
||||
name: "conversation detail",
|
||||
args: []string{"chat", "+conversation-detail", "--group", "cid-fixture"},
|
||||
target: "chat +conversation-info",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(test.args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws "+test.target || len(ctx.Corrections) == 0 {
|
||||
t.Fatalf("read fallback context = %#v", ctx)
|
||||
}
|
||||
wantPrefix := strings.Fields(test.target)
|
||||
if len(ctx.Args) < len(wantPrefix) || !reflect.DeepEqual(ctx.Args[:len(wantPrefix)], wantPrefix) {
|
||||
t.Fatalf("read fallback args = %v, want prefix %v", ctx.Args, wantPrefix)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOfficialGroupMembersAliasBypassesCommandFallback(t *testing.T) {
|
||||
args := []string{"chat", "+chat-group-members", "--conversation-id", "cid-fixture", "--member-types", "user"}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws chat +chat-members-list" {
|
||||
t.Fatalf("official group-members alias context = %#v", ctx)
|
||||
}
|
||||
assertNoCommandPathFallbackCorrection(t, ctx)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedRenameFallbackResolvesCanonicalLeafBeforeParameterValidation(t *testing.T) {
|
||||
args := []string{"chat", "+rename-group", "--id", "cid-fixture", "--name", "Fixture Group"}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws chat +chat-update" || len(ctx.Corrections) == 0 {
|
||||
t.Fatalf("rename fallback context = %#v", ctx)
|
||||
}
|
||||
wantPrefix := []string{"chat", "+chat-update"}
|
||||
if len(ctx.Args) < len(wantPrefix) || !reflect.DeepEqual(ctx.Args[:len(wantPrefix)], wantPrefix) {
|
||||
t.Fatalf("rename fallback args = %v, want prefix %v", ctx.Args, wantPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedDocHistorySaveFallbacksPreserveArguments(t *testing.T) {
|
||||
sources := []string{"+create-version", "+save-version", "+snapshot", "+version-create"}
|
||||
for _, source := range sources {
|
||||
t.Run(source, func(t *testing.T) {
|
||||
args := []string{"doc", source, "--node", "node-fixture", "--mock", "--format", "json"}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws doc +history-save" || len(ctx.Corrections) == 0 {
|
||||
t.Fatalf("history-save fallback context = %#v", ctx)
|
||||
}
|
||||
wantArgs := []string{"doc", "+history-save", "--node", "node-fixture", "--mock", "--format", "json"}
|
||||
if !reflect.DeepEqual(ctx.Args, wantArgs) {
|
||||
t.Fatalf("history-save fallback args = %v, want %v", ctx.Args, wantArgs)
|
||||
}
|
||||
correction := ctx.Corrections[0]
|
||||
if correction.Handler != "command-path-fallback" || correction.Kind != "reviewed-fallback" {
|
||||
t.Fatalf("history-save correction = %#v", correction)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocExportPDFRemainsUnknownShortcut(t *testing.T) {
|
||||
if entry, ok := cli.LookupCommandPathFallback("doc +export-pdf"); ok {
|
||||
t.Fatalf("+export-pdf must not receive a path-only fallback: %#v", entry)
|
||||
}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
args := []string{"doc", "+export-pdf", "--node", "node-fixture", "--mock", "--format", "json"}
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if ctx == nil {
|
||||
t.Fatal("+export-pdf returned nil context")
|
||||
}
|
||||
var structured *apperrors.Error
|
||||
if !stderrors.As(err, &structured) || structured.Reason != "unknown_shortcut" {
|
||||
t.Fatalf("+export-pdf preparse error = %T %#v", err, err)
|
||||
}
|
||||
if len(ctx.Corrections) != 0 {
|
||||
t.Fatalf("+export-pdf unexpectedly received corrections: %#v", ctx.Corrections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedAmbiguousCommandFallbackNeverDispatches(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
candidates []string
|
||||
}{
|
||||
{path: "chat +group-send-text", candidates: []string{"chat +send-to-group", "chat +messages-send"}},
|
||||
{path: "chat +message-list", candidates: []string{"chat +chat-messages", "chat +messages-list-direct", "chat +search-msg", "chat +unread-chats"}},
|
||||
{path: "chat +read-single", candidates: []string{"chat +messages-list-direct", "chat +chat-messages"}},
|
||||
{path: "chat +send", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
|
||||
{path: "chat +send-by-bot", candidates: []string{"chat +messages-send", "chat message send-by-bot"}},
|
||||
{path: "chat +send-dm", candidates: []string{"chat +dm", "chat +messages-send"}},
|
||||
{path: "chat +send-message", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
|
||||
{path: "chat +send-single", candidates: []string{"chat +dm", "chat +messages-send"}},
|
||||
{path: "chat +send-text", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
|
||||
{path: "chat +send-to", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
|
||||
{path: "chat +send-file", candidates: []string{"chat +messages-send", "chat message send"}},
|
||||
{path: "chat +send-image", candidates: []string{"chat +messages-send", "chat message send"}},
|
||||
{path: "chat +send-media", candidates: []string{"chat +messages-send", "chat message send"}},
|
||||
{path: "chat +conversation-category-list", candidates: []string{"chat +category-list", "chat +category-list-conversations"}},
|
||||
{path: "chat +conversation-group-list", candidates: []string{"chat +category-list-conversations", "chat +conversation-list"}},
|
||||
{path: "chat +list-my-groups", candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"}},
|
||||
{path: "oa +list-processes", candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.path, func(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
args := append(strings.Fields(test.path), "--format", "json")
|
||||
ctx, err := executeParamAliasE2E(t, caller, args...)
|
||||
if ctx == nil {
|
||||
t.Fatal("ambiguous command fallback returned nil context")
|
||||
}
|
||||
var structured *apperrors.Error
|
||||
if !stderrors.As(err, &structured) || structured.Reason != "ambiguous_command_fallback" || structured.ExitCode() != 3 {
|
||||
t.Fatalf("ambiguous error = %T %#v", err, err)
|
||||
}
|
||||
if len(structured.Actions) != len(test.candidates) || len(caller.calls) != 0 {
|
||||
t.Fatalf("ambiguous actions=%v calls=%#v", structured.Actions, caller.calls)
|
||||
}
|
||||
for _, candidate := range test.candidates {
|
||||
if !strings.Contains(structured.Hint, "dws "+candidate) {
|
||||
t.Errorf("ambiguous hint %q missing candidate %q", structured.Hint, candidate)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCanonicalShortcutBadFlagDoesNotEnterCommandFallback(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
args := []string{"chat", "+chat-search", "--definitely-not-a-real-flag", "project"}
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if err != nil {
|
||||
t.Fatalf("preparse error = %v", err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws chat +chat-search" {
|
||||
t.Fatalf("canonical context = %#v", ctx)
|
||||
}
|
||||
for _, correction := range ctx.Corrections {
|
||||
if correction.Handler == "command-path-fallback" {
|
||||
t.Fatalf("canonical command received fallback correction: %#v", ctx.Corrections)
|
||||
}
|
||||
}
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "unknown flag") {
|
||||
t.Fatalf("canonical bad flag error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRewrittenShortcutStillUsesCanonicalParameterErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing required query",
|
||||
args: []string{"chat", "+group-search", "--dry-run"},
|
||||
want: "请至少指定 --query、--keyword 之一",
|
||||
},
|
||||
{
|
||||
name: "unknown canonical flag",
|
||||
args: []string{"chat", "+group-search", "--definitely-not-a-real-flag", "project"},
|
||||
want: "unknown flag",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(test.args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
|
||||
if err != nil {
|
||||
t.Fatalf("preparse error = %v", err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws chat +chat-search" || len(ctx.Corrections) == 0 ||
|
||||
ctx.Corrections[0].Handler != "command-path-fallback" {
|
||||
t.Fatalf("rewritten context = %#v", ctx)
|
||||
}
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("canonical parameter error = %v, want containing %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandFallbackNamesStayOutOfHelpSchemaAndShortcutCatalog(t *testing.T) {
|
||||
invalidShortcuts := map[string]bool{
|
||||
"+bot-list": true,
|
||||
"+conversation-detail": true,
|
||||
"+conversation-category-list": true,
|
||||
"+conversation-group-list": true,
|
||||
"+list-my-groups": true,
|
||||
"+group-search": true,
|
||||
"+members": true,
|
||||
"+group-member-list": true,
|
||||
"+list-group-bots": true,
|
||||
"+list-robot": true,
|
||||
"+list-robots": true,
|
||||
"+message-list": true,
|
||||
"+read-single": true,
|
||||
"+rename-group": true,
|
||||
"+send": true,
|
||||
"+send-by-bot": true,
|
||||
"+send-dm": true,
|
||||
"+send-message": true,
|
||||
"+send-single": true,
|
||||
"+send-text": true,
|
||||
"+send-to": true,
|
||||
"+send-file": true,
|
||||
"+send-image": true,
|
||||
"+send-media": true,
|
||||
"+group-send-text": true,
|
||||
}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
chat := exactAppCommand(root, "chat")
|
||||
if chat == nil {
|
||||
t.Fatal("chat command missing")
|
||||
}
|
||||
for _, child := range chat.Commands() {
|
||||
if invalidShortcuts[child.Name()] {
|
||||
t.Fatalf("fallback name %q became a real command", child.Name())
|
||||
}
|
||||
for _, alias := range child.Aliases {
|
||||
if invalidShortcuts[alias] {
|
||||
t.Fatalf("fallback name %q became a Cobra alias", alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
for path := range invalidShortcuts {
|
||||
if _, ok := cli.ResolveMeta("chat " + path); ok {
|
||||
t.Fatalf("fallback path %q leaked into embedded Schema", path)
|
||||
}
|
||||
}
|
||||
for _, declared := range shortcut.All() {
|
||||
if declared.Service == "chat" && invalidShortcuts[declared.Command] {
|
||||
t.Fatalf("fallback name %q leaked into shortcut catalog", declared.Command)
|
||||
}
|
||||
}
|
||||
if _, ok := cli.ResolveMeta("oa +list-processes"); ok {
|
||||
t.Fatal("fallback path oa +list-processes leaked into embedded Schema")
|
||||
}
|
||||
for _, declared := range shortcut.All() {
|
||||
if declared.Service == "oa" && declared.Command == "+list-processes" {
|
||||
t.Fatal("fallback name +list-processes leaked into shortcut catalog")
|
||||
}
|
||||
}
|
||||
|
||||
group := exactAppCommand(root, "chat group")
|
||||
searchCompatibility := exactAppCommand(root, "chat group search")
|
||||
if group == nil || searchCompatibility == nil || !searchCompatibility.Hidden || cmdutil.IsHintOnlyCommand(searchCompatibility) {
|
||||
t.Fatalf("chat group search must be a hidden executable compatibility leaf: group=%v search=%v", group, searchCompatibility)
|
||||
}
|
||||
var help bytes.Buffer
|
||||
group.SetOut(&help)
|
||||
if err := group.Help(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(help.String(), "\n search ") {
|
||||
t.Fatalf("hidden fallback source leaked into group help:\n%s", help.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocCommandFallbackNamesStayOutOfHelpSchemaAndShortcutCatalog(t *testing.T) {
|
||||
invalidShortcuts := map[string]bool{
|
||||
"+create-version": true,
|
||||
"+save-version": true,
|
||||
"+snapshot": true,
|
||||
"+version-create": true,
|
||||
"+export-pdf": true,
|
||||
}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
doc := exactAppCommand(root, "doc")
|
||||
if doc == nil {
|
||||
t.Fatal("doc command missing")
|
||||
}
|
||||
for _, child := range doc.Commands() {
|
||||
if invalidShortcuts[child.Name()] {
|
||||
t.Fatalf("invalid shortcut %q became a real command", child.Name())
|
||||
}
|
||||
for _, alias := range child.Aliases {
|
||||
if invalidShortcuts[alias] {
|
||||
t.Fatalf("invalid shortcut %q became a Cobra alias", alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
for path := range invalidShortcuts {
|
||||
if _, ok := cli.ResolveMeta("doc " + path); ok {
|
||||
t.Fatalf("invalid path %q leaked into embedded Schema", path)
|
||||
}
|
||||
}
|
||||
for _, declared := range shortcut.All() {
|
||||
if declared.Service == "doc" && invalidShortcuts[declared.Command] {
|
||||
t.Fatalf("invalid shortcut %q leaked into shortcut catalog", declared.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoCommandPathFallbackCorrection(t *testing.T, ctx *pipeline.Context) {
|
||||
t.Helper()
|
||||
for _, correction := range ctx.Corrections {
|
||||
if correction.Handler == "command-path-fallback" {
|
||||
t.Fatalf("official command unexpectedly received fallback correction: %#v", ctx.Corrections)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func exactAppCommand(root *cobra.Command, path string) *cobra.Command {
|
||||
current := root
|
||||
parts := strings.Fields(path)
|
||||
if len(parts) > 0 && parts[0] == root.Name() {
|
||||
parts = parts[1:]
|
||||
}
|
||||
for _, part := range parts {
|
||||
var next *cobra.Command
|
||||
for _, child := range current.Commands() {
|
||||
if child.Name() == part {
|
||||
next = child
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
return nil
|
||||
}
|
||||
current = next
|
||||
}
|
||||
return current
|
||||
}
|
||||
@@ -58,6 +58,14 @@ func paramAliasResponseForTool(tool string) string {
|
||||
return `{"deptList":[{"deptId":1,"name":"Fixture Dept"}]}`
|
||||
case "search_groups":
|
||||
return `{"result":{"items":[{"openConversationId":"fixture-conversation","title":"Fixture Group"}]}}`
|
||||
case "search_contact_by_key_word":
|
||||
return `{"result":[{"name":"Fixture User","userId":"fixture-user","openDingTalkId":"D-fixture-user"}]}`
|
||||
case "list_doc_versions":
|
||||
return `{"result":{"items":[{"version":3}]}}`
|
||||
case "search_doc_templates":
|
||||
return `{"result":[{"templateId":"fixture-template-id"}]}`
|
||||
case "get_document_content":
|
||||
return `{"revision":1}`
|
||||
default:
|
||||
return `{}`
|
||||
}
|
||||
@@ -191,7 +199,7 @@ func executeParamAliasE2E(t *testing.T, caller *paramAliasCaptureCaller, args ..
|
||||
return ctx, root.Execute()
|
||||
}
|
||||
|
||||
func TestBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
|
||||
func TestCrossPlatformCoverageBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
confirmation []string
|
||||
@@ -244,7 +252,7 @@ func TestBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamAliasReadCommandFinalPayload(t *testing.T) {
|
||||
func TestCrossPlatformCoverageParamAliasReadCommandFinalPayload(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
start := "2026-03-10T14:00:00+08:00"
|
||||
end := "2026-03-10T18:00:00+08:00"
|
||||
@@ -279,7 +287,7 @@ func TestParamAliasReadCommandFinalPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamAliasWriteCommandFinalPayload(t *testing.T) {
|
||||
func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasE2E(t, caller,
|
||||
"chat", "message", "send",
|
||||
@@ -311,7 +319,7 @@ func TestParamAliasWriteCommandFinalPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
|
||||
func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
command []string
|
||||
@@ -390,7 +398,7 @@ func TestChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
|
||||
func TestCrossPlatformCoverageAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
engine := newPipelineEngine()
|
||||
entries, err := cli.ReduceParamAliases(root)
|
||||
@@ -478,7 +486,7 @@ func TestAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
|
||||
t.Logf("verified generated chat parameter routes: entries=%d aliases=%d blocked=%d ambiguous=%d", chatEntries, aliasCases, guardCases[pipeline.FlagProtectionBlocked], guardCases[pipeline.FlagProtectionAmbiguous])
|
||||
}
|
||||
|
||||
func TestIMUserIDHallucinationRoutes(t *testing.T) {
|
||||
func TestCrossPlatformCoverageIMUserIDHallucinationRoutes(t *testing.T) {
|
||||
tests := []struct {
|
||||
command string
|
||||
want string
|
||||
@@ -531,7 +539,7 @@ func TestIMUserIDHallucinationRoutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
|
||||
func TestCrossPlatformCoverageHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
|
||||
const command = "chat message list-direct"
|
||||
if _, ok := cli.LookupParamAlias(command); ok {
|
||||
t.Fatalf("hidden command %q unexpectedly entered the public generated alias table", command)
|
||||
@@ -556,7 +564,7 @@ func TestHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tool string
|
||||
@@ -653,7 +661,7 @@ func TestSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
|
||||
func TestCrossPlatformCoverageParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
for _, args := range [][]string{
|
||||
{"calendar", "event", "list", "--date", "2026-03-10", "--start", "2026-03-11"},
|
||||
@@ -678,7 +686,7 @@ func TestParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
|
||||
func TestCrossPlatformCoverageAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
@@ -770,7 +778,7 @@ func TestAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testing.T) {
|
||||
func TestCrossPlatformCoverageRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
path string
|
||||
emitted string
|
||||
@@ -817,7 +825,7 @@ func TestRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagConflictErrorFormattingIsDeterministic(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFlagConflictErrorFormattingIsDeterministic(t *testing.T) {
|
||||
err := (&pipeline.FlagConflictError{Command: "dws demo", Canonical: "start", Spellings: []string{"start", "date"}}).Error()
|
||||
want := `conflicting parameter spellings for --start on "dws demo": --date, --start; pass exactly one spelling`
|
||||
if err != want {
|
||||
|
||||
@@ -44,7 +44,7 @@ import (
|
||||
// - expect=<realFlag> : emitted must reduce to that canonical flag.
|
||||
// - expect=did-you-mean:blocked : block guard hit; never auto-rewritten.
|
||||
// - expect=did-you-mean:ambiguous: co-occurrence guard hit; never rewritten.
|
||||
func TestParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
|
||||
func TestCrossPlatformCoverageParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
@@ -113,13 +113,19 @@ func TestParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
|
||||
t.Fatalf("reviewed canonical --%s on %q is not a real Cobra flag", c.Expect, c.Command)
|
||||
}
|
||||
flagArgs := ctx.Args[len(strings.Fields(c.Command)):]
|
||||
if len(flagArgs) < 2 || flagArgs[1] != fixtureValue {
|
||||
t.Fatalf("%q on %q lost its value: args=%v", c.Emitted, c.Command, ctx.Args)
|
||||
if len(flagArgs) == 0 {
|
||||
t.Fatalf("%q on %q lost its flag and value: args=%v", c.Emitted, c.Command, ctx.Args)
|
||||
}
|
||||
got := flagArgs[0]
|
||||
gotBare := strings.SplitN(strings.TrimPrefix(got, "--"), "=", 2)[0]
|
||||
gotBare, inlineValue, hasInlineValue := strings.Cut(strings.TrimPrefix(got, "--"), "=")
|
||||
switch {
|
||||
case got == "--"+c.Expect:
|
||||
case hasInlineValue && inlineValue != fixtureValue:
|
||||
t.Fatalf("%q on %q changed its inline value: got %q, want %q (args=%v)", c.Emitted, c.Command, inlineValue, fixtureValue, ctx.Args)
|
||||
case !hasInlineValue && (len(flagArgs) < 2 || flagArgs[1] != fixtureValue):
|
||||
t.Fatalf("%q on %q lost its value: args=%v", c.Emitted, c.Command, ctx.Args)
|
||||
}
|
||||
switch {
|
||||
case gotBare == c.Expect:
|
||||
// (1) rewritten; the embedded table must agree.
|
||||
if !hasEntry {
|
||||
t.Fatalf("%q on %q was rewritten without an embedded alias entry", c.Emitted, c.Command)
|
||||
|
||||
@@ -30,13 +30,34 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
|
||||
"calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
|
||||
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
|
||||
"chat +chat-add-bot": {"chat", "+chat-add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
|
||||
"chat +chat-audit-join": {"chat", "+chat-audit-join", "--group", "fixture-conversation", "--record-id", "7", "--applicant", "user-1", "--inviter", "user-2", "--status", "AuditApprove", "--yes"},
|
||||
"chat +chat-members-get": {"chat", "+chat-members-get", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
|
||||
"chat +chat-members-list": {"chat", "+chat-members-list", "--conversation-id", "fixture-conversation", "--member-types", "user,bot"},
|
||||
"chat +chat-mute-member": {"chat", "+chat-mute-member", "--group", "fixture-conversation", "--users", "D-user-1,D-user-2", "--mute-time", "3600000", "--yes"},
|
||||
"chat +chat-remove-bot": {"chat", "+chat-remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
|
||||
"chat +chat-role-remove-user": {"chat", "+chat-role-remove-user", "--group", "fixture-conversation", "--user", "D-user-1", "--role-ids", "role-1", "--yes"},
|
||||
"chat +chat-transfer-owner": {"chat", "+chat-transfer-owner", "--group", "fixture-conversation", "--new-owner", "D-user-1", "--yes"},
|
||||
"chat +chat-update": {"chat", "+chat-update", "--group", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
|
||||
"chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"},
|
||||
"chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
|
||||
"chat +category-create": {"chat", "+category-create", "--title", "Fixture Cat", "--yes"},
|
||||
"chat +category-rename": {"chat", "+category-rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"},
|
||||
"chat +group-members": {"chat", "+group-members", "--group", "Fixture Group"},
|
||||
"chat +conversation-set-top": {"chat", "+conversation-set-top", "--conversation-id", "fixture-conversation", "--yes"},
|
||||
"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 +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"},
|
||||
"chat +messages-list": {"chat", "+messages-list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
|
||||
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
|
||||
"chat +messages-resource-download": {"chat", "+messages-resource-download", "--resource-id", "resource-1", "--message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--output", "downloads/fixture.bin"},
|
||||
"chat +messages-set-pin": {"chat", "+messages-set-pin", "--open-conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
|
||||
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
|
||||
"chat +search-msg": {"chat", "+search-msg", "--group", "fixture-conversation", "--query", "fixture", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--no-enrich"},
|
||||
"chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--text", "hello fixture", "--yes"},
|
||||
@@ -81,9 +102,36 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"ding +receiver-status": {"ding", "+receiver-status", "--ding-id", "ding-1"},
|
||||
"ding message receiver-status": {"ding", "message", "receiver-status", "--ding-id", "ding-1"},
|
||||
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1", "--yes"},
|
||||
"doc +comment-create": {"doc", "+comment-create", "--node", "node-1", "--content", "fixture comment", "--yes"},
|
||||
"doc +comment-list": {"doc", "+comment-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"doc +comment-reply": {"doc", "+comment-reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--yes"},
|
||||
"doc +access-grant": {"doc", "+access-grant", "--node", "node-1", "--to", "Fixture User", "--role", "READER", "--workspace", "workspace-1", "--yes"},
|
||||
"doc +copy": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
|
||||
"doc +create": {"doc", "+create", "--name", "Fixture Document", "--content", "fixture body", "--doc-format", "markdown"},
|
||||
"doc +create-from-template": {"doc", "+create-from-template", "--query", "fixture template", "--name", "Fixture From Template", "--folder", "folder-1", "--workspace", "workspace-1"},
|
||||
"doc +doc-append": {"doc", "+doc-append", "--doc", "node-1", "--text", "fixture appendix", "--yes"},
|
||||
"doc +export-submit": {"doc", "+export-submit", "--node", "node-1", "--export-format", "docx"},
|
||||
"doc +fetch": {"doc", "+fetch", "--node", "node-1", "--scope", "section", "--start-block-id", "block-1"},
|
||||
"doc +find-doc": {"doc", "+find-doc", "--query", "fixture", "--limit", "7"},
|
||||
"doc +history-revert": {"doc", "+history-revert", "--node", "node-1", "--version", "3", "--yes"},
|
||||
"doc +inspect": {"doc", "+inspect", "--node", "node-1", "--include-history"},
|
||||
"doc +list": {"doc", "+list", "--folder", "folder-1", "--cursor", "cursor-1"},
|
||||
"doc +move": {"doc", "+move", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"doc +search": {"doc", "+search", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"doc +template-list": {"doc", "+template-list", "--source", "MY", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"doc +template-search": {"doc", "+template-search", "--query", "fixture", "--source", "MY", "--limit", "7"},
|
||||
"doc +version-list": {"doc", "+version-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"doc +version-revert": {"doc", "+version-revert", "--node", "node-1", "--version", "3", "--yes"},
|
||||
"doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"},
|
||||
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "append", "--content", "fixture body", "--expected-revision", "1", "--yes"},
|
||||
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"},
|
||||
"doc comment create-inline": {"doc", "comment", "create-inline", "--node", "node-1", "--block-id", "block-1", "--start", "0", "--end", "7", "--content", "fixture comment", "--yes"},
|
||||
"doc comment delete": {"doc", "comment", "delete", "--node", "node-1", "--comment-key", "comment-1", "--yes"},
|
||||
"doc comment reply": {"doc", "comment", "reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--mentioned-open-conversation-id", "cid-1,cid-2", "--yes"},
|
||||
"doc comment update": {"doc", "comment", "update", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture update", "--yes"},
|
||||
"doc version revert": {"doc", "version", "revert", "--node", "node-1", "--version", "3", "--yes"},
|
||||
"drive info": {"drive", "info", "--node", "node-1", "--space-id", "space-1"},
|
||||
"drive list": {"drive", "list", "--folder", "folder-1", "--limit", "7"},
|
||||
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"},
|
||||
@@ -101,6 +149,13 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
// that case the shared command template above cannot contain every canonical
|
||||
// flag at once, so select a fixture-specific complete invocation here.
|
||||
var paramAliasCompleteCommandVariants = map[string]map[string][]string{
|
||||
"doc +copy": {
|
||||
"folder": {"doc", "+copy", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"workspace": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
|
||||
},
|
||||
"doc block insert": {
|
||||
"parent-block": {"doc", "block", "insert", "--node", "node-1", "--parent-block", "parent-block-1", "--index", "0", "--text", "fixture paragraph", "--yes"},
|
||||
},
|
||||
"chat message list": {
|
||||
"user": {"chat", "message", "list", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
},
|
||||
@@ -111,6 +166,9 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
|
||||
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--uuid", "param-alias-equivalence-group", "--yes"},
|
||||
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--uuid", "param-alias-equivalence-file", "--yes"},
|
||||
},
|
||||
"chat +conversation-set-top": {
|
||||
"conversation-ids": {"chat", "+conversation-set-top", "--conversation-ids", "fixture-conversation-1,fixture-conversation-2", "--yes"},
|
||||
},
|
||||
}
|
||||
|
||||
// paramAliasNewIMCases is the exact set of aliases added by the reviewed IM
|
||||
@@ -149,6 +207,38 @@ var paramAliasNewIMCases = []struct {
|
||||
{command: "chat message send", emitted: "file", canonical: "file-path"},
|
||||
{command: "chat message send-by-bot", emitted: "at-users", canonical: "at-user-ids"},
|
||||
{command: "chat message send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
|
||||
{command: "chat +chat-update", emitted: "chat-id", canonical: "group"},
|
||||
{command: "chat +chat-update", emitted: "conversation-id", canonical: "group"},
|
||||
{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 +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"},
|
||||
{command: "chat +conversation-set-top", emitted: "chat-ids", canonical: "conversation-ids"},
|
||||
{command: "chat +chat-members-get", emitted: "conversation-id", canonical: "id"},
|
||||
{command: "chat +chat-members-get", emitted: "open-dingtalk-ids", canonical: "users"},
|
||||
{command: "chat +chat-members-get", emitted: "chat", canonical: "id"},
|
||||
{command: "chat +messages-list", emitted: "start", canonical: "time"},
|
||||
{command: "chat +messages-reply", emitted: "msg-id", canonical: "ref-msg-id"},
|
||||
{command: "chat +messages-reply", emitted: "chat", canonical: "conversation-id"},
|
||||
{command: "chat +flag-cancel", emitted: "group", canonical: "conversation-id"},
|
||||
{command: "chat +flag-cancel", emitted: "chat", canonical: "conversation-id"},
|
||||
{command: "chat +flag-create", emitted: "group", canonical: "conversation-id"},
|
||||
{command: "chat +chat-add-bot", emitted: "conversation-id", canonical: "id"},
|
||||
{command: "chat +chat-add-bot", emitted: "robot", canonical: "robot-code"},
|
||||
{command: "chat +chat-audit-join", emitted: "applicant-user-id", canonical: "applicant"},
|
||||
{command: "chat +chat-mute-member", emitted: "user-ids", canonical: "users"},
|
||||
{command: "chat +chat-remove-bot", emitted: "open-bot-id", canonical: "bot-id"},
|
||||
{command: "chat +chat-role-remove-user", emitted: "open-dingtalk-id", canonical: "user"},
|
||||
{command: "chat +chat-transfer-owner", emitted: "user-id", canonical: "new-owner"},
|
||||
{command: "chat +feed-group-query-item", emitted: "chat-ids", canonical: "conversation-ids"},
|
||||
{command: "chat +messages-combine-forward", emitted: "src-open-cid", canonical: "src-conversation-id"},
|
||||
{command: "chat +messages-forward", emitted: "source-message-id", canonical: "msg-id"},
|
||||
{command: "chat +messages-forward-topic", emitted: "src-open-message-id", canonical: "src-msg-id"},
|
||||
{command: "chat +messages-resource-download", emitted: "conversation-id", canonical: "open-conversation-id"},
|
||||
{command: "chat +messages-set-pin", emitted: "conversation-id", canonical: "open-conversation-id"},
|
||||
}
|
||||
|
||||
// paramAliasRepresentativePayloadCases keeps final transport coverage across
|
||||
@@ -163,14 +253,33 @@ var paramAliasNewIMCases = []struct {
|
||||
// That duplicated command construction was enough to push the pre-existing
|
||||
// macOS app suite beyond its package-level 10-minute timeout.
|
||||
var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("aitable +record-query", "base"): true, // concept alias on a shortcut read
|
||||
paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias
|
||||
paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias
|
||||
paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias
|
||||
paramAliasPayloadCaseKey("contact user profile get", "user-id"): true, // native compatibility flag
|
||||
paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override
|
||||
paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias
|
||||
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
|
||||
paramAliasPayloadCaseKey("aitable +record-query", "base"): true, // concept alias on a shortcut read
|
||||
paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias
|
||||
paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias
|
||||
paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias
|
||||
paramAliasPayloadCaseKey("contact user profile get", "user-id"): true, // native compatibility flag
|
||||
paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override
|
||||
paramAliasPayloadCaseKey("doc +comment-create", "body"): true, // write shortcut content alias
|
||||
paramAliasPayloadCaseKey("doc +copy", "parent-folder-id"): true, // Doc folder role on a write shortcut
|
||||
paramAliasPayloadCaseKey("doc +create", "content-format"): true, // shortcut format alias preserves markdown/jsonml enum
|
||||
paramAliasPayloadCaseKey("doc +create-from-template", "keyword"): true, // template search alias composes with a write workflow
|
||||
paramAliasPayloadCaseKey("doc +create-from-template", "workspace-id"): true, // template target workspace identifier
|
||||
paramAliasPayloadCaseKey("doc +create-from-template", "parent-folder-id"): true, // template target folder identifier
|
||||
paramAliasPayloadCaseKey("doc +export-submit", "doc-id"): true, // Doc node identifier alias
|
||||
paramAliasPayloadCaseKey("doc +fetch", "start-block"): true, // section boundary role remains exact
|
||||
paramAliasPayloadCaseKey("doc +history-revert", "version-number"): true, // destructive history version alias keeps confirmation
|
||||
paramAliasPayloadCaseKey("doc +inspect", "include-versions"): true, // boolean section alias preserves value
|
||||
paramAliasPayloadCaseKey("doc +template-list", "next-token"): true, // Doc cursor alias on a read shortcut
|
||||
paramAliasPayloadCaseKey("doc +update", "mode"): true, // write operation selector alias
|
||||
paramAliasPayloadCaseKey("doc +update", "revision"): true, // optimistic edit revision alias
|
||||
paramAliasPayloadCaseKey("doc +access-grant", "doc-id"): true, // permission write keeps document identity
|
||||
paramAliasPayloadCaseKey("doc +version-revert", "version-number"): true, // high-write version role with canonical confirmation
|
||||
paramAliasPayloadCaseKey("doc block insert", "content"): true, // block write content alias
|
||||
paramAliasPayloadCaseKey("doc block insert", "parent-block-id"): true, // scoped block-role alias
|
||||
paramAliasPayloadCaseKey("doc comment delete", "comment-id"): true, // destructive comment-key alias
|
||||
paramAliasPayloadCaseKey("doc comment reply", "mentioned-open-conversation-ids"): true, // list-valued group mention role
|
||||
paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias
|
||||
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
|
||||
@@ -273,8 +382,9 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
|
||||
}
|
||||
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
if _, err := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...); err != nil {
|
||||
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", err, canonicalArgs, canonicalCaller.calls)
|
||||
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
|
||||
if canonicalErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, canonicalErr) {
|
||||
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
|
||||
}
|
||||
if len(canonicalCaller.calls) == 0 {
|
||||
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
|
||||
@@ -290,13 +400,16 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
|
||||
}
|
||||
activeAliases++
|
||||
aliasCaller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if err != nil {
|
||||
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", err, aliasArgs, aliasCaller.calls)
|
||||
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if aliasErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, aliasErr) {
|
||||
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("complete alias command skipped PreParse")
|
||||
}
|
||||
if (canonicalErr == nil) != (aliasErr == nil) || (canonicalErr != nil && canonicalErr.Error() != aliasErr.Error()) {
|
||||
t.Fatalf("canonical and alias completion errors differ: canonical=%v alias=%v", canonicalErr, aliasErr)
|
||||
}
|
||||
normalizeParamAliasVolatileDefaults(test.command, canonicalCaller, aliasCaller)
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
|
||||
@@ -308,6 +421,16 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
|
||||
}
|
||||
}
|
||||
|
||||
// Resource download deliberately continues from the transport call into a
|
||||
// local HTTPS download. The generic capture caller returns an empty object, so
|
||||
// this command's stable post-transport validation error is the expected test
|
||||
// boundary; canonical and alias calls must still produce the same request and
|
||||
// the same error.
|
||||
func paramAliasExpectedCaptureBoundaryError(command string, err error) bool {
|
||||
return command == "chat +messages-resource-download" && err != nil &&
|
||||
strings.Contains(err.Error(), "资源下载接口未返回合法的 HTTPS 下载地址")
|
||||
}
|
||||
|
||||
// +chat-messages supplies the current wall-clock time when callers omit
|
||||
// --time. Alias equivalence concerns the resolved target and transport shape;
|
||||
// a suite crossing a second boundary must not make that default appear
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
func TestLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
|
||||
func TestCrossPlatformCoverageLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
@@ -55,7 +55,7 @@ func TestLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
args := []string{
|
||||
"chat", "message", "send",
|
||||
@@ -98,3 +98,65 @@ func TestPreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
|
||||
t.Fatalf("--debug details missing from early error:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTree(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantReason string
|
||||
wantCommand string
|
||||
}{
|
||||
{
|
||||
name: "unknown shortcut",
|
||||
args: []string{"chat", "+chat-mesages", "--keyword", "x", "--format", "json"},
|
||||
wantReason: "unknown_shortcut",
|
||||
wantCommand: "dws chat",
|
||||
},
|
||||
{
|
||||
name: "unknown dev app subcommand",
|
||||
args: []string{"dev", "app", "search", "--keyword", "x", "--format", "json"},
|
||||
wantReason: "unknown_subcommand",
|
||||
wantCommand: "dws dev app",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
|
||||
if ctx == nil || ctx.Command != test.wantCommand {
|
||||
t.Fatalf("Context = %#v, want command %q", ctx, test.wantCommand)
|
||||
}
|
||||
err = newPreParseValidationError(err)
|
||||
var structured *apperrors.Error
|
||||
if !stderrors.As(err, &structured) {
|
||||
t.Fatalf("error = %T %v", err, err)
|
||||
}
|
||||
if structured.Reason != test.wantReason || structured.ExitCode() != 3 {
|
||||
t.Fatalf("structured error = %#v", structured)
|
||||
}
|
||||
if len(structured.AvailableFlags) != 0 || strings.Contains(structured.Message, "unknown flag") {
|
||||
t.Fatalf("command error leaked flag classification: %#v", structured)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolvedShortcutStillUsesExactLeafFlagError(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
args := []string{"chat", "+chat-messages", "--keyword", "x", "--format", "json"}
|
||||
if ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args); err != nil {
|
||||
t.Fatalf("valid shortcut path failed PreParse: %#v, %v", ctx, err)
|
||||
}
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
var structured *apperrors.Error
|
||||
if !stderrors.As(err, &structured) {
|
||||
t.Fatalf("error = %T %v", err, err)
|
||||
}
|
||||
if structured.Reason != "unknown_flag" || !strings.Contains(structured.Message, "dws chat +chat-messages --help") {
|
||||
t.Fatalf("resolved shortcut flag error = %#v", structured)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,9 @@ func Execute() (exitCode int) {
|
||||
// newPreParseValidationError keeps pipeline handler identity in internal logs
|
||||
// while exposing only the underlying parameter-domain error to CLI users.
|
||||
func newPreParseValidationError(err error) error {
|
||||
if structured, ok := err.(*apperrors.Error); ok {
|
||||
return structured
|
||||
}
|
||||
userErr := err
|
||||
var handlerErr *pipeline.HandlerError
|
||||
if stderrors.As(err, &handlerErr) && handlerErr.Unwrap() != nil {
|
||||
@@ -1324,6 +1327,18 @@ func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
|
||||
// - PostResponse: after transport returns, before stdout (canonical RunE)
|
||||
func newPipelineEngine() *pipeline.Engine {
|
||||
engine := pipeline.NewEngine()
|
||||
engine.SetCommandPathFallbackLookup(func(path string) (pipeline.CommandPathFallback, bool) {
|
||||
entry, ok := cli.LookupCommandPathFallback(path)
|
||||
if !ok {
|
||||
return pipeline.CommandPathFallback{}, false
|
||||
}
|
||||
return pipeline.CommandPathFallback{
|
||||
From: entry.From,
|
||||
Mode: string(entry.Mode),
|
||||
To: entry.To,
|
||||
Candidates: append([]string(nil), entry.Candidates...),
|
||||
}, true
|
||||
})
|
||||
engine.RegisterAll(
|
||||
// Register handler runs during command tree building.
|
||||
handlers.RegisterHandler{},
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -0,0 +1,633 @@
|
||||
{
|
||||
"source_report": "param_hallucination_20260728_120943",
|
||||
"source_file": "raw_dashscope_qwen3_7-max_20260728_120943.json",
|
||||
"source_dws_commit": "a8e83e5",
|
||||
"model": "dashscope/qwen3.7-max",
|
||||
"expected_count": 52,
|
||||
"badcases": [
|
||||
{
|
||||
"id": "dws_im_v3_0003-run10-command0",
|
||||
"case_id": "dws_im_v3_0003",
|
||||
"run": 10,
|
||||
"command_index": 0,
|
||||
"turn": 1,
|
||||
"source_path": "chat +members",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +members --group \"dws测试群01\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0003-run10-command1",
|
||||
"case_id": "dws_im_v3_0003",
|
||||
"run": 10,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +members",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +members --group-name \"dws测试群01\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0003-run10-command2",
|
||||
"case_id": "dws_im_v3_0003",
|
||||
"run": 10,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +members",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +members \"dws测试群01\" --format json",
|
||||
"original_exit_code": 5,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0010-run1-command1",
|
||||
"case_id": "dws_im_v3_0010",
|
||||
"run": 1,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-dm",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-dm --open-dingtalk-id DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS --content \"评测任务更新了,稍后同步\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0010-run1-command2",
|
||||
"case_id": "dws_im_v3_0010",
|
||||
"run": 1,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-dm",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-dm --dingtalk-id DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS --content \"评测任务更新了,稍后同步\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0010-run8-command4",
|
||||
"case_id": "dws_im_v3_0010",
|
||||
"run": 8,
|
||||
"command_index": 4,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-single --user-id \"DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS\" --content \"评测任务更新了,稍后同步\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0010-run10-command0",
|
||||
"case_id": "dws_im_v3_0010",
|
||||
"run": 10,
|
||||
"command_index": 0,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-by-bot",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-by-bot --open-dingtalk-id \"段旭彬\" --message \"评测任务更新了,稍后同步\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0010-run10-command1",
|
||||
"case_id": "dws_im_v3_0010",
|
||||
"run": 10,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-by-bot",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-by-bot --open-id \"段旭彬\" --msg \"评测任务更新了,稍后同步\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0010-run10-command2",
|
||||
"case_id": "dws_im_v3_0010",
|
||||
"run": 10,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-by-bot",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-by-bot \"段旭彬\" \"评测任务更新了,稍后同步\" --format json",
|
||||
"original_exit_code": 5,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0011-run1-command3",
|
||||
"case_id": "dws_im_v3_0011",
|
||||
"run": 1,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-media",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-media --user-id \"489149\" --file-path \"建筑.jpeg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0011-run1-command4",
|
||||
"case_id": "dws_im_v3_0011",
|
||||
"run": 1,
|
||||
"command_index": 4,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-media",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-media --receiver \"489149\" --file-path \"建筑.jpeg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0011-run9-command1",
|
||||
"case_id": "dws_im_v3_0011",
|
||||
"run": 9,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --user-id 489149 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case__ysfhvr3/建筑.jpeg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0011-run9-command2",
|
||||
"case_id": "dws_im_v3_0011",
|
||||
"run": 9,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --dry-run --format json 2>&1 | head -50",
|
||||
"original_exit_code": 0,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run1-command2",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 1,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_bc6azkyi/streamlit.docx\" --to \"符咏畅\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run1-command3",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 1,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_bc6azkyi/streamlit.docx\" --user \"符咏畅\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run2-command3",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 2,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --receiver 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_3er98f5m/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run2-command8",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 2,
|
||||
"command_index": 8,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send --user 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_3er98f5m/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run3-command1",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 3,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --user-id 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_cljycf8p/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run3-command2",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 3,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --receiver 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_cljycf8p/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run4-command3",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 4,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --help",
|
||||
"original_exit_code": 64,
|
||||
"is_help": true
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run6-command4",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 6,
|
||||
"command_index": 4,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --user 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_8ef9dsey/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run6-command5",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 6,
|
||||
"command_index": 5,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --to \"519483\" --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_8ef9dsey/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run6-command6",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 6,
|
||||
"command_index": 6,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --name \"符咏畅\" --path \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_8ef9dsey/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run7-command1",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 7,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --user-id \"519483\" --file-path \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_fjcfinmi/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run7-command2",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 7,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --format json --userid \"519483\" --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_fjcfinmi/streamlit.docx\"",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0016-run1-command7",
|
||||
"case_id": "dws_im_v3_0016",
|
||||
"run": 1,
|
||||
"command_index": 7,
|
||||
"turn": 3,
|
||||
"source_path": "chat +rename-group",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +rename-group --id \"cideWy3UCPNZ4k8Mr4BL/8KRQ==\" --name \"测试废物群-20260713\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run2-command6",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 2,
|
||||
"command_index": 6,
|
||||
"turn": 2,
|
||||
"source_path": "chat +send-message",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-message --group \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --content \"@陈邦杰 请确认下评测环境\" --mention \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run2-command7",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 2,
|
||||
"command_index": 7,
|
||||
"turn": 2,
|
||||
"source_path": "chat +send-message",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-message --open-conversation-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --content \"@陈邦杰 请确认下评测环境\" --mention \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run2-command9",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 2,
|
||||
"command_index": 9,
|
||||
"turn": 2,
|
||||
"source_path": "chat +send-message",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-message --format json 2>&1",
|
||||
"original_exit_code": 5,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run5-command0",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 5,
|
||||
"command_index": 0,
|
||||
"turn": 1,
|
||||
"source_path": "chat +group-member-list",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +group-member-list --group-name \"dws测试群01\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run6-command6",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 6,
|
||||
"command_index": 6,
|
||||
"turn": 2,
|
||||
"source_path": "chat +send-text",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-text --open-conversation-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --text \"@陈邦杰 请确认下评测环境\" --at-open-dingtalk-ids \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run6-command7",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 6,
|
||||
"command_index": 7,
|
||||
"turn": 2,
|
||||
"source_path": "chat +send-text",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-text --conversation-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --content \"@陈邦杰 请确认下评测环境\" --at-user-ids \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run6-command8",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 6,
|
||||
"command_index": 8,
|
||||
"turn": 2,
|
||||
"source_path": "chat +group-send-text",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +group-send-text --group-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --text \"@陈邦杰 请确认下评测环境\" --at-open-dingtalk-ids \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0018-run10-command5",
|
||||
"case_id": "dws_im_v3_0018",
|
||||
"run": 10,
|
||||
"command_index": 5,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-image",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-image --group \"cidyfbDkUpmy4ReyI7vfZy+zw==\" --file-path \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_z7y6p6_0/dog.png\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run4-command1",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 4,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-image",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-image --user-id 489149 --image /private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_wsppmjwr/landscape.jpg --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run4-command2",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 4,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-image",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-image --to 489149 --file /private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_wsppmjwr/landscape.jpg --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run6-command3",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 6,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send --receiver-id 489149 --image \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_09930iej/landscape.jpg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run8-command2",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 8,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --user-id 489149 --file \"landscape.jpg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run8-command3",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 8,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --receiver-id 489149 --file \"landscape.jpg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run8-command6",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 8,
|
||||
"command_index": 6,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send --to 489149 --file \"landscape.jpg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run8-command9",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 8,
|
||||
"command_index": 9,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-to",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-to --uid 489149 --file \"landscape.jpg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run9-command0",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 9,
|
||||
"command_index": 0,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send --receiver 489149 --file-path \"./landscape.jpg\" --format json 2>&1",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run4-command3",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 4,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +read-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +read-single --user-id 550582 --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run4-command4",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 4,
|
||||
"command_index": 4,
|
||||
"turn": 1,
|
||||
"source_path": "chat +read-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +read-single --uid 550582 --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run4-command5",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 4,
|
||||
"command_index": 5,
|
||||
"turn": 1,
|
||||
"source_path": "chat +read-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +read-single --staff-id 550582 --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run4-command6",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 4,
|
||||
"command_index": 6,
|
||||
"turn": 1,
|
||||
"source_path": "chat +read-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +read-single --open-dingtalk-id \"DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run4-command7",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 4,
|
||||
"command_index": 7,
|
||||
"turn": 1,
|
||||
"source_path": "chat +read-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +read-single --target 550582 --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run4-command8",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 4,
|
||||
"command_index": 8,
|
||||
"turn": 1,
|
||||
"source_path": "chat +read-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +read-single 550582 --format json",
|
||||
"original_exit_code": 5,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run5-command1",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 5,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +message-list",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +message-list --format json 2>&1",
|
||||
"original_exit_code": 5,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0028-run4-command5",
|
||||
"case_id": "dws_im_v3_0028",
|
||||
"run": 4,
|
||||
"command_index": 5,
|
||||
"turn": 1,
|
||||
"source_path": "chat +list-robot",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +list-robot \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --format json",
|
||||
"original_exit_code": 5,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0028-run5-command15",
|
||||
"case_id": "dws_im_v3_0028",
|
||||
"run": 5,
|
||||
"command_index": 15,
|
||||
"turn": 1,
|
||||
"source_path": "chat +list-group-bots",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +list-group-bots --id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0028-run9-command9",
|
||||
"case_id": "dws_im_v3_0028",
|
||||
"run": 9,
|
||||
"command_index": 9,
|
||||
"turn": 1,
|
||||
"source_path": "chat +list-robots",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +list-robots --cid \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const commandPathFallbacksSchemaRef = "./command_path_fallbacks.schema.json"
|
||||
|
||||
// command_path_fallbacks.json is a reviewed recovery policy for invalid paths
|
||||
// observed in evaluation. It is deliberately not a command identity source:
|
||||
// Help, Skill, Schema, and normal navigation continue to publish canonical
|
||||
// paths from the CommandRegistry and the real Cobra tree only.
|
||||
|
||||
//go:embed command_path_fallbacks.json
|
||||
var embeddedCommandPathFallbacksJSON []byte
|
||||
|
||||
//go:embed command_path_fallbacks.schema.json
|
||||
var embeddedCommandPathFallbacksSchemaJSON []byte
|
||||
|
||||
var commandFallbackPathPattern = regexp.MustCompile(`^[A-Za-z0-9+][A-Za-z0-9._:+-]*(?: [A-Za-z0-9+][A-Za-z0-9._:+-]*)*$`)
|
||||
|
||||
// CommandPathFallbackMode determines whether an invalid reviewed path can be
|
||||
// normalized exactly or must stop and surface reviewed candidates.
|
||||
type CommandPathFallbackMode string
|
||||
|
||||
const (
|
||||
CommandPathFallbackRewrite CommandPathFallbackMode = "rewrite"
|
||||
CommandPathFallbackAmbiguous CommandPathFallbackMode = "ambiguous"
|
||||
)
|
||||
|
||||
// CommandPathFallback is one validated, generated runtime recovery record.
|
||||
// From is never advertised as a stable alias. To and Candidates are canonical
|
||||
// real Cobra paths validated at generation time.
|
||||
type CommandPathFallback struct {
|
||||
From string `json:"from"`
|
||||
Mode CommandPathFallbackMode `json:"mode"`
|
||||
To string `json:"to,omitempty"`
|
||||
Candidates []string `json:"candidates,omitempty"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
ReviewReason string `json:"review_reason"`
|
||||
}
|
||||
|
||||
type commandPathFallbackSnapshot struct {
|
||||
Schema string `json:"$schema"`
|
||||
Version int `json:"version"`
|
||||
Entries []commandPathFallbackEntrySpec `json:"entries"`
|
||||
}
|
||||
|
||||
type commandPathFallbackEntrySpec struct {
|
||||
From string `json:"from"`
|
||||
Mode CommandPathFallbackMode `json:"mode"`
|
||||
To string `json:"to,omitempty"`
|
||||
Candidates []string `json:"candidates,omitempty"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
ReviewReason string `json:"review_reason"`
|
||||
}
|
||||
|
||||
var (
|
||||
embeddedCommandPathFallbacksOnce sync.Once
|
||||
embeddedCommandPathFallbacksData []CommandPathFallback
|
||||
embeddedCommandPathFallbacksErr error
|
||||
loadReviewedCommandPathFallbacks = loadEmbeddedCommandPathFallbacks
|
||||
)
|
||||
|
||||
// LoadCommandPathFallbacks decodes and validates the authored recovery table.
|
||||
// Callers receive a clone so generation and tests cannot mutate shared data.
|
||||
func LoadCommandPathFallbacks() ([]CommandPathFallback, error) {
|
||||
return loadReviewedCommandPathFallbacks()
|
||||
}
|
||||
|
||||
func loadEmbeddedCommandPathFallbacks() ([]CommandPathFallback, error) {
|
||||
embeddedCommandPathFallbacksOnce.Do(func() {
|
||||
embeddedCommandPathFallbacksData, embeddedCommandPathFallbacksErr = decodeCommandPathFallbacks(embeddedCommandPathFallbacksJSON)
|
||||
})
|
||||
return cloneCommandPathFallbacks(embeddedCommandPathFallbacksData), embeddedCommandPathFallbacksErr
|
||||
}
|
||||
|
||||
func decodeCommandPathFallbacks(data []byte) ([]CommandPathFallback, error) {
|
||||
var snapshot commandPathFallbackSnapshot
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&snapshot); err != nil {
|
||||
return nil, fmt.Errorf("decode reviewed command path fallbacks: %w", err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("multiple JSON values")
|
||||
}
|
||||
return nil, fmt.Errorf("decode reviewed command path fallbacks: %w", err)
|
||||
}
|
||||
if snapshot.Version != 1 {
|
||||
return nil, fmt.Errorf("unsupported command path fallbacks version %d", snapshot.Version)
|
||||
}
|
||||
if strings.TrimSpace(snapshot.Schema) != commandPathFallbacksSchemaRef {
|
||||
return nil, fmt.Errorf("command path fallbacks must declare $schema=%q", commandPathFallbacksSchemaRef)
|
||||
}
|
||||
if len(snapshot.Entries) == 0 {
|
||||
return nil, fmt.Errorf("command path fallbacks declares no entries")
|
||||
}
|
||||
|
||||
entries := make([]CommandPathFallback, 0, len(snapshot.Entries))
|
||||
seenFrom := make(map[string]bool, len(snapshot.Entries))
|
||||
for index, spec := range snapshot.Entries {
|
||||
from, err := validateAuthoredCommandFallbackPath(spec.From)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("command path fallback entry %d from: %w", index, err)
|
||||
}
|
||||
if seenFrom[from] {
|
||||
return nil, fmt.Errorf("command path fallbacks contains duplicate from path %q", from)
|
||||
}
|
||||
seenFrom[from] = true
|
||||
reason := strings.TrimSpace(spec.ReviewReason)
|
||||
if !spec.Reviewed || reason == "" {
|
||||
return nil, fmt.Errorf("command path fallback %q requires reviewed=true and non-empty review_reason", from)
|
||||
}
|
||||
|
||||
entry := CommandPathFallback{
|
||||
From: from,
|
||||
Mode: spec.Mode,
|
||||
Reviewed: true,
|
||||
ReviewReason: reason,
|
||||
}
|
||||
switch spec.Mode {
|
||||
case CommandPathFallbackRewrite:
|
||||
if len(spec.Candidates) > 0 {
|
||||
return nil, fmt.Errorf("rewrite command path fallback %q must not declare candidates", from)
|
||||
}
|
||||
to, pathErr := validateAuthoredCommandFallbackPath(spec.To)
|
||||
if pathErr != nil {
|
||||
return nil, fmt.Errorf("rewrite command path fallback %q to: %w", from, pathErr)
|
||||
}
|
||||
entry.To = to
|
||||
case CommandPathFallbackAmbiguous:
|
||||
if strings.TrimSpace(spec.To) != "" {
|
||||
return nil, fmt.Errorf("ambiguous command path fallback %q must not declare to", from)
|
||||
}
|
||||
if len(spec.Candidates) < 2 {
|
||||
return nil, fmt.Errorf("ambiguous command path fallback %q requires at least two candidates", from)
|
||||
}
|
||||
seenCandidates := make(map[string]bool, len(spec.Candidates))
|
||||
for _, rawCandidate := range spec.Candidates {
|
||||
candidate, pathErr := validateAuthoredCommandFallbackPath(rawCandidate)
|
||||
if pathErr != nil {
|
||||
return nil, fmt.Errorf("ambiguous command path fallback %q candidate: %w", from, pathErr)
|
||||
}
|
||||
if seenCandidates[candidate] {
|
||||
return nil, fmt.Errorf("ambiguous command path fallback %q repeats candidate %q", from, candidate)
|
||||
}
|
||||
seenCandidates[candidate] = true
|
||||
entry.Candidates = append(entry.Candidates, candidate)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("command path fallback %q has invalid mode %q", from, spec.Mode)
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].From < entries[j].From })
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func validateAuthoredCommandFallbackPath(raw string) (string, error) {
|
||||
path := strings.TrimSpace(raw)
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("path is empty")
|
||||
}
|
||||
if path != raw || normalizeSchemaCLIPath(path) != path || !commandFallbackPathPattern.MatchString(path) {
|
||||
return "", fmt.Errorf("path %q is not a normalized command path without leading dws or flags", raw)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// ReduceCommandPathFallbacks validates the reviewed recovery table against the
|
||||
// live distribution-owned Cobra tree. It refuses to turn an existing command
|
||||
// or stable alias into a hidden rewrite. The sole exception is an explicitly
|
||||
// annotated hint-only compatibility node, which contains no business action.
|
||||
func ReduceCommandPathFallbacks(root *cobra.Command) ([]CommandPathFallback, error) {
|
||||
if root == nil {
|
||||
return nil, fmt.Errorf("command path fallback source root is nil")
|
||||
}
|
||||
entries, err := LoadCommandPathFallbacks()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
byFrom := make(map[string]CommandPathFallback, len(entries))
|
||||
for _, entry := range entries {
|
||||
byFrom[entry.From] = entry
|
||||
}
|
||||
var problems []string
|
||||
for _, entry := range entries {
|
||||
match, resolveErr := resolveExactCobraPath(root, entry.From)
|
||||
if resolveErr != nil {
|
||||
problems = append(problems, fmt.Sprintf("from %q cannot be resolved safely: %v", entry.From, resolveErr))
|
||||
} else if match.Command != nil && !cmdutil.IsHintOnlyCommand(match.Command) {
|
||||
problems = append(problems, fmt.Sprintf("from %q collides with a real Cobra command or alias", entry.From))
|
||||
}
|
||||
|
||||
switch entry.Mode {
|
||||
case CommandPathFallbackRewrite:
|
||||
if _, chained := byFrom[entry.To]; chained {
|
||||
problems = append(problems, fmt.Sprintf("rewrite %q targets fallback source %q; chained fallbacks are forbidden", entry.From, entry.To))
|
||||
}
|
||||
problems = append(problems, validateCommandFallbackTarget(root, entry.From, entry.To, true)...)
|
||||
case CommandPathFallbackAmbiguous:
|
||||
for _, candidate := range entry.Candidates {
|
||||
if _, chained := byFrom[candidate]; chained {
|
||||
problems = append(problems, fmt.Sprintf("ambiguous fallback %q candidate %q is another fallback source", entry.From, candidate))
|
||||
}
|
||||
// Ambiguous recovery never dispatches. It may therefore point the
|
||||
// caller at both canonical shortcuts and native leaves, while exact
|
||||
// rewrites keep the stricter +shortcut identity boundary.
|
||||
problems = append(problems, validateCommandFallbackTarget(root, entry.From, candidate, false)...)
|
||||
}
|
||||
default:
|
||||
problems = append(problems, fmt.Sprintf("fallback %q has unsupported mode %q", entry.From, entry.Mode))
|
||||
}
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
sort.Strings(problems)
|
||||
return nil, fmt.Errorf("command path fallback reduction failed:\n - %s", strings.Join(problems, "\n - "))
|
||||
}
|
||||
return cloneCommandPathFallbacks(entries), nil
|
||||
}
|
||||
|
||||
func validateCommandFallbackTarget(root *cobra.Command, from, target string, requireShortcutParity bool) []string {
|
||||
var problems []string
|
||||
if commandFallbackService(from) != commandFallbackService(target) {
|
||||
problems = append(problems, fmt.Sprintf("fallback %q crosses service boundary to %q", from, target))
|
||||
}
|
||||
if requireShortcutParity && commandFallbackHasShortcut(from) != commandFallbackHasShortcut(target) {
|
||||
problems = append(problems, fmt.Sprintf("fallback %q and target %q disagree on +shortcut identity", from, target))
|
||||
}
|
||||
match, err := resolveExactCobraPath(root, target)
|
||||
if err != nil {
|
||||
return append(problems, fmt.Sprintf("fallback %q target %q cannot be resolved safely: %v", from, target, err))
|
||||
}
|
||||
if match.Command == nil {
|
||||
return append(problems, fmt.Sprintf("fallback %q target %q does not exist", from, target))
|
||||
}
|
||||
if match.UsedAlias {
|
||||
problems = append(problems, fmt.Sprintf("fallback %q target %q must use canonical Cobra names, not an alias", from, target))
|
||||
}
|
||||
if !runnableSchemaLeaf(match.Command) || !match.Command.IsAvailableCommand() {
|
||||
problems = append(problems, fmt.Sprintf("fallback %q target %q is not a public runnable Cobra leaf", from, target))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func commandFallbackService(path string) string {
|
||||
parts := strings.Fields(path)
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
func commandFallbackHasShortcut(path string) bool {
|
||||
for _, part := range strings.Fields(path) {
|
||||
if strings.HasPrefix(part, "+") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cloneCommandPathFallbacks(entries []CommandPathFallback) []CommandPathFallback {
|
||||
cloned := make([]CommandPathFallback, len(entries))
|
||||
for index, entry := range entries {
|
||||
cloned[index] = entry
|
||||
cloned[index].Candidates = append([]string(nil), entry.Candidates...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
{
|
||||
"$schema": "./command_path_fallbacks.schema.json",
|
||||
"version": 1,
|
||||
"entries": [
|
||||
{
|
||||
"from": "chat +group-search",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +chat-search",
|
||||
"reviewed": true,
|
||||
"review_reason": "0803 evaluation badcase: the model emitted +group-search with --query; +chat-search provides the same group-name search operation."
|
||||
},
|
||||
{
|
||||
"from": "chat +members",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +group-members",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation badcases emitted +members three times for listing members of a group selected by name; +group-members is the unique reviewed read-only shortcut for that intent."
|
||||
},
|
||||
{
|
||||
"from": "chat +group-member-list",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +group-members",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +group-member-list for a group-name member lookup; +group-members is the unique reviewed read-only shortcut and canonical parameter validation remains authoritative."
|
||||
},
|
||||
{
|
||||
"from": "chat +list-group-bots",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +chat-bots",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +list-group-bots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs."
|
||||
},
|
||||
{
|
||||
"from": "chat +list-robot",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +chat-bots",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted singular +list-robot for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs."
|
||||
},
|
||||
{
|
||||
"from": "chat +list-robots",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +chat-bots",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +list-robots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs."
|
||||
},
|
||||
{
|
||||
"from": "chat +message-list",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +chat-messages",
|
||||
"chat +messages-list-direct",
|
||||
"chat +search-msg",
|
||||
"chat +unread-chats"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +message-list without identifying group versus direct history, conversation history versus cross-chat search, or ordinary versus unread conversations; no candidate may be selected automatically."
|
||||
},
|
||||
{
|
||||
"from": "chat +read-single",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-list-direct",
|
||||
"chat +chat-messages"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +read-single six times for direct-message history, but the invented name does not choose between the focused direct-history shortcut and the broader group/direct history workflow; command recovery must stop before parameter validation or dispatch."
|
||||
},
|
||||
{
|
||||
"from": "chat +rename-group",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +chat-update",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +rename-group for the unique group-name update intent; +chat-update is the reviewed shortcut with that exact command-level operation. Parameter compatibility remains the canonical target's responsibility."
|
||||
},
|
||||
{
|
||||
"from": "chat +send",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat +dm",
|
||||
"chat +send-to-group"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-message",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat +dm",
|
||||
"chat +send-to-group"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-message without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-text",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat +dm",
|
||||
"chat +send-to-group"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation identified text content but not a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-to",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat +dm",
|
||||
"chat +send-to-group"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-to without proving whether the recipient denotes a user, group, or low-level identifier; the write operation must not choose a target workflow automatically."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-dm",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +dm",
|
||||
"chat +messages-send"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-dm twice, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-single",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +dm",
|
||||
"chat +messages-send"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-single for a direct message, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-by-bot",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat message send-by-bot"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-by-bot three times without a complete sending-identity contract; stop and present the unified identity-aware shortcut and the exact native bot sender instead of selecting a write path."
|
||||
},
|
||||
{
|
||||
"from": "chat +group-send-text",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +send-to-group",
|
||||
"chat +messages-send"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +group-send-text for a group text operation, but the invented name does not choose between name-resolved group text and the unified identifier-aware workflow; the write operation must not dispatch automatically."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-file",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat message send"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-file fifteen times with incompatible target and file parameter spellings; stop before dispatch and let the caller choose the unified shortcut or native current-user file workflow."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-image",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat message send"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-image without proving whether the input is an existing mediaId or a local file; stop before dispatch and let the caller choose the reviewed unified or native workflow."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-media",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat message send"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-media without a concrete media type, sending identity, or compatible parameter contract; stop before dispatch and let the caller choose the reviewed unified or native workflow."
|
||||
},
|
||||
{
|
||||
"from": "oa +list-processes",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"oa +list-forms",
|
||||
"oa +my-initiated",
|
||||
"oa approval list-initiated"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf."
|
||||
},
|
||||
{
|
||||
"from": "chat +conversation-detail",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +conversation-info",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260804 multi-im badcase requested one conversation's details. +conversation-info is the unique current read-only shortcut for that operation. The rewrite changes only the command path and preserves every flag/value for target validation."
|
||||
},
|
||||
{
|
||||
"from": "chat +bot-list",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +chat-bots",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260804 multi-im badcase requested the robot list for one group. +chat-bots is the unique current read-only shortcut. The fallback must not reinterpret the accompanying group flag."
|
||||
},
|
||||
{
|
||||
"from": "chat +conversation-category-list",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +category-list",
|
||||
"chat +category-list-conversations"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "The invented name can mean listing the user's categories or listing conversations inside one category. No candidate may execute before the caller chooses the intended object level."
|
||||
},
|
||||
{
|
||||
"from": "chat +conversation-group-list",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +category-list-conversations",
|
||||
"chat +conversation-list"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "The invented name can mean conversations in a custom category or the general conversation list. The command name alone does not identify the requested collection."
|
||||
},
|
||||
{
|
||||
"from": "chat +list-my-groups",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +my-groups",
|
||||
"chat +chat-list-mine",
|
||||
"chat +chat-list"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "The invented name does not choose between the established resolver shortcut, the legacy personal-group list and the current Schema-complete chat list. Recovery must stop instead of silently changing pagination or output semantics."
|
||||
},
|
||||
{
|
||||
"from": "doc +list-templates",
|
||||
"mode": "rewrite",
|
||||
"to": "doc +template-list",
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 Doc experiments emitted +list-templates while discovering available templates. +template-list is the unique current public read-only shortcut for that operation; the rewrite changes only the command path and preserves flags for target validation."
|
||||
},
|
||||
{
|
||||
"from": "doc +search-template",
|
||||
"mode": "rewrite",
|
||||
"to": "doc +template-search",
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 Doc experiments emitted the verb/object inversion +search-template. +template-search is the unique current public read-only template-name search shortcut; the rewrite preserves every flag and value."
|
||||
},
|
||||
{
|
||||
"from": "doc +template",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"doc +template-list",
|
||||
"doc +template-search",
|
||||
"doc +create-from-template"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 Doc experiments emitted the family-like +template without selecting browse, search, or create-from-template. Command recovery must stop before parameters or execution and present the three public workflows."
|
||||
},
|
||||
{
|
||||
"from": "doc +version",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"doc +history-list",
|
||||
"doc +history-save",
|
||||
"doc +history-revert"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 Doc experiments emitted the family-like +version without choosing list, save, or revert. The write and high-risk revert operations cannot be selected from an umbrella name; present the canonical history shortcuts and stop."
|
||||
},
|
||||
{
|
||||
"from": "doc +create-version",
|
||||
"mode": "rewrite",
|
||||
"to": "doc +history-save",
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 supplemental Doc badcase emitted +create-version while explicitly trying to save the current document as a recoverable history snapshot. +history-save is the unique current canonical shortcut for that operation and accepts the same --node value without parameter transformation."
|
||||
},
|
||||
{
|
||||
"from": "doc +save-version",
|
||||
"mode": "rewrite",
|
||||
"to": "doc +history-save",
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 supplemental Doc badcase emitted the verb/object inversion +save-version for a current-version snapshot. +history-save is the unique current canonical shortcut with the same document-node contract; the legacy +version-save path is not selected as the fallback target."
|
||||
},
|
||||
{
|
||||
"from": "doc +snapshot",
|
||||
"mode": "rewrite",
|
||||
"to": "doc +history-save",
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 supplemental Doc badcase emitted +snapshot during an explicit save-current-version workflow. The current +history-save selection contract names this exact recoverable history-snapshot operation, so the reviewed rewrite preserves the --node value and does not infer an update or export workflow."
|
||||
},
|
||||
{
|
||||
"from": "doc +version-create",
|
||||
"mode": "rewrite",
|
||||
"to": "doc +history-save",
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 supplemental Doc badcase emitted +version-create while saving the current document version. +history-save is the unique current canonical snapshot shortcut and the path-only rewrite leaves its real execution and safety contract authoritative."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/internal/cli/command_path_fallbacks.schema.json",
|
||||
"title": "DWS Reviewed Command Path Fallbacks",
|
||||
"description": "Human-reviewed recovery-only normalization for invalid model-emitted command paths. These entries are not stable command identity, navigation, Help, Skill, or Schema aliases.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"$schema",
|
||||
"version",
|
||||
"entries"
|
||||
],
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"const": "./command_path_fallbacks.schema.json"
|
||||
},
|
||||
"version": {
|
||||
"const": 1
|
||||
},
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/entry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"commandPath": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9+][A-Za-z0-9._:+-]*(?: [A-Za-z0-9+][A-Za-z0-9._:+-]*)*$",
|
||||
"description": "Exact CLI path without a leading dws or flags."
|
||||
},
|
||||
"entry": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"from",
|
||||
"mode",
|
||||
"reviewed",
|
||||
"review_reason"
|
||||
],
|
||||
"properties": {
|
||||
"from": {
|
||||
"$ref": "#/$defs/commandPath"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"rewrite",
|
||||
"ambiguous"
|
||||
]
|
||||
},
|
||||
"to": {
|
||||
"$ref": "#/$defs/commandPath"
|
||||
},
|
||||
"candidates": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"uniqueItems": true,
|
||||
"description": "Canonical public runnable paths offered for an ambiguous source. Because ambiguous recovery never dispatches, candidates may mix reviewed shortcuts and native leaves within the same service.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/commandPath"
|
||||
}
|
||||
},
|
||||
"reviewed": {
|
||||
"const": true
|
||||
},
|
||||
"review_reason": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"mode": {
|
||||
"const": "rewrite"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"mode"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"to"
|
||||
],
|
||||
"not": {
|
||||
"required": [
|
||||
"candidates"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"mode": {
|
||||
"const": "ambiguous"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"mode"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"candidates"
|
||||
],
|
||||
"not": {
|
||||
"required": [
|
||||
"to"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Code generated by cmd_command_path_fallbacks; DO NOT EDIT.
|
||||
// Source: internal/cli/command_path_fallbacks.json validated against the live Cobra tree.
|
||||
// Regenerate with `make generate-schema` (or `go generate ./internal/cli`).
|
||||
|
||||
package cli
|
||||
|
||||
var generatedCommandPathFallbacks = []CommandPathFallback{
|
||||
{
|
||||
From: "chat +bot-list",
|
||||
Mode: "rewrite",
|
||||
To: "chat +chat-bots",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260804 multi-im badcase requested the robot list for one group. +chat-bots is the unique current read-only shortcut. The fallback must not reinterpret the accompanying group flag.",
|
||||
},
|
||||
{
|
||||
From: "chat +conversation-category-list",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +category-list", "chat +category-list-conversations"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "The invented name can mean listing the user's categories or listing conversations inside one category. No candidate may execute before the caller chooses the intended object level.",
|
||||
},
|
||||
{
|
||||
From: "chat +conversation-detail",
|
||||
Mode: "rewrite",
|
||||
To: "chat +conversation-info",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260804 multi-im badcase requested one conversation's details. +conversation-info is the unique current read-only shortcut for that operation. The rewrite changes only the command path and preserves every flag/value for target validation.",
|
||||
},
|
||||
{
|
||||
From: "chat +conversation-group-list",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +category-list-conversations", "chat +conversation-list"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "The invented name can mean conversations in a custom category or the general conversation list. The command name alone does not identify the requested collection.",
|
||||
},
|
||||
{
|
||||
From: "chat +group-member-list",
|
||||
Mode: "rewrite",
|
||||
To: "chat +group-members",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +group-member-list for a group-name member lookup; +group-members is the unique reviewed read-only shortcut and canonical parameter validation remains authoritative.",
|
||||
},
|
||||
{
|
||||
From: "chat +group-search",
|
||||
Mode: "rewrite",
|
||||
To: "chat +chat-search",
|
||||
Reviewed: true,
|
||||
ReviewReason: "0803 evaluation badcase: the model emitted +group-search with --query; +chat-search provides the same group-name search operation.",
|
||||
},
|
||||
{
|
||||
From: "chat +group-send-text",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +send-to-group", "chat +messages-send"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +group-send-text for a group text operation, but the invented name does not choose between name-resolved group text and the unified identifier-aware workflow; the write operation must not dispatch automatically.",
|
||||
},
|
||||
{
|
||||
From: "chat +list-group-bots",
|
||||
Mode: "rewrite",
|
||||
To: "chat +chat-bots",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +list-group-bots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs.",
|
||||
},
|
||||
{
|
||||
From: "chat +list-my-groups",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "The invented name does not choose between the established resolver shortcut, the legacy personal-group list and the current Schema-complete chat list. Recovery must stop instead of silently changing pagination or output semantics.",
|
||||
},
|
||||
{
|
||||
From: "chat +list-robot",
|
||||
Mode: "rewrite",
|
||||
To: "chat +chat-bots",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted singular +list-robot for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs.",
|
||||
},
|
||||
{
|
||||
From: "chat +list-robots",
|
||||
Mode: "rewrite",
|
||||
To: "chat +chat-bots",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +list-robots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs.",
|
||||
},
|
||||
{
|
||||
From: "chat +members",
|
||||
Mode: "rewrite",
|
||||
To: "chat +group-members",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation badcases emitted +members three times for listing members of a group selected by name; +group-members is the unique reviewed read-only shortcut for that intent.",
|
||||
},
|
||||
{
|
||||
From: "chat +message-list",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +chat-messages", "chat +messages-list-direct", "chat +search-msg", "chat +unread-chats"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +message-list without identifying group versus direct history, conversation history versus cross-chat search, or ordinary versus unread conversations; no candidate may be selected automatically.",
|
||||
},
|
||||
{
|
||||
From: "chat +read-single",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-list-direct", "chat +chat-messages"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +read-single six times for direct-message history, but the invented name does not choose between the focused direct-history shortcut and the broader group/direct history workflow; command recovery must stop before parameter validation or dispatch.",
|
||||
},
|
||||
{
|
||||
From: "chat +rename-group",
|
||||
Mode: "rewrite",
|
||||
To: "chat +chat-update",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +rename-group for the unique group-name update intent; +chat-update is the reviewed shortcut with that exact command-level operation. Parameter compatibility remains the canonical target's responsibility.",
|
||||
},
|
||||
{
|
||||
From: "chat +send",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-by-bot",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat message send-by-bot"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-by-bot three times without a complete sending-identity contract; stop and present the unified identity-aware shortcut and the exact native bot sender instead of selecting a write path.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-dm",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +dm", "chat +messages-send"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-dm twice, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-file",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat message send"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-file fifteen times with incompatible target and file parameter spellings; stop before dispatch and let the caller choose the unified shortcut or native current-user file workflow.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-image",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat message send"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-image without proving whether the input is an existing mediaId or a local file; stop before dispatch and let the caller choose the reviewed unified or native workflow.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-media",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat message send"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-media without a concrete media type, sending identity, or compatible parameter contract; stop before dispatch and let the caller choose the reviewed unified or native workflow.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-message",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-message without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-single",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +dm", "chat +messages-send"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-single for a direct message, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-text",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation identified text content but not a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-to",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-to without proving whether the recipient denotes a user, group, or low-level identifier; the write operation must not choose a target workflow automatically.",
|
||||
},
|
||||
{
|
||||
From: "doc +create-version",
|
||||
Mode: "rewrite",
|
||||
To: "doc +history-save",
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 supplemental Doc badcase emitted +create-version while explicitly trying to save the current document as a recoverable history snapshot. +history-save is the unique current canonical shortcut for that operation and accepts the same --node value without parameter transformation.",
|
||||
},
|
||||
{
|
||||
From: "doc +list-templates",
|
||||
Mode: "rewrite",
|
||||
To: "doc +template-list",
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 Doc experiments emitted +list-templates while discovering available templates. +template-list is the unique current public read-only shortcut for that operation; the rewrite changes only the command path and preserves flags for target validation.",
|
||||
},
|
||||
{
|
||||
From: "doc +save-version",
|
||||
Mode: "rewrite",
|
||||
To: "doc +history-save",
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 supplemental Doc badcase emitted the verb/object inversion +save-version for a current-version snapshot. +history-save is the unique current canonical shortcut with the same document-node contract; the legacy +version-save path is not selected as the fallback target.",
|
||||
},
|
||||
{
|
||||
From: "doc +search-template",
|
||||
Mode: "rewrite",
|
||||
To: "doc +template-search",
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 Doc experiments emitted the verb/object inversion +search-template. +template-search is the unique current public read-only template-name search shortcut; the rewrite preserves every flag and value.",
|
||||
},
|
||||
{
|
||||
From: "doc +snapshot",
|
||||
Mode: "rewrite",
|
||||
To: "doc +history-save",
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 supplemental Doc badcase emitted +snapshot during an explicit save-current-version workflow. The current +history-save selection contract names this exact recoverable history-snapshot operation, so the reviewed rewrite preserves the --node value and does not infer an update or export workflow.",
|
||||
},
|
||||
{
|
||||
From: "doc +template",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"doc +template-list", "doc +template-search", "doc +create-from-template"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 Doc experiments emitted the family-like +template without selecting browse, search, or create-from-template. Command recovery must stop before parameters or execution and present the three public workflows.",
|
||||
},
|
||||
{
|
||||
From: "doc +version",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"doc +history-list", "doc +history-save", "doc +history-revert"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 Doc experiments emitted the family-like +version without choosing list, save, or revert. The write and high-risk revert operations cannot be selected from an umbrella name; present the canonical history shortcuts and stop.",
|
||||
},
|
||||
{
|
||||
From: "doc +version-create",
|
||||
Mode: "rewrite",
|
||||
To: "doc +history-save",
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 supplemental Doc badcase emitted +version-create while saving the current document version. +history-save is the unique current canonical snapshot shortcut and the path-only rewrite leaves its real execution and safety contract authoritative.",
|
||||
},
|
||||
{
|
||||
From: "oa +list-processes",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf.",
|
||||
},
|
||||
}
|
||||
|
||||
func loadGeneratedCommandPathFallbacks() []CommandPathFallback {
|
||||
return generatedCommandPathFallbacks
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package cli
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
commandPathFallbackIndexOnce sync.Once
|
||||
commandPathFallbackIndex map[string]CommandPathFallback
|
||||
)
|
||||
|
||||
func buildCommandPathFallbackIndex() {
|
||||
entries := loadGeneratedCommandPathFallbacks()
|
||||
commandPathFallbackIndex = make(map[string]CommandPathFallback, len(entries))
|
||||
for _, entry := range entries {
|
||||
commandPathFallbackIndex[entry.From] = entry
|
||||
}
|
||||
}
|
||||
|
||||
// LookupCommandPathFallback performs an exact O(1) lookup of a reviewed
|
||||
// recovery-only path. Normalization only removes a leading dws and folds
|
||||
// whitespace; it does not apply prefix, typo, or semantic matching.
|
||||
func LookupCommandPathFallback(rawPath string) (CommandPathFallback, bool) {
|
||||
commandPathFallbackIndexOnce.Do(buildCommandPathFallbackIndex)
|
||||
entry, ok := commandPathFallbackIndex[normalizeSchemaCLIPath(rawPath)]
|
||||
if !ok {
|
||||
return CommandPathFallback{}, false
|
||||
}
|
||||
entry.Candidates = append([]string(nil), entry.Candidates...)
|
||||
return entry, true
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func commandFallbackJSON(entries string) []byte {
|
||||
return []byte(`{"$schema":"./command_path_fallbacks.schema.json","version":1,"entries":` + entries + `}`)
|
||||
}
|
||||
|
||||
func validCommandFallbackEntry(from, to string) string {
|
||||
return `{"from":"` + from + `","mode":"rewrite","to":"` + to + `","reviewed":true,"review_reason":"reviewed fixture"}`
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDecodeCommandPathFallbacksStrictContract(t *testing.T) {
|
||||
valid := commandFallbackJSON(`[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]`)
|
||||
entries, err := decodeCommandPathFallbacks(valid)
|
||||
if err != nil || len(entries) != 1 || entries[0].From != "chat +bad" || entries[0].To != "chat +good" {
|
||||
t.Fatalf("decode valid = %#v, %v", entries, err)
|
||||
}
|
||||
entries[0].Candidates = append(entries[0].Candidates, "mutated")
|
||||
cloned := cloneCommandPathFallbacks(entries)
|
||||
cloned[0].Candidates[0] = "clone"
|
||||
if entries[0].Candidates[0] != "mutated" {
|
||||
t.Fatal("cloneCommandPathFallbacks aliases candidate storage")
|
||||
}
|
||||
|
||||
tests := map[string]struct {
|
||||
data []byte
|
||||
want string
|
||||
}{
|
||||
"unknown field": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":true,"review_reason":"x","extra":true}]`),
|
||||
want: "unknown field",
|
||||
},
|
||||
"wrong schema": {
|
||||
data: []byte(`{"$schema":"wrong","version":1,"entries":[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]}`),
|
||||
want: "$schema",
|
||||
},
|
||||
"wrong version": {
|
||||
data: []byte(`{"$schema":"./command_path_fallbacks.schema.json","version":2,"entries":[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]}`),
|
||||
want: "unsupported",
|
||||
},
|
||||
"empty entries": {data: commandFallbackJSON(`[]`), want: "no entries"},
|
||||
"unnormalized path": {
|
||||
data: commandFallbackJSON(`[` + validCommandFallbackEntry(` chat +bad `, `chat +good`) + `]`),
|
||||
want: "not a normalized",
|
||||
},
|
||||
"unreviewed": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":false,"review_reason":"x"}]`),
|
||||
want: "reviewed=true",
|
||||
},
|
||||
"empty reason": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":true,"review_reason":" "}]`),
|
||||
want: "reviewed=true",
|
||||
},
|
||||
"invalid mode": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"guess","reviewed":true,"review_reason":"x"}]`),
|
||||
want: "invalid mode",
|
||||
},
|
||||
"rewrite candidates": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","candidates":["chat +one","chat +two"],"reviewed":true,"review_reason":"x"}]`),
|
||||
want: "must not declare candidates",
|
||||
},
|
||||
"empty rewrite target": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","reviewed":true,"review_reason":"x"}]`),
|
||||
want: "path is empty",
|
||||
},
|
||||
"ambiguous target": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","to":"chat +good","candidates":["chat +one","chat +two"],"reviewed":true,"review_reason":"x"}]`),
|
||||
want: "must not declare to",
|
||||
},
|
||||
"ambiguous too small": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one"],"reviewed":true,"review_reason":"x"}]`),
|
||||
want: "at least two",
|
||||
},
|
||||
"empty ambiguous candidate": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one",""],"reviewed":true,"review_reason":"x"}]`),
|
||||
want: "path is empty",
|
||||
},
|
||||
"duplicate candidate": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one","chat +one"],"reviewed":true,"review_reason":"x"}]`),
|
||||
want: "repeats candidate",
|
||||
},
|
||||
"duplicate from": {
|
||||
data: commandFallbackJSON(`[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `,` + validCommandFallbackEntry(`chat +bad`, `chat +other`) + `]`),
|
||||
want: "duplicate from",
|
||||
},
|
||||
"multiple documents": {
|
||||
data: append(valid, []byte(` {}`)...),
|
||||
want: "multiple JSON values",
|
||||
},
|
||||
}
|
||||
for name, test := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := decodeCommandPathFallbacks(test.data); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("decode error = %v, want containing %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackSchemaAndEmbeddedSource(t *testing.T) {
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(embeddedCommandPathFallbacksSchemaJSON, &schema); err != nil {
|
||||
t.Fatalf("decode embedded schema: %v", err)
|
||||
}
|
||||
if schema["$schema"] != "https://json-schema.org/draft/2020-12/schema" || schema["additionalProperties"] != false {
|
||||
t.Fatalf("schema header = %#v", schema)
|
||||
}
|
||||
entries, err := LoadCommandPathFallbacks()
|
||||
if err != nil || len(entries) != 34 {
|
||||
t.Fatalf("LoadCommandPathFallbacks() = %#v, %v", entries, err)
|
||||
}
|
||||
if got, ok := LookupCommandPathFallback("dws chat +group-search"); !ok || got.To != "chat +chat-search" {
|
||||
t.Fatalf("generated lookup = %#v, %v", got, ok)
|
||||
}
|
||||
if _, ok := LookupCommandPathFallback("chat +missing"); ok {
|
||||
t.Fatal("generated lookup accepted unknown path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackAuditCoverage(t *testing.T) {
|
||||
rewrites := map[string]string{
|
||||
"chat +bot-list": "chat +chat-bots",
|
||||
"chat +conversation-detail": "chat +conversation-info",
|
||||
"chat +group-search": "chat +chat-search",
|
||||
"chat +members": "chat +group-members",
|
||||
"chat +group-member-list": "chat +group-members",
|
||||
"chat +list-group-bots": "chat +chat-bots",
|
||||
"chat +list-robot": "chat +chat-bots",
|
||||
"chat +list-robots": "chat +chat-bots",
|
||||
"chat +rename-group": "chat +chat-update",
|
||||
"doc +create-version": "doc +history-save",
|
||||
"doc +list-templates": "doc +template-list",
|
||||
"doc +save-version": "doc +history-save",
|
||||
"doc +search-template": "doc +template-search",
|
||||
"doc +snapshot": "doc +history-save",
|
||||
"doc +version-create": "doc +history-save",
|
||||
}
|
||||
for from, to := range rewrites {
|
||||
entry, ok := LookupCommandPathFallback(from)
|
||||
if !ok || entry.Mode != CommandPathFallbackRewrite || entry.To != to {
|
||||
t.Errorf("rewrite %q = %#v, %v; want %q", from, entry, ok, to)
|
||||
}
|
||||
}
|
||||
|
||||
ambiguous := map[string][]string{
|
||||
"chat +conversation-category-list": {"chat +category-list", "chat +category-list-conversations"},
|
||||
"chat +conversation-group-list": {"chat +category-list-conversations", "chat +conversation-list"},
|
||||
"chat +group-send-text": {"chat +send-to-group", "chat +messages-send"},
|
||||
"chat +list-my-groups": {"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"},
|
||||
"chat +message-list": {"chat +chat-messages", "chat +messages-list-direct", "chat +search-msg", "chat +unread-chats"},
|
||||
"chat +read-single": {"chat +messages-list-direct", "chat +chat-messages"},
|
||||
"chat +send": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
"chat +send-by-bot": {"chat +messages-send", "chat message send-by-bot"},
|
||||
"chat +send-dm": {"chat +dm", "chat +messages-send"},
|
||||
"chat +send-message": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
"chat +send-single": {"chat +dm", "chat +messages-send"},
|
||||
"chat +send-text": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
"chat +send-to": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
"chat +send-file": {"chat +messages-send", "chat message send"},
|
||||
"chat +send-image": {"chat +messages-send", "chat message send"},
|
||||
"chat +send-media": {"chat +messages-send", "chat message send"},
|
||||
"oa +list-processes": {"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
|
||||
"doc +template": {"doc +template-list", "doc +template-search", "doc +create-from-template"},
|
||||
"doc +version": {"doc +history-list", "doc +history-save", "doc +history-revert"},
|
||||
}
|
||||
for from, candidates := range ambiguous {
|
||||
entry, ok := LookupCommandPathFallback(from)
|
||||
if !ok || entry.Mode != CommandPathFallbackAmbiguous || !reflect.DeepEqual(entry.Candidates, candidates) {
|
||||
t.Errorf("ambiguous %q = %#v, %v; want %v", from, entry, ok, candidates)
|
||||
}
|
||||
}
|
||||
|
||||
if entry, ok := LookupCommandPathFallback("chat +definitely-unknown"); ok {
|
||||
t.Errorf("unreviewed path unexpectedly falls back: %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReduceCommandPathFallbacksValidatesLiveTree(t *testing.T) {
|
||||
root := commandFallbackTestRoot()
|
||||
entries := []CommandPathFallback{
|
||||
{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good", Reviewed: true, ReviewReason: "fixture"},
|
||||
{From: "chat group search", Mode: CommandPathFallbackRewrite, To: "chat search", Reviewed: true, ReviewReason: "fixture"},
|
||||
{From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +other"}, Reviewed: true, ReviewReason: "fixture"},
|
||||
{From: "chat +mixed", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat search"}, Reviewed: true, ReviewReason: "fixture"},
|
||||
}
|
||||
withCommandFallbackSource(t, entries)
|
||||
got, err := ReduceCommandPathFallbacks(root)
|
||||
if err != nil || len(got) != len(entries) {
|
||||
t.Fatalf("ReduceCommandPathFallbacks() = %#v, %v", got, err)
|
||||
}
|
||||
got[2].Candidates[0] = "mutated"
|
||||
again, err := ReduceCommandPathFallbacks(root)
|
||||
if err != nil || again[2].Candidates[0] == "mutated" {
|
||||
t.Fatalf("reduction did not clone candidates: %#v, %v", again, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReduceCommandPathFallbacksRejectsUnsafeMappings(t *testing.T) {
|
||||
baseRoot := commandFallbackTestRoot
|
||||
tests := map[string]struct {
|
||||
entries []CommandPathFallback
|
||||
mutate func(*cobra.Command)
|
||||
want string
|
||||
}{
|
||||
"missing target": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +missing"}},
|
||||
want: "does not exist",
|
||||
},
|
||||
"real source collision": {
|
||||
entries: []CommandPathFallback{{From: "chat +other", Mode: CommandPathFallbackRewrite, To: "chat +good"}},
|
||||
want: "collides with a real Cobra command or alias",
|
||||
},
|
||||
"cobra alias collision": {
|
||||
entries: []CommandPathFallback{{From: "chat +official-alias", Mode: CommandPathFallbackRewrite, To: "chat +other"}},
|
||||
want: "collides with a real Cobra command or alias",
|
||||
},
|
||||
"cross service": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "mail +good"}},
|
||||
want: "crosses service boundary",
|
||||
},
|
||||
"shortcut mismatch": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat search"}},
|
||||
want: "disagree on +shortcut identity",
|
||||
},
|
||||
"chained rewrite": {
|
||||
entries: []CommandPathFallback{
|
||||
{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +next"},
|
||||
{From: "chat +next", Mode: CommandPathFallbackRewrite, To: "chat +good"},
|
||||
},
|
||||
want: "chained fallbacks are forbidden",
|
||||
},
|
||||
"chained ambiguous candidate": {
|
||||
entries: []CommandPathFallback{
|
||||
{From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +next"}},
|
||||
{From: "chat +next", Mode: CommandPathFallbackRewrite, To: "chat +other"},
|
||||
},
|
||||
want: "is another fallback source",
|
||||
},
|
||||
"missing ambiguous candidate": {
|
||||
entries: []CommandPathFallback{{From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +missing"}}},
|
||||
want: "does not exist",
|
||||
},
|
||||
"hidden target": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good"}},
|
||||
mutate: func(root *cobra.Command) {
|
||||
exactSchemaCommand(root, "chat +good").Hidden = true
|
||||
},
|
||||
want: "not a public runnable",
|
||||
},
|
||||
"target is alias": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +official-alias"}},
|
||||
want: "must use canonical Cobra names",
|
||||
},
|
||||
"ambiguous source command": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good"}},
|
||||
mutate: func(root *cobra.Command) {
|
||||
chat := exactSchemaCommand(root, "chat")
|
||||
chat.AddCommand(
|
||||
&cobra.Command{Use: "+bad", Run: func(*cobra.Command, []string) {}},
|
||||
&cobra.Command{Use: "+bad", Run: func(*cobra.Command, []string) {}},
|
||||
)
|
||||
},
|
||||
want: "cannot be resolved safely",
|
||||
},
|
||||
"ambiguous target command": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +duplicate"}},
|
||||
mutate: func(root *cobra.Command) {
|
||||
chat := exactSchemaCommand(root, "chat")
|
||||
chat.AddCommand(
|
||||
&cobra.Command{Use: "+duplicate", Run: func(*cobra.Command, []string) {}},
|
||||
&cobra.Command{Use: "+duplicate", Run: func(*cobra.Command, []string) {}},
|
||||
)
|
||||
},
|
||||
want: "target \"chat +duplicate\" cannot be resolved safely",
|
||||
},
|
||||
"unsupported reduced mode": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackMode("invalid")}},
|
||||
want: "unsupported mode",
|
||||
},
|
||||
}
|
||||
for name, test := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := baseRoot()
|
||||
if test.mutate != nil {
|
||||
test.mutate(root)
|
||||
}
|
||||
for index := range test.entries {
|
||||
test.entries[index].Reviewed = true
|
||||
test.entries[index].ReviewReason = "fixture"
|
||||
}
|
||||
withCommandFallbackSource(t, test.entries)
|
||||
if _, err := ReduceCommandPathFallbacks(root); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("reduction error = %v, want containing %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
withCommandFallbackLoadError(t, errors.New("fixture load"))
|
||||
if _, err := ReduceCommandPathFallbacks(baseRoot()); err == nil || !strings.Contains(err.Error(), "fixture load") {
|
||||
t.Fatalf("load error = %v", err)
|
||||
}
|
||||
if _, err := ReduceCommandPathFallbacks(nil); err == nil || !strings.Contains(err.Error(), "root is nil") {
|
||||
t.Fatalf("nil root error = %v", err)
|
||||
}
|
||||
if got := commandFallbackService(""); got != "" {
|
||||
t.Fatalf("commandFallbackService(empty) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func commandFallbackTestRoot() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
chat := &cobra.Command{Use: "chat"}
|
||||
good := &cobra.Command{Use: "+good", Aliases: []string{"+official-alias"}, Run: func(*cobra.Command, []string) {}}
|
||||
chat.AddCommand(
|
||||
good,
|
||||
&cobra.Command{Use: "+other", Run: func(*cobra.Command, []string) {}},
|
||||
&cobra.Command{Use: "search", Run: func(*cobra.Command, []string) {}},
|
||||
)
|
||||
group := &cobra.Command{Use: "group"}
|
||||
group.AddCommand(cmdutil.HintSubCmd("search", "use chat search"))
|
||||
chat.AddCommand(group)
|
||||
mail := &cobra.Command{Use: "mail"}
|
||||
mail.AddCommand(&cobra.Command{Use: "+good", Run: func(*cobra.Command, []string) {}})
|
||||
root.AddCommand(chat, mail)
|
||||
return root
|
||||
}
|
||||
|
||||
func withCommandFallbackSource(t *testing.T, entries []CommandPathFallback) {
|
||||
t.Helper()
|
||||
old := loadReviewedCommandPathFallbacks
|
||||
loadReviewedCommandPathFallbacks = func() ([]CommandPathFallback, error) {
|
||||
return cloneCommandPathFallbacks(entries), nil
|
||||
}
|
||||
t.Cleanup(func() { loadReviewedCommandPathFallbacks = old })
|
||||
}
|
||||
|
||||
func withCommandFallbackLoadError(t *testing.T, want error) {
|
||||
t.Helper()
|
||||
old := loadReviewedCommandPathFallbacks
|
||||
loadReviewedCommandPathFallbacks = func() ([]CommandPathFallback, error) { return nil, want }
|
||||
t.Cleanup(func() { loadReviewedCommandPathFallbacks = old })
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackGeneratedTableWellFormed(t *testing.T) {
|
||||
entries := loadGeneratedCommandPathFallbacks()
|
||||
if len(entries) == 0 {
|
||||
t.Fatal("generated command path fallback table is empty")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, entry := range entries {
|
||||
if seen[entry.From] || !entry.Reviewed || strings.TrimSpace(entry.ReviewReason) == "" {
|
||||
t.Fatalf("invalid generated entry: %#v", entry)
|
||||
}
|
||||
seen[entry.From] = true
|
||||
}
|
||||
if !reflect.DeepEqual(entries, cloneCommandPathFallbacks(entries)) {
|
||||
t.Fatal("generated table clone changed values")
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -27,7 +27,9 @@
|
||||
// 3. schema_parameter_mapping_ledger.go mapping exclusions / removals (Go)
|
||||
// (active bindings retired; ParamDecl.Property owns delivery)
|
||||
// 4. param_concepts.json + schema reviewed parameter synonym policy
|
||||
// 5. cobra command tree (Go runtime) flags/usage/required (reflected)
|
||||
// 5. command_path_fallbacks.json + schema
|
||||
// reviewed recovery-only invalid paths
|
||||
// 6. cobra command tree (Go runtime) flags/usage/required (reflected)
|
||||
//
|
||||
// schema_hints/, schema_agent_metadata/, schema_command_registry/,
|
||||
// schema_mcp_metadata.json, and schema_mcp_service_review(.json|/ledger) are
|
||||
@@ -35,7 +37,9 @@
|
||||
//
|
||||
// Remaining generated output from this file:
|
||||
// - param_aliases_generated.go per-command parameter normalization
|
||||
// - command_path_fallbacks_generated.go invalid-path recovery normalization
|
||||
|
||||
package cli
|
||||
|
||||
//go:generate go run ../generator/cmd_param_aliases -root ../.. -output param_aliases_generated.go
|
||||
//go:generate go run ../generator/cmd_command_path_fallbacks -root ../.. -output command_path_fallbacks_generated.go
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
|
||||
func TestCrossPlatformCoverageGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
|
||||
entries, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
t.Fatalf("read internal/cli: %v", err)
|
||||
@@ -50,6 +50,7 @@ func TestGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
|
||||
for _, generator := range []string{
|
||||
"cmd_schema_catalog",
|
||||
"cmd_param_aliases",
|
||||
"cmd_command_path_fallbacks",
|
||||
} {
|
||||
if !bytes.Contains(content, []byte("//go:generate go run")) || !bytes.Contains(content, []byte(generator)) {
|
||||
t.Errorf("gen.go does not register %s", generator)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,32 +10,37 @@
|
||||
},
|
||||
|
||||
"concepts": {
|
||||
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +template-search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
|
||||
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "mail thread list", "oa +list-executed"], "risk": "green"},
|
||||
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +create-from-template", "doc +find-doc", "doc +search", "doc +template-search", "doc template search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
|
||||
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "doc +comment-list", "doc +find-doc", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list", "mail thread list", "oa +list-executed"], "risk": "green"},
|
||||
"page_number": {"denotes": "one-based page number", "canonical_hint": "page", "members": ["page", "page-no", "current-page", "page-num"], "excludes": ["cursor", "page-index", "page-size", "page-token"], "commands": ["devdoc article search"], "risk": "green"},
|
||||
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list"], "risk": "green"},
|
||||
"content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc block insert", "doc block update"], "risk": "green"},
|
||||
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list", "doc +comment-list", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list"], "risk": "green"},
|
||||
"content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc +checkpoint-update", "doc +comment-create", "doc +comment-reply", "doc +comment-update", "doc +create", "doc +doc-append", "doc block insert", "doc block update", "doc comment create", "doc comment create-inline", "doc comment reply", "doc comment update", "doc create"], "risk": "green"},
|
||||
"time_start": {"denotes": "start time point with unchanged value format and unit", "canonical_hint": "start", "members": ["start", "start-time", "start-date", "from", "from-date", "begin", "since", "time-min", "min-time"], "excludes": ["date", "time", "end"], "commands": ["calendar event list", "chat message list-all", "report list"], "risk": "yellow"},
|
||||
"time_end": {"denotes": "end time point with unchanged value format and unit", "canonical_hint": "end", "members": ["end", "end-time", "end-date", "time-max", "max-time"], "excludes": ["date", "time", "start"], "commands": ["calendar event list"], "risk": "yellow"},
|
||||
"base_id": {"denotes": "multi-dimensional table Base id", "canonical_hint": "base-id", "members": ["base", "base-id", "base-token"], "excludes": [], "commands": ["aitable +field-get", "aitable +list-tables", "aitable +record-query", "aitable +record-share-url", "aitable +table-get"], "risk": "green"},
|
||||
"dept_id": {"denotes": "single department id", "canonical_hint": "dept", "members": ["dept", "dept-id", "department", "department-id", "parent", "parent-id"], "excludes": ["depts", "dept-ids", "department-ids", "name", "query"], "commands": ["contact +list-sub-depts", "contact dept list-children"], "risk": "yellow"},
|
||||
"dept_ids": {"denotes": "department id list", "canonical_hint": "dept-ids", "members": ["depts", "dept-ids", "department-ids"], "excludes": ["dept", "dept-id", "department-id", "name", "query"], "commands": ["contact +list-dept-members"], "risk": "yellow"},
|
||||
"group_id": {"denotes": "single DingTalk numeric groupId", "canonical_hint": "group-id", "members": ["group-id"], "excludes": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "commands": ["chat group get-by-group-id"], "risk": "yellow"},
|
||||
"open_conversation_id": {"denotes": "single DingTalk openConversationId with unchanged value", "canonical_hint": "conversation-id", "members": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id"], "excludes": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids", "group-name", "name", "id", "source", "target", "src-conversation-id", "dest-conversation-id"], "commands": ["chat +chat-bots", "chat +chat-dismiss", "chat +chat-invite-url", "chat +chat-mute", "chat +chat-role-add", "chat +chat-role-list", "chat +chat-role-query-user", "chat +chat-role-set-user", "chat +chat-role-update", "chat +chat-set-admin", "chat +chat-set-history", "chat +chat-update-alias", "chat +chat-update-nick", "chat +conversation-info", "chat +messages-list-pin", "chat +messages-read-status", "chat category add-conv", "chat category remove-conv", "chat chmod", "chat clear-messages", "chat clear-red-point", "chat conversation-info", "chat group audit-join-validation", "chat group bots", "chat group dismiss", "chat group invite-url", "chat group members", "chat group members add", "chat group members add-bot", "chat group members list-by-ids", "chat group members remove", "chat group members remove-bot", "chat group notice create", "chat group notice edit", "chat group notice get", "chat group notice list", "chat group quit", "chat group rename", "chat group set-admin", "chat group set-history", "chat group transfer-owner", "chat group update-alias", "chat group update-icon", "chat group update-nick", "chat group update-settings", "chat group-mute", "chat group-mute-member", "chat group-role add", "chat group-role list", "chat group-role query-user", "chat group-role remove", "chat group-role remove-user", "chat group-role set-user", "chat group-role update", "chat hide", "chat mark-read", "chat mark-unread", "chat message add-favorite", "chat message download-media", "chat message list", "chat message list-mentions", "chat message list-pin-msg", "chat message list-topic-replies", "chat message read-status", "chat message recall", "chat message recall-by-bot", "chat message remove-favorite", "chat message reply", "chat message search", "chat message send", "chat message send-by-bot", "chat message send-card", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg", "chat mute-at-all", "chat mute-red-envelope", "chat set-top"], "risk": "yellow"},
|
||||
"group_id": {"denotes": "single DingTalk numeric groupId", "canonical_hint": "group-id", "members": ["group-id"], "excludes": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "commands": ["chat +chat-get-by-id", "chat group get-by-group-id"], "risk": "yellow"},
|
||||
"open_conversation_id": {"denotes": "single DingTalk openConversationId with unchanged value", "canonical_hint": "conversation-id", "members": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id"], "excludes": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids", "group-name", "name", "id", "source", "target", "src-conversation-id", "dest-conversation-id"], "commands": ["chat +category-add-conversation", "chat +category-remove-conversation", "chat +chat-add-bot", "chat +chat-audit-join", "chat +chat-bots", "chat +chat-dismiss", "chat +chat-invite-url", "chat +chat-members-get", "chat +chat-mute", "chat +chat-mute-member", "chat +chat-quit", "chat +chat-remove-bot", "chat +chat-role-add", "chat +chat-role-list", "chat +chat-role-query-user", "chat +chat-role-remove", "chat +chat-role-remove-user", "chat +chat-role-set-user", "chat +chat-role-update", "chat +chat-set-admin", "chat +chat-set-history", "chat +chat-transfer-owner", "chat +chat-update-alias", "chat +chat-update-icon", "chat +chat-update-nick", "chat +chat-update-settings", "chat +conversation-clear-messages", "chat +conversation-clear-red-point", "chat +conversation-hide", "chat +conversation-info", "chat +conversation-mark-read", "chat +conversation-mark-unread", "chat +conversation-mute", "chat +flag-cancel", "chat +flag-create", "chat +messages-add-emoji", "chat +messages-add-text-emotion", "chat +messages-list-pin", "chat +messages-read-status", "chat +messages-recall-by-bot", "chat +messages-remove-emoji", "chat +messages-remove-text-emotion", "chat +messages-reply", "chat +messages-resource-download", "chat +messages-resource-url", "chat +messages-send-by-bot", "chat +messages-set-pin", "chat +messages-set-top", "chat +messages-unset-pin", "chat +messages-unset-top", "chat category add-conv", "chat category remove-conv", "chat chmod", "chat clear-messages", "chat clear-red-point", "chat conversation-info", "chat group audit-join-validation", "chat group bots", "chat group dismiss", "chat group invite-url", "chat group members", "chat group members add", "chat group members add-bot", "chat group members list-by-ids", "chat group members remove", "chat group members remove-bot", "chat group notice create", "chat group notice edit", "chat group notice get", "chat group notice list", "chat group quit", "chat group rename", "chat group set-admin", "chat group set-history", "chat group transfer-owner", "chat group update-alias", "chat group update-icon", "chat group update-nick", "chat group update-settings", "chat group-mute", "chat group-mute-member", "chat group-role add", "chat group-role list", "chat group-role query-user", "chat group-role remove", "chat group-role remove-user", "chat group-role set-user", "chat group-role update", "chat hide", "chat mark-read", "chat mark-unread", "chat message add-favorite", "chat message download-media", "chat message list", "chat message list-mentions", "chat message list-pin-msg", "chat message list-topic-replies", "chat message read-status", "chat message recall", "chat message recall-by-bot", "chat message remove-favorite", "chat message reply", "chat message search", "chat message send", "chat message send-by-bot", "chat message send-card", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg", "chat mute-at-all", "chat mute-red-envelope", "chat set-top"], "risk": "yellow"},
|
||||
"open_conversation_ids": {"denotes": "DingTalk openConversationId list with unchanged element values", "canonical_hint": "conversation-ids", "members": ["conversation-ids", "open-conversation-ids", "groups"], "excludes": ["group-id", "group-ids", "conversation-id", "open-conversation-id", "chat-id"], "commands": ["chat message search-advanced"], "risk": "yellow"},
|
||||
"group_name": {"denotes": "group-name search keyword, not a group identifier", "canonical_hint": "group-name", "members": ["group-name"], "excludes": ["group-id", "conversation-id", "open-conversation-id", "chat-id", "id"], "commands": ["chat +group-members", "chat +send-to-group"], "risk": "yellow"},
|
||||
"open_message_id": {"denotes": "single DingTalk openMessageId with unchanged value", "canonical_hint": "open-message-id", "members": ["msg-id", "message-id", "open-message-id"], "excludes": ["msg-ids", "message-ids", "open-message-ids", "ref-msg-id", "src-msg-id", "open-task-id", "topic-id", "resource-id"], "commands": ["chat +messages-read-status", "chat mark-read", "chat message add-emoji", "chat message add-favorite", "chat message add-text-emotion", "chat message download-media", "chat message forward", "chat message read-status", "chat message recall", "chat message remove-emoji", "chat message remove-favorite", "chat message remove-text-emotion", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg"], "risk": "yellow"},
|
||||
"open_message_ids": {"denotes": "DingTalk openMessageId list with unchanged element values", "canonical_hint": "msg-ids", "members": ["msg-ids", "message-ids", "open-message-ids"], "excludes": ["msg-id", "message-id", "open-message-id", "ref-msg-id", "src-msg-id"], "commands": ["chat +messages-mget", "chat message combine-forward", "chat message list-by-ids", "chat message list-emotion-replies"], "risk": "yellow"},
|
||||
"referenced_open_message_id": {"denotes": "referenced DingTalk openMessageId in a reply", "canonical_hint": "ref-msg-id", "members": ["ref-msg-id", "ref-message-id"], "excludes": ["msg-id", "message-id", "open-message-id", "msg-ids", "src-msg-id"], "commands": ["chat message reply"], "risk": "yellow"},
|
||||
"open_message_id": {"denotes": "single DingTalk openMessageId with unchanged value", "canonical_hint": "open-message-id", "members": ["msg-id", "message-id", "open-message-id"], "excludes": ["msg-ids", "message-ids", "open-message-ids", "ref-msg-id", "src-msg-id", "open-task-id", "topic-id", "resource-id"], "commands": ["chat +conversation-mark-read", "chat +flag-cancel", "chat +flag-create", "chat +messages-add-emoji", "chat +messages-add-text-emotion", "chat +messages-forward", "chat +messages-read-status", "chat +messages-remove-emoji", "chat +messages-remove-text-emotion", "chat +messages-resource-download", "chat +messages-set-pin", "chat +messages-set-top", "chat +messages-unset-pin", "chat +messages-unset-top", "chat mark-read", "chat message add-emoji", "chat message add-favorite", "chat message add-text-emotion", "chat message download-media", "chat message forward", "chat message read-status", "chat message recall", "chat message remove-emoji", "chat message remove-favorite", "chat message remove-text-emotion", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg"], "risk": "yellow"},
|
||||
"open_message_ids": {"denotes": "DingTalk openMessageId list with unchanged element values", "canonical_hint": "msg-ids", "members": ["msg-ids", "message-ids", "open-message-ids"], "excludes": ["msg-id", "message-id", "open-message-id", "ref-msg-id", "src-msg-id"], "commands": ["chat +flag-cancel", "chat +flag-create", "chat +messages-combine-forward", "chat +messages-mget", "chat message combine-forward", "chat message list-by-ids", "chat message list-emotion-replies"], "risk": "yellow"},
|
||||
"referenced_open_message_id": {"denotes": "referenced DingTalk openMessageId in a reply", "canonical_hint": "ref-msg-id", "members": ["ref-msg-id", "ref-message-id"], "excludes": ["msg-id", "message-id", "open-message-id", "msg-ids", "src-msg-id"], "commands": ["chat +messages-reply", "chat message reply"], "risk": "yellow"},
|
||||
"user_id": {"denotes": "single user id", "canonical_hint": "user-id", "members": ["user", "user-id", "userid", "uid", "staff-id"], "excludes": ["at-user-ids", "to-user", "users", "user-ids", "name"], "commands": ["chat +chat-role-query-user", "chat +chat-role-set-user", "chat +messages-list-direct", "chat chmod", "chat conversation-info", "chat group transfer-owner", "chat group-role query-user", "chat group-role remove-user", "chat group-role set-user", "chat message list", "chat message send", "contact user profile get"], "risk": "yellow"},
|
||||
"user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"},
|
||||
"open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"},
|
||||
"user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat +messages-batch-send-by-bot", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"},
|
||||
"open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat +chat-members-get", "chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"},
|
||||
"ding_id": {"denotes": "DING id", "canonical_hint": "ding-id", "members": ["ding-id", "open-ding-id"], "excludes": ["id"], "commands": ["ding message receiver-status"], "risk": "yellow"},
|
||||
"folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive list", "mail folder update"], "risk": "green"},
|
||||
"space_id": {"denotes": "drive/wiki space id", "canonical_hint": "space-id", "members": ["space-id", "space", "workspace", "workspace-id"], "excludes": ["folder", "node"], "commands": ["drive info"], "risk": "yellow"},
|
||||
"space_id": {"denotes": "drive/wiki/Doc workspace id with unchanged value", "canonical_hint": "space-id", "members": ["space-id", "space", "workspace", "workspace-id"], "excludes": ["folder", "node"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +copy", "doc +create", "doc +create-from-template", "doc +grant-and-share", "doc +import", "doc +list", "doc +move", "doc create", "doc file create", "doc import", "doc template apply", "drive info"], "risk": "yellow"},
|
||||
"app_id": {"denotes": "application id", "canonical_hint": "unified-app-id", "members": ["app-id", "unified-app-id", "application-id"], "excludes": ["app-key", "app-secret", "agent-id"], "commands": ["dev app get"], "risk": "yellow"},
|
||||
"robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id"], "commands": ["chat group members add-bot", "chat message recall-by-bot", "chat message send-by-bot", "ding message send"], "risk": "yellow"},
|
||||
"open_bot_id": {"denotes": "single DingTalk openBotId with unchanged value", "canonical_hint": "bot-id", "members": ["bot-id", "open-bot-id"], "excludes": ["robot-code", "robot", "robot-id", "bot-code"], "commands": ["chat group members remove-bot"], "risk": "yellow"}
|
||||
"robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id", "bot-id", "open-bot-id", "bot-code"], "commands": ["chat +chat-add-bot", "chat +messages-batch-recall-by-bot", "chat +messages-batch-send-by-bot", "chat +messages-recall-by-bot", "chat +messages-send-by-bot", "chat group members add-bot", "chat message recall-by-bot", "chat message send-by-bot", "ding message send"], "risk": "yellow"},
|
||||
"open_bot_id": {"denotes": "single DingTalk openBotId with unchanged value", "canonical_hint": "bot-id", "members": ["bot-id", "open-bot-id"], "excludes": ["robot-code", "robot", "robot-id", "bot-code"], "commands": ["chat +chat-remove-bot", "chat group members remove-bot"], "risk": "yellow"},
|
||||
"doc_node_id": {"denotes": "single DingTalk document nodeId or accepted document URL/token with unchanged value", "canonical_hint": "node", "members": ["node", "node-id", "doc", "doc-id", "file-id", "document-id", "url"], "excludes": ["id", "folder", "folder-id", "parent-id", "workspace", "workspace-id", "block-id", "comment-id", "comment-key", "job-id", "task-id", "template-id", "version", "revision"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +background-delete", "doc +background-update", "doc +checkpoint-update", "doc +comment-create", "doc +comment-delete", "doc +comment-list", "doc +comment-reply", "doc +comment-update", "doc +copy", "doc +doc-append", "doc +export", "doc +export-submit", "doc +fetch", "doc +history-list", "doc +history-revert", "doc +history-save", "doc +inspect", "doc +move", "doc +review", "doc +version-list", "doc +version-revert", "doc +version-save", "doc block delete", "doc block insert", "doc block list", "doc block update", "doc comment create", "doc comment create-inline", "doc comment delete", "doc comment list", "doc comment reply", "doc comment update", "doc export", "doc info", "doc media download", "doc media insert", "doc media upload", "doc read", "doc style background clear", "doc style background set", "doc style cover clear", "doc style cover set", "doc style get", "doc update", "doc version list", "doc version revert", "doc version save", "doc whiteboard insert"], "risk": "yellow"},
|
||||
"doc_comment_key": {"denotes": "single DingTalk document commentKey with unchanged value", "canonical_hint": "comment-key", "members": ["comment-key", "comment-id"], "excludes": ["id", "node", "node-id", "doc-id", "block-id"], "commands": ["doc +comment-delete", "doc +comment-reply", "doc +comment-update", "doc comment delete", "doc comment reply", "doc comment update"], "risk": "yellow"},
|
||||
"doc_version_number": {"denotes": "single DingTalk document historical version number with unchanged integer value", "canonical_hint": "version", "members": ["version", "version-number", "version-no"], "excludes": ["revision", "id", "node", "node-id", "doc-id"], "commands": ["doc +history-revert", "doc +version-revert", "doc version revert"], "risk": "yellow"},
|
||||
"doc_content_format": {"denotes": "DingTalk document body format with unchanged markdown/jsonml value", "canonical_hint": "content-format", "members": ["content-format", "doc-format"], "excludes": ["format", "export-format", "mime-type"], "commands": ["doc +create", "doc +update", "doc create", "doc update"], "risk": "green"},
|
||||
"doc_edit_revision": {"denotes": "single optimistic-concurrency revision for a document edit", "canonical_hint": "revision", "members": ["revision", "expected-revision"], "excludes": ["version", "version-number", "version-no"], "commands": ["doc +update", "doc update"], "risk": "yellow"}
|
||||
},
|
||||
|
||||
"command_overrides": {
|
||||
@@ -91,10 +96,24 @@
|
||||
"chat group-role set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."},
|
||||
"chat +messages-send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact shortcut."},
|
||||
"chat message send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact command."},
|
||||
"doc block insert": {"block": ["before-block-id"], "note": "requires a two-parameter conversion to --ref-block plus --where before; name-only normalization would silently default to after"},
|
||||
"doc block insert": {"block": ["before-block-id"], "note": "Parent and reference roles remain distinct. --before-block-id needs both --ref-block and --where before, while role-free --block-id cannot choose parent versus reference.", "scoped_aliases": {"parent-block-id": "parent-block", "ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "ambiguous": ["block-id"], "scope_strict": true},
|
||||
"chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain."},
|
||||
"doc +export-get": {"block": ["node"], "note": "node is a document node id, a different entity from job-id"},
|
||||
"doc +export-get": {"block": ["doc-id", "document-id", "file-id", "node", "node-id", "task-id", "url"], "note": "This command queries one export jobId. Document node identifiers and import taskId spellings are different entities and are rejected.", "scoped_aliases": {"export-job-id": "job-id"}, "scope_strict": true},
|
||||
"doc block delete": {"block": ["index"], "note": "index (position) vs node (node id) are different"},
|
||||
"doc +copy": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +list": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +move": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc comment create": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
|
||||
"doc comment reply": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
|
||||
"doc comment update": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
|
||||
"doc +comment-create": {"block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids", "mentioned-open-conversation-id", "mentioned-open-conversation-ids", "open-conversation-id", "open-conversation-ids"], "note": "This shortcut has no group-mention flag or payload field. Group-conversation spellings are blocked instead of inventing unsupported capability; route to the stable doc comment command when group mentions are required."},
|
||||
"doc +comment-reply": {"block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids", "mentioned-open-conversation-id", "mentioned-open-conversation-ids", "open-conversation-id", "open-conversation-ids"], "note": "This shortcut has no group-mention flag or payload field. Group-conversation spellings are blocked instead of inventing unsupported capability; route to the stable doc comment command when group mentions are required."},
|
||||
"doc media insert": {"scoped_aliases": {"ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "block": ["before-block-id", "parent-block", "parent-block-id"], "ambiguous": ["block-id"], "scope_strict": true, "note": "Media insertion supports a reference block but no parent-block role. --before-block-id additionally needs --where before; role-free --block-id is left ambiguous."},
|
||||
"doc read": {"block": ["before-block-id", "parent-block-id", "ref-block-id", "reference-block-id"], "ambiguous": ["block-id"], "note": "A section read requires --scope section plus a start/end boundary. A role-free --block-id cannot be reduced to one flag without inventing the missing scope/boundary role."},
|
||||
"doc export get": {"scoped_aliases": {"export-job-id": "job-id"}, "block": ["doc-id", "document-id", "file-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one export jobId. Document node identifiers are rejected; native hidden --task-id remains the command's reviewed add-only compatibility alias for --job-id."},
|
||||
"doc import get": {"scoped_aliases": {"import-task-id": "task-id"}, "block": ["doc-id", "document-id", "file-id", "job-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one import taskId. Document node identifiers and export jobId spellings are different entities and are rejected."},
|
||||
"doc +share-doc": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a document nodeId into a URL, so identifier spellings are rejected with guidance to provide --url."},
|
||||
"doc update": {"block": ["version", "version-no", "version-number"], "note": "--revision is an optimistic-concurrency revision, not a historical document version number. Version spellings must not reduce to --revision."},
|
||||
"report outbox list": {"block": ["template-type"], "note": "type vs name are different fields"},
|
||||
"chat group members add-bot": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat group members list-by-ids": {"bind": {"id": "open_conversation_id", "users": "open_dingtalk_ids"}, "block": ["user-id", "user-ids"], "note": "This command's --id carries openConversationId, while --users carries an openDingTalkId list."},
|
||||
@@ -111,7 +130,48 @@
|
||||
"chat category create-smart": {"bind": {"members": "open_dingtalk_ids"}, "scoped_aliases": {"title": "name"}, "note": "The real --members is an openDingTalkId list; the reviewed title/name alias is exact to the category display name."},
|
||||
"chat group audit-join-validation": {"ambiguous": ["user", "user-id", "userid", "uid", "staff-id"], "note": "A role-free user identifier cannot choose between the required --applicant and --inviter roles."},
|
||||
"chat message reply": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The required --ref-sender is a role-specific openDingTalkId and must not accept generic userId spellings."},
|
||||
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."}
|
||||
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."},
|
||||
"chat +category-add-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
|
||||
"chat +category-list-conversations": {"block": ["category-ids"], "note": "The real --category-id is singular; list cardinality is not reduced automatically."},
|
||||
"chat +category-remove-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
|
||||
"chat +chat-add-bot": {"bind": {"id": "open_conversation_id"}, "note": "The real --id is the group's openConversationId; robotCode and openBotId remain different domains."},
|
||||
"chat +chat-audit-join": {"scoped_aliases": {"applicant-user-id": "applicant", "inviter-user-id": "inviter"}, "ambiguous": ["user", "user-id", "userid", "uid", "staff-id"], "scope_strict": true, "note": "A role-free user identifier cannot choose between applicant and inviter."},
|
||||
"chat +chat-create": {"block": ["user-id", "open-dingtalk-id"], "note": "The real --users field is a list and may contain mixed identifier domains; a singular value is not promoted."},
|
||||
"chat +chat-get-by-id": {"block": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "note": "The real --group-id is numeric groupId; no CID or group-name spelling can be value-preservingly converted."},
|
||||
"chat +chat-members-get": {"bind": {"id": "open_conversation_id", "users": "open_dingtalk_ids"}, "block": ["group", "group-name", "user-id", "user-ids"], "note": "The real --id is openConversationId and --users is an openDingTalkId list. The observed --group spelling carried a natural group name and is blocked; explicit CID spellings and --chat remain value-preserving aliases."},
|
||||
"chat +chat-members-list": {"scoped_aliases": {"chat-id": "conversation-id", "id": "conversation-id"}, "block": ["query", "keyword", "group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "scope_strict": true, "note": "Native --chat/--open-conversation-id stay native; member filtering by query is unsupported and group-name resolution stays on --group/--chat-query."},
|
||||
"chat +chat-mute-member": {"scoped_aliases": {"user-ids": "users", "open-dingtalk-ids": "users"}, "block": ["user", "user-id", "open-dingtalk-id"], "scope_strict": true, "note": "The target accepts a mixed identifier list; list spellings preserve values, but singular inputs are not promoted."},
|
||||
"chat +chat-remove-bot": {"bind": {"id": "open_conversation_id"}, "note": "The real --id is openConversationId; --bot-id is separately governed by open_bot_id."},
|
||||
"chat +chat-role-remove": {"block": ["role-ids"], "note": "The command removes one role ID; list cardinality is not reduced."},
|
||||
"chat +chat-role-remove-user": {"scoped_aliases": {"user-id": "user", "open-dingtalk-id": "user"}, "block": ["role-id"], "scope_strict": true, "note": "The single --user accepts either identifier domain; --role-ids remains a list."},
|
||||
"chat +chat-transfer-owner": {"scoped_aliases": {"user-id": "new-owner", "open-dingtalk-id": "new-owner"}, "scope_strict": true, "note": "The only user role is the new owner, and the target accepts either userId or openDingTalkId without changing the value."},
|
||||
"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 +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."},
|
||||
"chat +messages-forward-topic": {"scoped_aliases": {"src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-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": "src-msg-id", "source-message-id": "src-msg-id"}, "block": ["group-id", "group-ids", "msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "Source and destination conversation roles and the source-message role remain explicit; role-free message IDs and list cardinality are not inferred."},
|
||||
"chat +messages-list": {"scoped_aliases": {"start": "time"}, "block": ["before", "before-time", "end", "direction", "page-all", "count", "max", "max-results", "max-size", "page-size"], "scope_strict": true, "note": "start preserves the same boundary value; before/direction require multi-parameter or value transforms and page-all requires iteration. Native --conversation-id/--id/--size remain native."},
|
||||
"chat +messages-recall-by-bot": {"block": ["msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "note": "--keys carries processQueryKey values, not openMessageId values."},
|
||||
"chat +messages-reply": {"scoped_aliases": {"msg-id": "ref-msg-id", "open-message-id": "ref-msg-id"}, "block": ["group", "msg-ids", "message-ids", "open-message-ids"], "scope_strict": true, "note": "The observed --group spelling carried a natural group name and is blocked. The only message role is the referenced message; plural IDs are not accepted, while --chat remains a CID alias and native --message-id stays native."},
|
||||
"chat +messages-resource-download": {"block": ["download-dir"], "note": "--output may be a file or directory under workspace safety rules; a download directory cannot be assumed equivalent."},
|
||||
"doc +create": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["content-file", "parent-id"], "scope_strict": true, "note": "--content-format is value-preservingly normalized to --doc-format. A raw --content-file path cannot become --content without adding the required @file transform, so it is blocked with guidance to use @relative-path or stable doc create."},
|
||||
"doc +create-from-template": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +import": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +update": {"scoped_aliases": {"mode": "command", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "url": "node"}, "block": ["content-file"], "scope_strict": true, "note": "--mode append/overwrite is the same operation selector subset as --command and preserves its value. --content-file is blocked because +update requires @relative-path or stdin and central aliases cannot read/transform a file value."},
|
||||
"doc +inspect": {"scoped_aliases": {"include-versions": "include-history"}, "block": ["include", "include-info"], "scope_strict": true, "note": "Historical versions and history are the same optional section on this exact shortcut. Generic --include needs value-dependent flag expansion, while base document info is always returned, so those spellings are rejected with precise guidance."},
|
||||
"doc +fetch": {"scoped_aliases": {"start-block": "start-block-id", "end-block": "end-block-id"}, "ambiguous": ["block-id"], "scope_strict": true, "note": "Start/end block roles are preserved. A role-free --block-id cannot choose a range/section boundary and must stop before execution."},
|
||||
"doc +media-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose between document and media identities."},
|
||||
"doc +media-insert": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and local-media roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."},
|
||||
"doc +media-list": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "The command lists media inside one document, so only strong document spellings map to --node. File and URL spellings remain role-ambiguous."},
|
||||
"doc +media-preview": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."},
|
||||
"doc +resource-delete": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command removes a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."},
|
||||
"doc +resource-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command downloads a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."},
|
||||
"doc +resource-update": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has document-node, local-file, and HTTPS image URL roles. Only strong document spellings map to --node; --file-id and --url must stop as ambiguous."},
|
||||
"doc +share": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a node identifier into a URL, so identifier spellings are rejected with guidance to provide --url."},
|
||||
"doc +grant-and-share": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node"}, "scope_strict": true, "note": "This workflow has two different real URL roles: --node selects the document for access control and --url is the shareable link sent to recipients. Explicit document-ID spellings map only to --node; --url remains native."}
|
||||
},
|
||||
|
||||
"validation_fixture": {
|
||||
@@ -258,8 +318,117 @@
|
||||
{"command": "chat +unread-chats", "emitted": "size", "expect": "count", "via": "override:scoped"},
|
||||
{"command": "chat category rename", "emitted": "name", "expect": "title", "via": "override:scoped"},
|
||||
{"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"},
|
||||
{"command": "doc +comment-create", "emitted": "node-id", "expect": "node", "via": "concept:doc_node_id"},
|
||||
{"command": "doc +doc-append", "emitted": "node", "expect": "doc", "via": "concept:doc_node_id"},
|
||||
{"command": "doc +find-doc", "emitted": "keyword", "expect": "query", "via": "concept:search_query"},
|
||||
{"command": "doc +search", "emitted": "q", "expect": "query", "via": "concept:search_query"},
|
||||
{"command": "doc +comment-list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size"},
|
||||
{"command": "doc +list", "emitted": "page-token", "expect": "cursor", "via": "concept:page_cursor"},
|
||||
{"command": "doc +copy", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"},
|
||||
{"command": "doc +copy", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
|
||||
{"command": "doc +copy", "emitted": "parent-id", "expect": "did-you-mean:blocked", "via": "guard:doc-folder-value-domain"},
|
||||
{"command": "doc +comment-reply", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
|
||||
{"command": "doc comment reply", "emitted": "mentioned-open-conversation-ids", "expect": "mentioned-open-conversation-id", "via": "override:scoped-role-list"},
|
||||
{"command": "doc comment reply", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:numeric-group-id-vs-open-conversation-id"},
|
||||
{"command": "doc +comment-create", "emitted": "mentioned-open-conversation-id", "expect": "did-you-mean:blocked", "via": "guard:shortcut-missing-capability"},
|
||||
{"command": "doc block insert", "emitted": "parent-block-id", "expect": "parent-block", "via": "override:scoped-block-role"},
|
||||
{"command": "doc block insert", "emitted": "block-id", "expect": "did-you-mean:ambiguous", "via": "guard:parent-vs-reference-block-role"},
|
||||
{"command": "doc media insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-ref-block-plus-where"},
|
||||
{"command": "doc read", "emitted": "block-id", "expect": "did-you-mean:ambiguous", "via": "guard:requires-scope-and-boundary-role"},
|
||||
{"command": "doc export get", "emitted": "node", "expect": "did-you-mean:blocked", "via": "guard:document-node-vs-export-job"},
|
||||
{"command": "doc import get", "emitted": "job-id", "expect": "did-you-mean:blocked", "via": "guard:export-job-vs-import-task"},
|
||||
{"command": "doc +version-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "doc +version-revert", "emitted": "revision", "expect": "did-you-mean:blocked", "via": "concept:doc_version_number+exclude"},
|
||||
{"command": "doc update", "emitted": "version", "expect": "did-you-mean:blocked", "via": "guard:historical-version-vs-edit-revision"},
|
||||
{"command": "doc +share-doc", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"},
|
||||
{"command": "doc +comment-create", "emitted": "body", "expect": "content", "via": "concept:content_text"},
|
||||
{"command": "doc +doc-append", "emitted": "content", "expect": "text", "via": "concept:content_text"},
|
||||
{"command": "doc +export-submit", "emitted": "doc-id", "expect": "node", "via": "concept:doc_node_id"},
|
||||
{"command": "doc +move", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
|
||||
{"command": "doc +template-list", "emitted": "next-token", "expect": "cursor", "via": "concept:page_cursor"},
|
||||
{"command": "doc +version-list", "emitted": "page-size", "expect": "limit", "via": "concept:pagination_size"},
|
||||
{"command": "doc +version-save", "emitted": "file-id", "expect": "node", "via": "concept:doc_node_id"},
|
||||
{"command": "doc comment create", "emitted": "text", "expect": "content", "via": "concept:content_text"},
|
||||
{"command": "doc comment create-inline", "emitted": "body", "expect": "content", "via": "concept:content_text"},
|
||||
{"command": "doc comment delete", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
|
||||
{"command": "doc comment update", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
|
||||
{"command": "doc version revert", "emitted": "version-no", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "chat +chat-messages", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"},
|
||||
{"command": "chat +search-msg", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"}
|
||||
{"command": "chat +search-msg", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"},
|
||||
{"command": "chat +chat-update", "emitted": "chat-id", "expect": "group", "via": "override:scoped-explicit-cid"},
|
||||
{"command": "chat +chat-update", "emitted": "conversation-id", "expect": "group", "via": "override:scoped-explicit-cid"},
|
||||
{"command": "chat +chat-update", "emitted": "open-conversation-id", "expect": "group", "via": "override:scoped-explicit-cid"},
|
||||
{"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": "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"},
|
||||
{"command": "chat +chat-members-list", "emitted": "chat-id", "expect": "conversation-id", "via": "override:scoped-explicit-cid"},
|
||||
{"command": "chat +chat-members-list", "emitted": "id", "expect": "conversation-id", "via": "override:scoped-explicit-cid"},
|
||||
{"command": "chat +chat-members-list", "emitted": "query", "expect": "did-you-mean:blocked", "via": "guard:unsupported-member-filter"},
|
||||
{"command": "chat +conversation-set-top", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "override:scoped-explicit-cid"},
|
||||
{"command": "chat +conversation-set-top", "emitted": "chat-ids", "expect": "conversation-ids", "via": "override:scoped-explicit-cid-list"},
|
||||
{"command": "chat +conversation-set-top", "emitted": "groups", "expect": "did-you-mean:blocked", "via": "guard:group-name-or-list-ambiguity"},
|
||||
{"command": "chat +conversation-set-top", "emitted": "top", "expect": "did-you-mean:blocked", "via": "guard:inverse-boolean-semantics"},
|
||||
{"command": "chat +chat-members-get", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
|
||||
{"command": "chat +chat-members-get", "emitted": "open-dingtalk-ids", "expect": "users", "via": "concept:open_dingtalk_ids+bind"},
|
||||
{"command": "chat +chat-members-get", "emitted": "group", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"},
|
||||
{"command": "chat +chat-members-get", "emitted": "chat", "expect": "id", "via": "concept:open_conversation_id+bind"},
|
||||
{"command": "chat +chat-get-by-id", "emitted": "group", "expect": "did-you-mean:blocked", "via": "guard:open-conversation-id-vs-numeric-group-id"},
|
||||
{"command": "chat +messages-list", "emitted": "start", "expect": "time", "via": "override:scoped-time-boundary"},
|
||||
{"command": "chat +messages-list", "emitted": "count", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
{"command": "chat +messages-list", "emitted": "max-results", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
{"command": "chat +messages-list", "emitted": "page-all", "expect": "did-you-mean:blocked", "via": "guard:requires-pagination-loop"},
|
||||
{"command": "chat +messages-reply", "emitted": "msg-id", "expect": "ref-msg-id", "via": "override:scoped-reference-message"},
|
||||
{"command": "chat +messages-reply", "emitted": "group", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"},
|
||||
{"command": "chat +messages-reply", "emitted": "chat", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat +flag-cancel", "emitted": "group", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat +flag-cancel", "emitted": "chat", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat +flag-create", "emitted": "group", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat +chat-add-bot", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
|
||||
{"command": "chat +chat-add-bot", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code"},
|
||||
{"command": "chat +chat-audit-join", "emitted": "applicant-user-id", "expect": "applicant", "via": "override:scoped-user-role"},
|
||||
{"command": "chat +chat-audit-join", "emitted": "user-id", "expect": "did-you-mean:ambiguous", "via": "guard:applicant-vs-inviter-role"},
|
||||
{"command": "chat +chat-create", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
|
||||
{"command": "chat +chat-mute-member", "emitted": "user-ids", "expect": "users", "via": "override:scoped-mixed-id-list"},
|
||||
{"command": "chat +chat-mute-member", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
|
||||
{"command": "chat +chat-remove-bot", "emitted": "open-bot-id", "expect": "bot-id", "via": "concept:open_bot_id"},
|
||||
{"command": "chat +chat-role-remove", "emitted": "role-ids", "expect": "did-you-mean:blocked", "via": "guard:list-vs-single"},
|
||||
{"command": "chat +chat-role-remove-user", "emitted": "open-dingtalk-id", "expect": "user", "via": "override:scoped-mixed-id"},
|
||||
{"command": "chat +chat-transfer-owner", "emitted": "user-id", "expect": "new-owner", "via": "override:scoped-owner-role"},
|
||||
{"command": "chat +feed-group-query-item", "emitted": "chat-ids", "expect": "conversation-ids", "via": "override:scoped-explicit-cid-list"},
|
||||
{"command": "chat +feed-group-query-item", "emitted": "conversation-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
|
||||
{"command": "chat +messages-batch-recall-by-bot", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:process-query-key-vs-open-message-id"},
|
||||
{"command": "chat +messages-combine-forward", "emitted": "src-open-cid", "expect": "src-conversation-id", "via": "override:scoped-source-role"},
|
||||
{"command": "chat +messages-combine-forward", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-destination-role"},
|
||||
{"command": "chat +messages-forward", "emitted": "source-message-id", "expect": "msg-id", "via": "override:scoped-source-role"},
|
||||
{"command": "chat +messages-forward", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-destination-role"},
|
||||
{"command": "chat +messages-forward-topic", "emitted": "src-open-message-id", "expect": "src-msg-id", "via": "override:scoped-source-role"},
|
||||
{"command": "chat +messages-forward-topic", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:message-source-role"},
|
||||
{"command": "chat +messages-recall-by-bot", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:process-query-key-vs-open-message-id"},
|
||||
{"command": "chat +messages-resource-download", "emitted": "conversation-id", "expect": "open-conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat +messages-resource-download", "emitted": "download-dir", "expect": "did-you-mean:blocked", "via": "guard:output-file-or-directory-contract"},
|
||||
{"command": "chat +messages-set-pin", "emitted": "conversation-id", "expect": "open-conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "doc +create", "emitted": "content-format", "expect": "doc-format", "via": "concept:doc_content_format"},
|
||||
{"command": "doc +create", "emitted": "content-file", "expect": "did-you-mean:blocked", "via": "guard:requires-file-read-transform"},
|
||||
{"command": "doc +inspect", "emitted": "include-versions", "expect": "include-history", "via": "override:scoped-section"},
|
||||
{"command": "doc +inspect", "emitted": "include", "expect": "did-you-mean:blocked", "via": "guard:requires-value-dependent-flag-expansion"},
|
||||
{"command": "doc +inspect", "emitted": "include-info", "expect": "did-you-mean:blocked", "via": "guard:base-info-always-returned"},
|
||||
{"command": "doc +update", "emitted": "mode", "expect": "command", "via": "override:scoped-operation"},
|
||||
{"command": "doc +update", "emitted": "revision", "expect": "expected-revision", "via": "concept:doc_edit_revision"},
|
||||
{"command": "doc +update", "emitted": "version", "expect": "did-you-mean:blocked", "via": "guard:historical-version-vs-edit-revision"},
|
||||
{"command": "doc +fetch", "emitted": "start-block", "expect": "start-block-id", "via": "override:scoped-boundary-role"},
|
||||
{"command": "doc +fetch", "emitted": "block-id", "expect": "did-you-mean:ambiguous", "via": "guard:start-vs-end-boundary-role"},
|
||||
{"command": "doc +access-grant", "emitted": "doc-id", "expect": "node", "via": "concept:doc_node_id"},
|
||||
{"command": "doc +history-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "doc +create-from-template", "emitted": "keyword", "expect": "query", "via": "concept:search_query"},
|
||||
{"command": "doc +create-from-template", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"},
|
||||
{"command": "doc +create-from-template", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
|
||||
{"command": "doc +media-download", "emitted": "file-id", "expect": "did-you-mean:ambiguous", "via": "guard:document-node-vs-attachment-resource-role"},
|
||||
{"command": "doc +resource-update", "emitted": "url", "expect": "did-you-mean:ambiguous", "via": "guard:document-url-vs-image-url-role"},
|
||||
{"command": "doc +share", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Command cmd_command_path_fallbacks validates the reviewed recovery-only
|
||||
// command path table against the live distribution Cobra tree and writes the
|
||||
// deterministic runtime lookup source. It does not add paths to Cobra, Help,
|
||||
// Skill, Schema, or the stable CommandRegistry identity surface.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/generator/outputguard"
|
||||
)
|
||||
|
||||
const defaultOutput = "internal/cli/command_path_fallbacks_generated.go"
|
||||
|
||||
var (
|
||||
newCommandFallbackRoot = app.NewSchemaSourceRootCommand
|
||||
reduceCommandFallbackEntries = cli.ReduceCommandPathFallbacks
|
||||
formatCommandFallbackSource = format.Source
|
||||
writeCommandFallbackFile = os.WriteFile
|
||||
exitCommandFallbackProcess = os.Exit
|
||||
)
|
||||
|
||||
func main() {
|
||||
var rootPath string
|
||||
var outputPath string
|
||||
flag.StringVar(&rootPath, "root", ".", "Repository root used to protect generator inputs")
|
||||
flag.StringVar(&outputPath, "output", defaultOutput, "Output generated command-path fallback table")
|
||||
flag.Parse()
|
||||
|
||||
if err := generateCommandPathFallbacks(rootPath, outputPath); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
}
|
||||
|
||||
func generateCommandPathFallbacks(rootPath, outputPath string) error {
|
||||
if err := validateOutputIsolation(rootPath, outputPath); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := reduceCommandFallbackEntries(newCommandFallbackRoot())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
source, err := renderCommandPathFallbacks(entries)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeCommandFallbackFile(outputPath, source, 0o644); err != nil {
|
||||
return fmt.Errorf("write generated command path fallbacks: %w", err)
|
||||
}
|
||||
_, _ = fmt.Fprintf(os.Stderr, "generated command path fallbacks: output=%s entries=%d\n", outputPath, len(entries))
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOutputIsolation(rootPath, outputPath string) error {
|
||||
inputs := []outputguard.Input{
|
||||
{Name: "reviewed command path fallbacks", Path: "internal/cli/command_path_fallbacks.json"},
|
||||
{Name: "reviewed command path fallback schema", Path: "internal/cli/command_path_fallbacks.schema.json"},
|
||||
}
|
||||
target := outputguard.Target{Name: "--output", Path: outputPath}
|
||||
if err := outputguard.Validate(rootPath, inputs, []outputguard.Target{target}); err != nil {
|
||||
return err
|
||||
}
|
||||
return outputguard.ValidateRepoTargetAllowlist(rootPath, target, defaultOutput)
|
||||
}
|
||||
|
||||
func renderCommandPathFallbacks(entries []cli.CommandPathFallback) ([]byte, error) {
|
||||
var b bytes.Buffer
|
||||
b.WriteString("// Copyright 2026 Alibaba Group\n")
|
||||
b.WriteString("// Licensed under the Apache License, Version 2.0\n\n")
|
||||
b.WriteString("// Code generated by cmd_command_path_fallbacks; DO NOT EDIT.\n")
|
||||
b.WriteString("// Source: internal/cli/command_path_fallbacks.json validated against the live Cobra tree.\n")
|
||||
b.WriteString("// Regenerate with `make generate-schema` (or `go generate ./internal/cli`).\n\n")
|
||||
b.WriteString("package cli\n\n")
|
||||
b.WriteString("var generatedCommandPathFallbacks = []CommandPathFallback{\n")
|
||||
for _, entry := range entries {
|
||||
b.WriteString("\t{\n")
|
||||
fmt.Fprintf(&b, "\t\tFrom: %q,\n", entry.From)
|
||||
fmt.Fprintf(&b, "\t\tMode: %q,\n", entry.Mode)
|
||||
if entry.To != "" {
|
||||
fmt.Fprintf(&b, "\t\tTo: %q,\n", entry.To)
|
||||
}
|
||||
if len(entry.Candidates) > 0 {
|
||||
fmt.Fprintf(&b, "\t\tCandidates: %s,\n", renderStringSlice(entry.Candidates))
|
||||
}
|
||||
b.WriteString("\t\tReviewed: true,\n")
|
||||
fmt.Fprintf(&b, "\t\tReviewReason: %q,\n", entry.ReviewReason)
|
||||
b.WriteString("\t},\n")
|
||||
}
|
||||
b.WriteString("}\n\n")
|
||||
b.WriteString("func loadGeneratedCommandPathFallbacks() []CommandPathFallback {\n")
|
||||
b.WriteString("\treturn generatedCommandPathFallbacks\n")
|
||||
b.WriteString("}\n")
|
||||
|
||||
formatted, err := formatCommandFallbackSource(b.Bytes())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("format generated command path fallbacks: %w", err)
|
||||
}
|
||||
return formatted, nil
|
||||
}
|
||||
|
||||
func renderStringSlice(values []string) string {
|
||||
quoted := make([]string, len(values))
|
||||
for index, value := range values {
|
||||
quoted[index] = fmt.Sprintf("%q", value)
|
||||
}
|
||||
return "[]string{" + strings.Join(quoted, ", ") + "}"
|
||||
}
|
||||
|
||||
func fail(err error) {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
exitCommandFallbackProcess(1)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"go/format"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageCommandFallbackGeneratorUsesDistributionOwnedCommandTree(t *testing.T) {
|
||||
got := reflect.ValueOf(newCommandFallbackRoot).Pointer()
|
||||
want := reflect.ValueOf(app.NewSchemaSourceRootCommand).Pointer()
|
||||
if got != want {
|
||||
t.Fatal("command fallback generator must use the distribution-owned Schema source command tree")
|
||||
}
|
||||
}
|
||||
|
||||
func preserveCommandFallbackGeneratorGlobals(t *testing.T) {
|
||||
t.Helper()
|
||||
oldArgs, oldFlags := os.Args, flag.CommandLine
|
||||
oldRoot := newCommandFallbackRoot
|
||||
oldReduce := reduceCommandFallbackEntries
|
||||
oldFormat := formatCommandFallbackSource
|
||||
oldWrite := writeCommandFallbackFile
|
||||
oldExit := exitCommandFallbackProcess
|
||||
t.Cleanup(func() {
|
||||
os.Args, flag.CommandLine = oldArgs, oldFlags
|
||||
newCommandFallbackRoot = oldRoot
|
||||
reduceCommandFallbackEntries = oldReduce
|
||||
formatCommandFallbackSource = oldFormat
|
||||
writeCommandFallbackFile = oldWrite
|
||||
exitCommandFallbackProcess = oldExit
|
||||
})
|
||||
}
|
||||
|
||||
func commandFallbackRepositoryRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
root, err := filepath.Abs(filepath.Join("..", "..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandFallbackMainWritesGeneratedFile(t *testing.T) {
|
||||
preserveCommandFallbackGeneratorGlobals(t)
|
||||
output := filepath.Join(t.TempDir(), "command_path_fallbacks_generated.go")
|
||||
flag.CommandLine = flag.NewFlagSet("command-fallback-success", flag.ContinueOnError)
|
||||
os.Args = []string{"cmd_command_path_fallbacks", "-root", commandFallbackRepositoryRoot(t), "-output", output}
|
||||
newCommandFallbackRoot = func(...context.Context) *cobra.Command { return &cobra.Command{Use: "dws"} }
|
||||
reduceCommandFallbackEntries = func(*cobra.Command) ([]cli.CommandPathFallback, error) {
|
||||
return []cli.CommandPathFallback{{
|
||||
From: "demo +bad",
|
||||
Mode: cli.CommandPathFallbackRewrite,
|
||||
To: "demo +good",
|
||||
Reviewed: true,
|
||||
ReviewReason: "fixture",
|
||||
}}, nil
|
||||
}
|
||||
|
||||
main()
|
||||
data, err := os.ReadFile(output)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(data)
|
||||
for _, want := range []string{
|
||||
"func loadGeneratedCommandPathFallbacks() []CommandPathFallback",
|
||||
`From: "demo +bad"`,
|
||||
`To: "demo +good"`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("generated source missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageGenerateCommandPathFallbackFailurePaths(t *testing.T) {
|
||||
preserveCommandFallbackGeneratorGlobals(t)
|
||||
root := commandFallbackRepositoryRoot(t)
|
||||
output := filepath.Join(t.TempDir(), "command_path_fallbacks_generated.go")
|
||||
newCommandFallbackRoot = func(...context.Context) *cobra.Command { return &cobra.Command{Use: "dws"} }
|
||||
|
||||
if err := generateCommandPathFallbacks(root, filepath.Join(root, "internal", "cli", "command_path_fallbacks.json")); err == nil {
|
||||
t.Fatal("generator accepted output overlapping reviewed input")
|
||||
}
|
||||
reduceCommandFallbackEntries = func(*cobra.Command) ([]cli.CommandPathFallback, error) {
|
||||
return nil, errors.New("reduce")
|
||||
}
|
||||
if err := generateCommandPathFallbacks(root, output); err == nil || !strings.Contains(err.Error(), "reduce") {
|
||||
t.Fatalf("reduction error = %v", err)
|
||||
}
|
||||
reduceCommandFallbackEntries = func(*cobra.Command) ([]cli.CommandPathFallback, error) { return nil, nil }
|
||||
formatCommandFallbackSource = func([]byte) ([]byte, error) { return nil, errors.New("format") }
|
||||
if err := generateCommandPathFallbacks(root, output); err == nil || !strings.Contains(err.Error(), "format generated") {
|
||||
t.Fatalf("format error = %v", err)
|
||||
}
|
||||
formatCommandFallbackSource = format.Source
|
||||
writeCommandFallbackFile = func(string, []byte, os.FileMode) error { return errors.New("write") }
|
||||
if err := generateCommandPathFallbacks(root, output); err == nil || !strings.Contains(err.Error(), "write generated") {
|
||||
t.Fatalf("write error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateCommandFallbackOutputIsolation(t *testing.T) {
|
||||
root := commandFallbackRepositoryRoot(t)
|
||||
if err := validateOutputIsolation(root, filepath.Join(t.TempDir(), "fallbacks.go")); err != nil {
|
||||
t.Fatalf("temporary output rejected: %v", err)
|
||||
}
|
||||
if err := validateOutputIsolation(root, filepath.Join(root, "internal", "cli", "not_a_delivery_target.go")); err == nil ||
|
||||
!strings.Contains(err.Error(), "not a canonical generated delivery target") {
|
||||
t.Fatalf("non-canonical repository output error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRenderCommandPathFallbacksShape(t *testing.T) {
|
||||
preserveCommandFallbackGeneratorGlobals(t)
|
||||
entries := []cli.CommandPathFallback{
|
||||
{
|
||||
From: "demo +bad",
|
||||
Mode: cli.CommandPathFallbackRewrite,
|
||||
To: "demo +good",
|
||||
Reviewed: true,
|
||||
ReviewReason: "rewrite fixture",
|
||||
},
|
||||
{
|
||||
From: "demo +choose",
|
||||
Mode: cli.CommandPathFallbackAmbiguous,
|
||||
Candidates: []string{"demo +one", "demo +two"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "ambiguous fixture",
|
||||
},
|
||||
}
|
||||
data, err := renderCommandPathFallbacks(entries)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(data)
|
||||
for _, want := range []string{
|
||||
`Mode: "rewrite"`,
|
||||
`Candidates: []string{"demo +one", "demo +two"}`,
|
||||
`ReviewReason: "ambiguous fixture"`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("render missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if got := renderStringSlice(nil); got != "[]string{}" {
|
||||
t.Fatalf("renderStringSlice(nil) = %q", got)
|
||||
}
|
||||
formatCommandFallbackSource = func([]byte) ([]byte, error) { return nil, errors.New("broken formatter") }
|
||||
if _, err := renderCommandPathFallbacks(entries); err == nil || !strings.Contains(err.Error(), "broken formatter") {
|
||||
t.Fatalf("formatter error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandFallbackMainReportsFailure(t *testing.T) {
|
||||
preserveCommandFallbackGeneratorGlobals(t)
|
||||
flag.CommandLine = flag.NewFlagSet("command-fallback-failure", flag.ContinueOnError)
|
||||
os.Args = []string{
|
||||
"cmd_command_path_fallbacks",
|
||||
"-root", commandFallbackRepositoryRoot(t),
|
||||
"-output", filepath.Join(commandFallbackRepositoryRoot(t), "internal", "cli", "command_path_fallbacks.json"),
|
||||
}
|
||||
exitCommandFallbackProcess = func(code int) { panic(code) }
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != 1 {
|
||||
t.Fatalf("main exit = %#v, want 1", recovered)
|
||||
}
|
||||
}()
|
||||
main()
|
||||
}
|
||||
+179
-94
@@ -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 获取 (必填)")
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
+1152
-41
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -370,6 +370,131 @@ func TestCrossPlatformCoverageUploadConversationLocalFileCoverage(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatBotRichMediaCoverage(t *testing.T) {
|
||||
testseam.Protect(t, &deps)
|
||||
testseam.Swap(t, &httpPutFile, func(context.Context, string, map[string]string, string, int64) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
filePath := filepath.Join(t.TempDir(), "report.pdf")
|
||||
if err := os.WriteFile(filePath, []byte("report"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, response := range []string{`{`, `{"result":{}}`} {
|
||||
if _, err := parseConversationFileDownloadURL(response); err == nil {
|
||||
t.Fatalf("parseConversationFileDownloadURL(%q) returned nil", response)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "markdown requires title", args: []string{"--text", "message"}},
|
||||
{name: "markdown requires text", args: []string{"--title", "title"}},
|
||||
{name: "image requires URL", args: []string{"--msg-type", "image"}},
|
||||
{name: "file requires path", args: []string{"--msg-type", "file"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
args := []string{"message", "send-by-bot", "--robot-code", "robot", "--group", "group"}
|
||||
if err := runChatCoverageCommand(t, &scriptedToolCaller{}, append(args, tc.args...)...); err == nil {
|
||||
t.Fatal("missing rich-media input returned nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("group and direct targets are mutually exclusive", func(t *testing.T) {
|
||||
err := runChatCoverageCommand(t, &scriptedToolCaller{},
|
||||
"message", "send-by-bot", "--robot-code", "robot",
|
||||
"--group", "group", "--users", "user", "--title", "title", "--text", "message",
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("mutually exclusive target error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("direct markdown defaults to DX message type", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
if err := runChatCoverageCommand(t, caller,
|
||||
"message", "send-by-bot", "--robot-code", "robot", "--users", "user",
|
||||
"--title", "title", "--text", "message",
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.tool != "batch_send_robot_msg_to_users" || caller.args["msgType"] != "sampleMarkdownDX" {
|
||||
t.Fatalf("direct markdown call = %s %#v", caller.tool, caller.args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("direct image", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
if err := runChatCoverageCommand(t, caller,
|
||||
"message", "send-by-bot", "--robot-code", "robot", "--users", "user",
|
||||
"--msg-type", "image", "--image-url", "https://example.test/image.png", "--at-all",
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.tool != "batch_send_robot_msg_to_users" || caller.args["msgType"] != "sampleImageMsg" || caller.args["isAtAll"] != "true" {
|
||||
t.Fatalf("direct image call = %s %#v", caller.tool, caller.args)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
target []string
|
||||
}{
|
||||
{name: "user file", target: []string{"--users", "user"}},
|
||||
{name: "open DingTalk ID file", target: []string{"--open-dingtalk-ids", "D-user"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://upload.example/file","uploadKey":"key"}`},
|
||||
{text: `{"result":{"downloadUrl":"https://download.example/report.pdf"}}`},
|
||||
{text: `{"success":true}`},
|
||||
}}
|
||||
args := []string{"message", "send-by-bot", "--robot-code", "robot", "--msg-type", "file", "--file-path", filePath}
|
||||
if err := runChatCoverageCommand(t, caller, append(args, tc.target...)...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 3 || caller.tool != "batch_send_robot_msg_to_users" || caller.args["msgType"] != "sampleDingtalkDriveFile" || caller.args["fileUrl"] != "https://download.example/report.pdf" {
|
||||
t.Fatalf("direct file call = %d %s %#v", caller.calls, caller.tool, caller.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("file dry run", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{dry: true}
|
||||
if err := runChatCoverageCommand(t, caller,
|
||||
"message", "send-by-bot", "--robot-code", "robot", "--group", "group",
|
||||
"--msg-type", "file", "--file-path", filePath,
|
||||
); err != nil || caller.calls != 0 {
|
||||
t.Fatalf("dry run error = %v, calls = %d", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file rejects multiple recipients", func(t *testing.T) {
|
||||
err := runChatCoverageCommand(t, &scriptedToolCaller{},
|
||||
"message", "send-by-bot", "--robot-code", "robot", "--users", "one,two",
|
||||
"--msg-type", "file", "--file-path", filePath,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires exactly one") {
|
||||
t.Fatalf("multiple-recipient error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file upload failure", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("upload failed")}}}
|
||||
err := runChatCoverageCommand(t, caller,
|
||||
"message", "send-by-bot", "--robot-code", "robot", "--group", "group",
|
||||
"--msg-type", "file", "--file-path", filePath,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "upload failed") {
|
||||
t.Fatalf("upload error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageGuardGroupOwnerRemovalCoverage(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +16,7 @@ package helpers
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -70,6 +71,7 @@ func (devHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(root)
|
||||
|
||||
doc := &cobra.Command{
|
||||
Use: "doc",
|
||||
@@ -81,6 +83,7 @@ func (devHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(doc)
|
||||
doc.AddCommand(newDevDocSearchCommand())
|
||||
|
||||
root.AddCommand(
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -108,6 +109,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(root)
|
||||
|
||||
webapp := &cobra.Command{
|
||||
Use: "webapp",
|
||||
@@ -119,6 +121,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(webapp)
|
||||
webapp.AddCommand(
|
||||
newDevAppWebappGetCommand(runner),
|
||||
newDevAppWebappConfigCommand(runner),
|
||||
@@ -134,6 +137,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(permission)
|
||||
permission.AddCommand(
|
||||
newDevAppPermissionListCommand(runner),
|
||||
newDevAppPermissionAddCommand(runner),
|
||||
@@ -150,6 +154,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(credentials)
|
||||
credentials.AddCommand(newDevAppCredentialsGetCommand(runner))
|
||||
|
||||
member := &cobra.Command{
|
||||
@@ -162,6 +167,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(member)
|
||||
member.AddCommand(
|
||||
newDevAppMemberListCommand(runner),
|
||||
newDevAppMemberAddCommand(runner),
|
||||
@@ -178,6 +184,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(security)
|
||||
security.AddCommand(newDevAppSecurityConfigCommand(runner))
|
||||
|
||||
robot := &cobra.Command{
|
||||
@@ -190,6 +197,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(robot)
|
||||
robot.AddCommand(
|
||||
newDevAppRobotSubmitCommand(runner),
|
||||
newDevAppRobotResultCommand(runner),
|
||||
@@ -209,6 +217,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(version)
|
||||
version.AddCommand(
|
||||
newDevAppVersionCreateCommand(runner),
|
||||
newDevAppVersionListCommand(runner),
|
||||
@@ -228,6 +237,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(event)
|
||||
event.AddCommand(
|
||||
newDevAppEventListCommand(runner),
|
||||
newDevAppEventSubscribeCommand(runner),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -423,6 +423,8 @@ CLI 层自动翻译为 MCP toolName + 参数名,无需记忆 MCP 参数名。
|
||||
range fill / range copy-to / add-dimension / delete-dimension / move-dimension
|
||||
group-dimension / ungroup-dimension
|
||||
set-dropdown / delete-dropdown / csv-put / delete-float-image
|
||||
其中 csv-put 与独立命令语义一致:CSV 字段值以 = 开头时按公式解析,
|
||||
前加单引号(例如 "'=1+1")时写入以 = 开头的字面文本。
|
||||
|
||||
注意:batch-update 中 group-dimension 适合默认展开分组;需要 --group-state fold 时请使用独立
|
||||
dws sheet group-dimension 命令。
|
||||
@@ -437,7 +439,8 @@ dws sheet group-dimension 命令。
|
||||
dws sheet batch-update --node NODE_ID --operations '[
|
||||
{"toolName":"range clear","input":{"sheet-id":"Sheet1","range":"A1:B3","type":"content"}},
|
||||
{"toolName":"range update","input":{"sheet-id":"Sheet1","range":"A1","values":[[{"type":"text","text":"hello"}]]}},
|
||||
{"toolName":"merge-cells","input":{"sheet-id":"Sheet1","range":"A1:B1","merge-type":"mergeAll"}}
|
||||
{"toolName":"merge-cells","input":{"sheet-id":"Sheet1","range":"A1:B1","merge-type":"mergeAll"}},
|
||||
{"toolName":"csv-put","input":{"sheet-id":"Sheet1","start-cell":"C1","csv":"=1+1,\u0027=1+1"}}
|
||||
]' --yes
|
||||
|
||||
# 宽松模式
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type csvPutRecordingCaller struct {
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
func (c *csvPutRecordingCaller) CallTool(_ context.Context, _ string, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.tool = tool
|
||||
c.args = args
|
||||
return &edition.ToolResult{}, nil
|
||||
}
|
||||
|
||||
func (*csvPutRecordingCaller) Format() string { return "json" }
|
||||
func (*csvPutRecordingCaller) DryRun() bool { return false }
|
||||
func (*csvPutRecordingCaller) Fields() string { return "" }
|
||||
func (*csvPutRecordingCaller) JQ() string { return "" }
|
||||
|
||||
func TestCSVPutFormulaContractAndPassThrough(t *testing.T) {
|
||||
const formulaCSV = "=1+1,'=1+1"
|
||||
|
||||
root := &cobra.Command{Use: "sheet"}
|
||||
root.AddCommand(newDataCmds()...)
|
||||
csvPut := findCoverageSubcommand(t, root, "csv-put")
|
||||
for _, want := range []string{"支持公式", "以 = 开头时默认按公式解析", "以 = 开头的字面文本", "'=1+1"} {
|
||||
if !strings.Contains(csvPut.Short+"\n"+csvPut.Long+"\n"+csvPut.Example, want) {
|
||||
t.Fatalf("csv-put help does not contain %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(csvPut.Long, "不支持公式") || strings.Contains(csvPut.Long, "=开头当文本") {
|
||||
t.Fatalf("csv-put help still advertises the old formula contract: %s", csvPut.Long)
|
||||
}
|
||||
if strings.Contains(csvPut.Long, "写入公式文本") {
|
||||
t.Fatalf("csv-put help incorrectly describes apostrophe escaping as formula text: %s", csvPut.Long)
|
||||
}
|
||||
|
||||
caller := &csvPutRecordingCaller{}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "sheet", "csv-put"})
|
||||
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{
|
||||
"csv-put",
|
||||
"--node", "node",
|
||||
"--sheet-id", "Sheet1",
|
||||
"--start-cell", "A1",
|
||||
"--csv", formulaCSV,
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute csv-put: %v", err)
|
||||
}
|
||||
if caller.tool != "set_range_from_csv" {
|
||||
t.Fatalf("tool = %q, want set_range_from_csv", caller.tool)
|
||||
}
|
||||
if got := caller.args["csv"]; got != formulaCSV {
|
||||
t.Fatalf("csv argument = %#v, want exact pass-through %q", got, formulaCSV)
|
||||
}
|
||||
if _, ok := caller.args["interpretFormulas"]; ok {
|
||||
t.Fatalf("csv-put unexpectedly added an interpretFormulas argument: %#v", caller.args)
|
||||
}
|
||||
|
||||
batchArgs := BuildCsvPutArgs(map[string]any{
|
||||
"sheet-id": "Sheet1",
|
||||
"start-cell": "A1",
|
||||
"csv": formulaCSV,
|
||||
})
|
||||
if got := batchArgs["csv"]; got != formulaCSV {
|
||||
t.Fatalf("batch csv argument = %#v, want exact pass-through %q", got, formulaCSV)
|
||||
}
|
||||
if _, ok := batchArgs["interpretFormulas"]; ok {
|
||||
t.Fatalf("batch csv-put unexpectedly added an interpretFormulas argument: %#v", batchArgs)
|
||||
}
|
||||
|
||||
batchHelp := newBatchUpdateCmd().Long
|
||||
for _, want := range []string{"csv-put", "以 = 开头时按公式解析", "以 = 开头的字面文本", "'=1+1"} {
|
||||
if !strings.Contains(batchHelp, want) {
|
||||
t.Fatalf("batch-update help does not contain %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(batchHelp, "写入公式文本") {
|
||||
t.Fatalf("batch-update help incorrectly describes apostrophe escaping as formula text: %s", batchHelp)
|
||||
}
|
||||
}
|
||||
@@ -281,16 +281,17 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
|
||||
csvPutCmd := &cobra.Command{
|
||||
Use: "csv-put",
|
||||
Short: "将 CSV 数据写入表格指定位置(纯值,自动扩容)",
|
||||
Short: "将 CSV 数据写入表格指定位置(支持公式,自动扩容)",
|
||||
Long: `将 RFC 4180 格式的 CSV 文本写入指定工作表的指定单元格位置。
|
||||
只写纯值,不支持公式/样式/批注。=开头当文本处理。自动扩容行列。
|
||||
写入值和公式,不支持样式/批注。字段值以 = 开头时默认按公式解析;
|
||||
如需写入以 = 开头的字面文本,在字段值前加单引号(例如 "'=1+1")。自动扩容行列。
|
||||
目标区域如含合并单元格,csv-put 会打散合并并覆盖所有单元格;这不同于
|
||||
range update 与合并区域冲突时返回 MERGED_CELLS_CONFLICT 的行为。
|
||||
|
||||
--csv 支持三种输入:直接传文本、@filepath 从文件读取、- 从 stdin 读取。
|
||||
--allow-overwrite 默认 false,目标区域有数据时需显式传 --allow-overwrite 才能覆盖。`,
|
||||
Example: ` dws sheet csv-put --node NODE_ID --sheet-id SHEET_ID --start-cell A1 \
|
||||
--csv 'name,score\nalice,95\nbob,87'
|
||||
--csv "=1+1,'=1+1"
|
||||
|
||||
dws sheet csv-put --node NODE_ID --sheet-id SHEET_ID --start-cell B2 \
|
||||
--csv @data.csv --allow-overwrite
|
||||
@@ -347,17 +348,20 @@ range update 与合并区域冲突时返回 MERGED_CELLS_CONFLICT 的行为。
|
||||
CLIPath: "sheet csv-put",
|
||||
PrimaryCLIPath: "sheet csv-put",
|
||||
},
|
||||
Description: "将 CSV 纯值写入起始单元格(可自动扩容;大批量纯值首选)。",
|
||||
Description: "将 CSV 值或公式写入起始单元格(= 开头按公式;可自动扩容)。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "set_range_from_csv"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "将 CSV 纯值写入起始单元格(可自动扩容;大批量纯值首选)。",
|
||||
UseWhen: []string{"写入纯值且超过约 5 行/20 格,或数据来自 CSV/表格文本时优先使用"},
|
||||
AvoidWhen: []string{"需要公式/超链接/富文本用 range update;在末尾追加数据行用 append;覆盖已有数据需 --allow-overwrite"},
|
||||
Examples: []string{"dws sheet csv-put --node <NODE_ID> --sheet-id <SHEET_ID> --start-cell A1 --csv @data.csv --allow-overwrite"},
|
||||
AgentSummary: "将 CSV 值或公式写入起始单元格(= 开头按公式;可自动扩容)。",
|
||||
UseWhen: []string{"需要从 CSV/表格文本批量写入值或以 = 开头的公式时优先使用"},
|
||||
AvoidWhen: []string{"需要超链接/富文本/样式/数据验证用 range update;在末尾追加数据行用 append;覆盖已有数据需 --allow-overwrite;要把 = 开头内容写成字面文本需前加单引号"},
|
||||
Examples: []string{
|
||||
"dws sheet csv-put --node <NODE_ID> --sheet-id <SHEET_ID> --start-cell A1 --csv @data.csv --allow-overwrite",
|
||||
"dws sheet csv-put --node <NODE_ID> --sheet-id <SHEET_ID> --start-cell A1 --csv \"=1+1,'=1+1\"",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId"},
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const sheetFormulaVerifyRemoteTool = "verify_formula"
|
||||
|
||||
func newSheetFormulaVerifyCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "formula-verify",
|
||||
@@ -95,9 +97,9 @@ func newSheetFormulaVerifyCmd() *cobra.Command {
|
||||
// 返回 payload 判定是否存在公式错误,故走 ReturnText 再自行 PrintJSON。
|
||||
func callMCPToolFormulaVerify(toolArgs map[string]any, exitOnError bool) error {
|
||||
if deps.Caller.DryRun() {
|
||||
return callMCPToolOnServer("sheet", "formula_verify", toolArgs)
|
||||
return callMCPToolOnServer("sheet", sheetFormulaVerifyRemoteTool, toolArgs)
|
||||
}
|
||||
text, err := callMCPToolReturnTextOnServer(context.Background(), "sheet", "formula_verify", toolArgs)
|
||||
text, err := callMCPToolReturnTextOnServer(context.Background(), "sheet", sheetFormulaVerifyRemoteTool, toolArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -110,7 +112,7 @@ func callMCPToolFormulaVerify(toolArgs map[string]any, exitOnError bool) error {
|
||||
return nil
|
||||
}
|
||||
if parsed == nil {
|
||||
return fmt.Errorf("formula_verify returned empty result")
|
||||
return fmt.Errorf("%s returned empty result", sheetFormulaVerifyRemoteTool)
|
||||
}
|
||||
if err := deps.Out.PrintJSON(parsed); err != nil {
|
||||
return err
|
||||
|
||||
@@ -42,6 +42,12 @@ func TestCrossPlatformCoverageSheetFormulaVerifyLimitsAndInlineTargets(t *testin
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("calls = %d, want 1", caller.calls)
|
||||
}
|
||||
if caller.server != "sheet" {
|
||||
t.Fatalf("server = %q, want sheet", caller.server)
|
||||
}
|
||||
if caller.tool != "verify_formula" {
|
||||
t.Fatalf("tool = %q, want verify_formula", caller.tool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSheetFormulaVerifyTargetsFromFile(t *testing.T) {
|
||||
|
||||
@@ -168,7 +168,7 @@ dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 merg
|
||||
- 写图片到单元格建议直接用 dws sheet write-image(更简洁)
|
||||
- 只设样式或批量刷整片区域样式请用 dws sheet range set-style;写值同时设置少量 cell 样式可用 cellStyles
|
||||
- 目标范围与已有合并区域冲突时,range update 会返回 MERGED_CELLS_CONFLICT;先用 sheet info 查看 mergedRanges,取消合并后写入,必要时再重新合并
|
||||
- csv-put 的合并处理不同:目标区域含合并单元格时会打散合并并写入纯值
|
||||
- csv-put 的合并处理不同:目标区域含合并单元格时会打散合并并写入 CSV 值或公式
|
||||
- 清空整片区域请用 dws sheet range clear`,
|
||||
Example: ` # 写入文本
|
||||
dws sheet range update --node NODE_ID --sheet-id SHEET_ID --range "A1:B2" \
|
||||
@@ -252,16 +252,16 @@ dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 merg
|
||||
CLIPath: "sheet range update",
|
||||
PrimaryCLIPath: "sheet range update",
|
||||
},
|
||||
Description: "更新指定区域单元格(值/公式/超链接等 object 协议);少量或含公式时使用。",
|
||||
Description: "更新指定区域单元格(值/公式/超链接等 object 协议);少量或需富格式时使用。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "update_range"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "更新指定区域单元格(值/公式/超链接等 object 协议);少量或含公式时使用。",
|
||||
UseWhen: []string{"需要写入少量单元格、公式或超链接,且能提供 sheetId 与精确 range 时"},
|
||||
AvoidWhen: []string{"大批量纯值写入优先 sheet csv-put;末尾追加行用 append;全局替换文本用 replace;只改样式用 range set-style;清整片区域用 range clear"},
|
||||
AgentSummary: "更新指定区域单元格(值/公式/超链接等 object 协议);少量或需富格式时使用。",
|
||||
UseWhen: []string{"需要写入少量单元格、公式对象、超链接或富格式,且能提供 sheetId 与精确 range 时"},
|
||||
AvoidWhen: []string{"CSV 形式的大批量值或公式写入优先 sheet csv-put;末尾追加行用 append;全局替换文本用 replace;只改样式用 range set-style;清整片区域用 range clear"},
|
||||
Examples: []string{"dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range \"A1\" --values '[[{\"type\":\"text\",\"text\":\"张三\"}]]'"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
|
||||
+61
-23
@@ -32,9 +32,10 @@ import (
|
||||
// SetArgs so that Cobra's subsequent ExecuteC uses the corrected
|
||||
// values.
|
||||
//
|
||||
// If the target command cannot be resolved (e.g. the user typed a
|
||||
// non-existent command), PreParse is skipped silently and Cobra will
|
||||
// handle the error.
|
||||
// Explicit +shortcut tokens and annotated group subcommands are validated
|
||||
// before flag parsing so an unknown command cannot be misreported as an
|
||||
// unknown flag on its nearest parent. Other unresolved paths remain Cobra's
|
||||
// responsibility.
|
||||
func RunPreParse(root *cobra.Command, engine *Engine) error {
|
||||
_, err := RunPreParseArgs(root, engine, os.Args[1:])
|
||||
return err
|
||||
@@ -45,13 +46,17 @@ func RunPreParse(root *cobra.Command, engine *Engine) error {
|
||||
// exact same command traversal, FlagInfo extraction, handler chain, and
|
||||
// root.SetArgs delivery path.
|
||||
func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Context, error) {
|
||||
if engine == nil || !engine.HasHandlers(PreParse) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if len(rawArgs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
normalizedArgs, commandCorrection, fallbackErr := normalizeCommandPathFallback(root, engine, rawArgs)
|
||||
if fallbackErr != nil {
|
||||
ctx := &Context{Args: append([]string{}, rawArgs...)}
|
||||
if presentationErr := primeEarlyErrorPresentation(root, root, rawArgs); presentationErr != nil {
|
||||
slog.Debug("pipeline command-fallback presentation flags", "error", presentationErr)
|
||||
}
|
||||
return ctx, fallbackErr
|
||||
}
|
||||
|
||||
// Cobra's Traverse does not merge root persistent flags before deciding
|
||||
// whether a leading flag consumes the next token. For example, it can treat
|
||||
@@ -59,37 +64,52 @@ func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Co
|
||||
// the unrelated root command `event list`. Remove only known root-persistent
|
||||
// flags from the traversal copy; the original argv remains intact for the
|
||||
// handlers and Cobra's real parse.
|
||||
target, _, err := root.Traverse(argsForCommandTraversal(root, rawArgs))
|
||||
target, remaining, err := root.Traverse(argsForCommandTraversal(root, normalizedArgs))
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
if err := validateUnresolvedCommand(target, remaining); err != nil {
|
||||
ctx := &Context{
|
||||
Args: append([]string{}, normalizedArgs...),
|
||||
Command: target.CommandPath(),
|
||||
}
|
||||
if presentationErr := primeEarlyErrorPresentation(root, target, ctx.Args); presentationErr != nil {
|
||||
slog.Debug("pipeline command-resolution presentation flags", "error", presentationErr)
|
||||
}
|
||||
return ctx, err
|
||||
}
|
||||
|
||||
// Build FlagInfo from the target command's registered flags.
|
||||
flagInfos := FlagInfoFromCommand(target)
|
||||
if len(flagInfos) == 0 {
|
||||
if (engine == nil || !engine.HasHandlers(PreParse) || len(flagInfos) == 0) && commandCorrection == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx := &Context{
|
||||
Args: append([]string{}, rawArgs...),
|
||||
Args: append([]string{}, normalizedArgs...),
|
||||
// target.CommandPath() still carries the "dws" prefix; PreParse
|
||||
// handlers that key off a command (e.g. the semantic-alias table)
|
||||
// normalize it themselves, so the runtime key matches the build key.
|
||||
Command: target.CommandPath(),
|
||||
FlagSpecs: flagInfos,
|
||||
}
|
||||
if commandCorrection != nil {
|
||||
ctx.Corrections = append(ctx.Corrections, *commandCorrection)
|
||||
}
|
||||
|
||||
if err := engine.RunPhase(PreParse, ctx); err != nil {
|
||||
// Cobra has not parsed persistent flags yet, but this error is rendered
|
||||
// immediately by app.Execute. Prime only the presentation controls so
|
||||
// --format/--debug/--verbose affect this early error exactly as they do
|
||||
// errors returned after Cobra parsing. No credentials, profiles, output
|
||||
// paths, or execution controls are applied here.
|
||||
if presentationErr := primeEarlyErrorPresentation(root, target, ctx.Args); presentationErr != nil {
|
||||
slog.Debug("pipeline pre-parse presentation flags", "error", presentationErr)
|
||||
if engine != nil && engine.HasHandlers(PreParse) && len(flagInfos) > 0 {
|
||||
if err := engine.RunPhase(PreParse, ctx); err != nil {
|
||||
// Cobra has not parsed persistent flags yet, but this error is rendered
|
||||
// immediately by app.Execute. Prime only the presentation controls so
|
||||
// --format/--debug/--verbose affect this early error exactly as they do
|
||||
// errors returned after Cobra parsing. No credentials, profiles, output
|
||||
// paths, or execution controls are applied here.
|
||||
if presentationErr := primeEarlyErrorPresentation(root, target, ctx.Args); presentationErr != nil {
|
||||
slog.Debug("pipeline pre-parse presentation flags", "error", presentationErr)
|
||||
}
|
||||
slog.Debug("pipeline pre-parse", "error", err)
|
||||
return ctx, err
|
||||
}
|
||||
slog.Debug("pipeline pre-parse", "error", err)
|
||||
return ctx, err
|
||||
}
|
||||
|
||||
// Only set corrected args if PreParse actually changed something.
|
||||
@@ -109,25 +129,43 @@ func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Co
|
||||
}
|
||||
|
||||
func argsForCommandTraversal(root *cobra.Command, rawArgs []string) []string {
|
||||
args, _ := argsForCommandTraversalWithPositions(root, rawArgs)
|
||||
return args
|
||||
}
|
||||
|
||||
func argsForCommandTraversalWithPositions(root *cobra.Command, rawArgs []string) ([]string, []int) {
|
||||
if root == nil || len(rawArgs) == 0 {
|
||||
return rawArgs
|
||||
positions := make([]int, len(rawArgs))
|
||||
for index := range positions {
|
||||
positions[index] = index
|
||||
}
|
||||
return rawArgs, positions
|
||||
}
|
||||
flags := root.PersistentFlags()
|
||||
if flags == nil || !flags.HasFlags() {
|
||||
return rawArgs
|
||||
positions := make([]int, len(rawArgs))
|
||||
for index := range positions {
|
||||
positions[index] = index
|
||||
}
|
||||
return rawArgs, positions
|
||||
}
|
||||
|
||||
matcher := newFlagTokenMatcher(flags)
|
||||
out := make([]string, 0, len(rawArgs))
|
||||
positions := make([]int, 0, len(rawArgs))
|
||||
for index := 0; index < len(rawArgs); index++ {
|
||||
argument := rawArgs[index]
|
||||
if argument == "--" {
|
||||
out = append(out, rawArgs[index:]...)
|
||||
for position := index; position < len(rawArgs); position++ {
|
||||
positions = append(positions, position)
|
||||
}
|
||||
break
|
||||
}
|
||||
flag, inlineValue, matched := matcher.matchTraversalToken(argument)
|
||||
if !matched {
|
||||
out = append(out, argument)
|
||||
positions = append(positions, index)
|
||||
continue
|
||||
}
|
||||
if index+1 < len(rawArgs) {
|
||||
@@ -140,7 +178,7 @@ func argsForCommandTraversal(root *cobra.Command, rawArgs []string) []string {
|
||||
index++
|
||||
}
|
||||
}
|
||||
return out
|
||||
return out, positions
|
||||
}
|
||||
|
||||
// separatedBoolValue recognises model-friendly `--flag false` and exact
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
commandPathFallbackModeRewrite = "rewrite"
|
||||
commandPathFallbackModeAmbiguous = "ambiguous"
|
||||
)
|
||||
|
||||
// normalizeCommandPathFallback applies only an exact reviewed path match. It
|
||||
// runs before Cobra resolves a leaf, then the canonical argv continues through
|
||||
// the ordinary parameter alias, validation, safety, and execution pipeline.
|
||||
func normalizeCommandPathFallback(root *cobra.Command, engine *Engine, rawArgs []string) ([]string, *Correction, error) {
|
||||
if root == nil || engine == nil || engine.commandPathFallbackLookup == nil || len(rawArgs) == 0 {
|
||||
return rawArgs, nil, nil
|
||||
}
|
||||
traversalArgs, positions := argsForCommandTraversalWithPositions(root, rawArgs)
|
||||
entry, tokenCount, ok := longestCommandPathFallback(engine, traversalArgs)
|
||||
if !ok {
|
||||
return rawArgs, nil, nil
|
||||
}
|
||||
// Runtime extensions are mounted on the root before PreParse runs. An exact
|
||||
// executable command (or exact Cobra alias) is user/runtime authority and
|
||||
// must win over the reviewed recovery dictionary. Hint-only compatibility
|
||||
// nodes are not executable business commands, so fallback may supersede
|
||||
// them as it does for the distribution-owned declaration tree.
|
||||
if exactRunnableCommandPath(root, traversalArgs[:tokenCount]) {
|
||||
return rawArgs, nil, nil
|
||||
}
|
||||
|
||||
switch entry.Mode {
|
||||
case commandPathFallbackModeRewrite:
|
||||
if strings.TrimSpace(entry.To) == "" {
|
||||
return rawArgs, nil, apperrors.NewInternal(fmt.Sprintf("reviewed command path fallback %q has no rewrite target", entry.From))
|
||||
}
|
||||
rewritten := rewriteCommandPathTokens(rawArgs, positions[:tokenCount], strings.Fields(entry.To))
|
||||
correction := &Correction{
|
||||
Handler: "command-path-fallback",
|
||||
Phase: PreParse,
|
||||
Field: "command_path",
|
||||
Original: entry.From,
|
||||
Corrected: entry.To,
|
||||
Kind: "reviewed-fallback",
|
||||
}
|
||||
return rewritten, correction, nil
|
||||
case commandPathFallbackModeAmbiguous:
|
||||
return rawArgs, nil, ambiguousCommandPathFallbackError(root, entry)
|
||||
default:
|
||||
return rawArgs, nil, apperrors.NewInternal(fmt.Sprintf("reviewed command path fallback %q has invalid mode %q", entry.From, entry.Mode))
|
||||
}
|
||||
}
|
||||
|
||||
func exactRunnableCommandPath(root *cobra.Command, path []string) bool {
|
||||
if root == nil || len(path) == 0 {
|
||||
return false
|
||||
}
|
||||
current := root
|
||||
if path[0] == root.Name() {
|
||||
path = path[1:]
|
||||
}
|
||||
if len(path) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, token := range path {
|
||||
var next *cobra.Command
|
||||
for _, child := range current.Commands() {
|
||||
if child.Name() == token || containsExactCommandAlias(child.Aliases, token) {
|
||||
next = child
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
return false
|
||||
}
|
||||
current = next
|
||||
}
|
||||
return current.Runnable() && !cmdutil.IsHintOnlyCommand(current)
|
||||
}
|
||||
|
||||
func containsExactCommandAlias(aliases []string, token string) bool {
|
||||
for _, alias := range aliases {
|
||||
if alias == token {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func longestCommandPathFallback(engine *Engine, traversalArgs []string) (CommandPathFallback, int, bool) {
|
||||
var selected CommandPathFallback
|
||||
selectedCount := 0
|
||||
parts := make([]string, 0, len(traversalArgs))
|
||||
for index, argument := range traversalArgs {
|
||||
argument = strings.TrimSpace(argument)
|
||||
if argument == "" || argument == "--" || strings.HasPrefix(argument, "-") {
|
||||
break
|
||||
}
|
||||
parts = append(parts, argument)
|
||||
if candidate, ok := engine.lookupCommandPathFallback(strings.Join(parts, " ")); ok {
|
||||
selected = candidate
|
||||
selectedCount = index + 1
|
||||
}
|
||||
}
|
||||
return selected, selectedCount, selectedCount > 0
|
||||
}
|
||||
|
||||
func rewriteCommandPathTokens(rawArgs []string, commandPositions []int, canonicalTokens []string) []string {
|
||||
if len(commandPositions) == 0 || len(canonicalTokens) == 0 {
|
||||
return append([]string(nil), rawArgs...)
|
||||
}
|
||||
remove := make(map[int]bool, len(commandPositions))
|
||||
for _, position := range commandPositions {
|
||||
remove[position] = true
|
||||
}
|
||||
insertAt := commandPositions[0]
|
||||
out := make([]string, 0, len(rawArgs)-len(commandPositions)+len(canonicalTokens))
|
||||
for index, argument := range rawArgs {
|
||||
if index == insertAt {
|
||||
out = append(out, canonicalTokens...)
|
||||
}
|
||||
if remove[index] {
|
||||
continue
|
||||
}
|
||||
out = append(out, argument)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ambiguousCommandPathFallbackError(root *cobra.Command, entry CommandPathFallback) error {
|
||||
rootName := "dws"
|
||||
if root != nil && strings.TrimSpace(root.Name()) != "" {
|
||||
rootName = root.Name()
|
||||
}
|
||||
paths := make([]string, 0, len(entry.Candidates))
|
||||
actions := make([]string, 0, len(entry.Candidates))
|
||||
for _, candidate := range entry.Candidates {
|
||||
path := strings.TrimSpace(rootName + " " + candidate)
|
||||
paths = append(paths, fmt.Sprintf("%q", path))
|
||||
actions = append(actions, fmt.Sprintf("Run '%s --help' to inspect this candidate", path))
|
||||
}
|
||||
hint := "Inspect the reviewed candidates and choose by the original user intent: " + strings.Join(paths, ", ")
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("command path %q is ambiguous", strings.TrimSpace(rootName+" "+entry.From)),
|
||||
apperrors.WithReason("ambiguous_command_fallback"),
|
||||
apperrors.WithHint(hint),
|
||||
apperrors.WithActions(actions...),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRunPreParseArgsRewritesReviewedCommandBeforeFlags(t *testing.T) {
|
||||
root, executed := commandFallbackPipelineRoot()
|
||||
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
|
||||
"chat +bad": {From: "chat +bad", Mode: "rewrite", To: "chat +good"},
|
||||
})
|
||||
raw := []string{"--format", "json", "chat", "+bad", "--query", "project"}
|
||||
ctx, err := RunPreParseArgs(root, engine, raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantArgs := []string{"--format", "json", "chat", "+good", "--query", "project"}
|
||||
if ctx == nil || !reflect.DeepEqual(ctx.Args, wantArgs) || ctx.Command != "dws chat +good" {
|
||||
t.Fatalf("context = %#v, want args=%v command=dws chat +good", ctx, wantArgs)
|
||||
}
|
||||
if len(ctx.Corrections) != 1 || ctx.Corrections[0].Handler != "command-path-fallback" ||
|
||||
ctx.Corrections[0].Original != "chat +bad" || ctx.Corrections[0].Corrected != "chat +good" {
|
||||
t.Fatalf("corrections = %#v", ctx.Corrections)
|
||||
}
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *executed != "dws chat +good:project" {
|
||||
t.Fatalf("executed = %q", *executed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPreParseArgsRewritesMultiTokenPathAroundPersistentFlags(t *testing.T) {
|
||||
root, executed := commandFallbackPipelineRoot()
|
||||
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
|
||||
"chat group search": {From: "chat group search", Mode: "rewrite", To: "chat search"},
|
||||
})
|
||||
raw := []string{"chat", "--format", "json", "group", "search", "--query", "project"}
|
||||
ctx, err := RunPreParseArgs(root, engine, raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantArgs := []string{"chat", "search", "--format", "json", "--query", "project"}
|
||||
if ctx == nil || !reflect.DeepEqual(ctx.Args, wantArgs) {
|
||||
t.Fatalf("rewritten args = %#v, want %v", ctx, wantArgs)
|
||||
}
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *executed != "dws chat search:project" {
|
||||
t.Fatalf("executed = %q", *executed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackDefersToExactRunnableCommandAndAlias(t *testing.T) {
|
||||
if exactRunnableCommandPath(nil, []string{"dws"}) {
|
||||
t.Fatal("nil root unexpectedly resolved an executable path")
|
||||
}
|
||||
emptyRoot := &cobra.Command{Use: "dws"}
|
||||
if exactRunnableCommandPath(emptyRoot, []string{"dws"}) {
|
||||
t.Fatal("root-only path unexpectedly resolved as an executable leaf")
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{name: "primary", path: "+members"},
|
||||
{name: "alias", path: "+runtime-members"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
executed := ""
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
chat := &cobra.Command{Use: "chat"}
|
||||
members := &cobra.Command{
|
||||
Use: "+members",
|
||||
Aliases: []string{"+runtime-members"},
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
sentinel, _ := cmd.Flags().GetString("sentinel")
|
||||
executed = cmd.CommandPath() + ":" + sentinel
|
||||
return nil
|
||||
},
|
||||
}
|
||||
members.Flags().String("sentinel", "", "")
|
||||
chat.AddCommand(members)
|
||||
root.AddCommand(chat)
|
||||
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
|
||||
"chat " + test.path: {From: "chat " + test.path, Mode: "rewrite", To: "chat +good"},
|
||||
})
|
||||
args := []string{"chat", test.path, "--sentinel", "fixture"}
|
||||
root.SetArgs(args)
|
||||
ctx, err := RunPreParseArgs(root, engine, args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx != nil {
|
||||
t.Fatalf("runtime command unexpectedly entered correction pipeline: %#v", ctx)
|
||||
}
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if executed != "dws chat +members:fixture" {
|
||||
t.Fatalf("runtime command execution = %q", executed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackMaySupersedeHintOnlyCommand(t *testing.T) {
|
||||
root, executed := commandFallbackPipelineRoot()
|
||||
chat, _, err := root.Find([]string{"chat"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chat.AddCommand(cmdutil.HintSubCmd("+bad", "use the reviewed recovery"))
|
||||
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
|
||||
"chat +bad": {From: "chat +bad", Mode: "rewrite", To: "chat +good"},
|
||||
})
|
||||
args := []string{"chat", "+bad", "--query", "project"}
|
||||
root.SetArgs(args)
|
||||
ctx, err := RunPreParseArgs(root, engine, args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws chat +good" || len(ctx.Corrections) != 1 {
|
||||
t.Fatalf("hint-only fallback context = %#v", ctx)
|
||||
}
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *executed != "dws chat +good:project" {
|
||||
t.Fatalf("hint-only fallback execution = %q", *executed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPreParseArgsRejectsAmbiguousCommandFallbackWithoutDispatch(t *testing.T) {
|
||||
root, executed := commandFallbackPipelineRoot()
|
||||
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
|
||||
"chat +choose": {
|
||||
From: "chat +choose",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +good", "chat +other"},
|
||||
},
|
||||
})
|
||||
ctx, err := RunPreParseArgs(root, engine, []string{"--format", "json", "chat", "+choose", "--query", "project"})
|
||||
if ctx == nil {
|
||||
t.Fatal("ambiguous fallback returned nil context")
|
||||
}
|
||||
structured := requireCommandResolutionError(t, err, "ambiguous_command_fallback")
|
||||
if len(structured.Actions) != 2 || !strings.Contains(structured.Hint, "dws chat +good") || !strings.Contains(structured.Hint, "dws chat +other") {
|
||||
t.Fatalf("ambiguous recovery = %#v", structured)
|
||||
}
|
||||
if *executed != "" {
|
||||
t.Fatalf("ambiguous fallback dispatched %q", *executed)
|
||||
}
|
||||
if format, getErr := root.PersistentFlags().GetString("format"); getErr != nil || format != "json" {
|
||||
t.Fatalf("format = %q, %v; want primed json", format, getErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackLeavesUnknownAndCanonicalFlagErrorsDistinct(t *testing.T) {
|
||||
root, _ := commandFallbackPipelineRoot()
|
||||
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
|
||||
"chat +bad": {From: "chat +bad", Mode: "rewrite", To: "chat +good"},
|
||||
})
|
||||
_, err := RunPreParseArgs(root, engine, []string{"chat", "+missing", "--query", "project"})
|
||||
requireCommandResolutionError(t, err, "unknown_shortcut")
|
||||
|
||||
validRoot, _ := commandFallbackPipelineRoot()
|
||||
validRaw := []string{"chat", "+good", "--not-a-flag", "value"}
|
||||
if ctx, err := RunPreParseArgs(validRoot, engine, validRaw); ctx != nil || err != nil {
|
||||
t.Fatalf("valid command preparse = %#v, %v", ctx, err)
|
||||
}
|
||||
validRoot.SetArgs(validRaw)
|
||||
_, executeErr := validRoot.ExecuteC()
|
||||
if executeErr == nil || !strings.Contains(executeErr.Error(), "unknown flag") {
|
||||
t.Fatalf("canonical bad flag error = %v", executeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackCannotBypassCanonicalConfirmation(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
wantDispatch int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "confirmation missing", args: []string{"chat", "+danger"}, wantErr: true},
|
||||
{name: "confirmed", args: []string{"chat", "+danger", "--yes"}, wantDispatch: 1},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
dispatches := 0
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
chat := &cobra.Command{Use: "chat"}
|
||||
write := &cobra.Command{
|
||||
Use: "+write",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
confirmed, _ := cmd.Flags().GetBool("yes")
|
||||
if !confirmed {
|
||||
return apperrors.NewValidation("confirmation required", apperrors.WithReason("confirmation_required"))
|
||||
}
|
||||
dispatches++
|
||||
return nil
|
||||
},
|
||||
}
|
||||
write.Flags().Bool("yes", false, "")
|
||||
chat.AddCommand(write)
|
||||
root.AddCommand(chat)
|
||||
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
|
||||
"chat +danger": {From: "chat +danger", Mode: "rewrite", To: "chat +write"},
|
||||
})
|
||||
if _, err := RunPreParseArgs(root, engine, test.args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := root.ExecuteC()
|
||||
if (err != nil) != test.wantErr || dispatches != test.wantDispatch {
|
||||
t.Fatalf("execute error=%v dispatches=%d, want error=%v dispatches=%d", err, dispatches, test.wantErr, test.wantDispatch)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackRejectsInvalidGeneratedMode(t *testing.T) {
|
||||
root, _ := commandFallbackPipelineRoot()
|
||||
for _, entry := range []CommandPathFallback{
|
||||
{From: "chat +bad", Mode: "rewrite"},
|
||||
{From: "chat +bad", Mode: "invalid", To: "chat +good"},
|
||||
} {
|
||||
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{"chat +bad": entry})
|
||||
_, err := RunPreParseArgs(root, engine, []string{"chat", "+bad"})
|
||||
var structured *apperrors.Error
|
||||
if !errors.As(err, &structured) || structured.Category != apperrors.CategoryInternal {
|
||||
t.Fatalf("invalid entry error = %T %v", err, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPreParseArgsKeepsCommandErrorWhenPresentationPrimingFails(t *testing.T) {
|
||||
fallbackRoot, _ := commandFallbackPipelineRoot()
|
||||
fallbackEngine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
|
||||
"chat +choose": {
|
||||
From: "chat +choose",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +good", "chat +other"},
|
||||
},
|
||||
})
|
||||
_, err := RunPreParseArgs(fallbackRoot, fallbackEngine, []string{"chat", "+choose", "--format"})
|
||||
requireCommandResolutionError(t, err, "ambiguous_command_fallback")
|
||||
|
||||
resolutionRoot, _ := commandFallbackPipelineRoot()
|
||||
_, err = RunPreParseArgs(resolutionRoot, nil, []string{"chat", "+missing", "--format"})
|
||||
requireCommandResolutionError(t, err, "unknown_shortcut")
|
||||
|
||||
handlerRoot, _ := commandFallbackPipelineRoot()
|
||||
handlerEngine := NewEngine()
|
||||
handlerEngine.Register(newStub("fail-before-presentation", PreParse, func(*Context) error {
|
||||
return errors.New("handler failure")
|
||||
}))
|
||||
ctx, err := RunPreParseArgs(handlerRoot, handlerEngine, []string{"chat", "+good", "--query", "project", "--format"})
|
||||
if ctx == nil || err == nil || !strings.Contains(err.Error(), "handler failure") {
|
||||
t.Fatalf("handler error after presentation failure = context %#v, error %v", ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandFallbackTraversalRemainingBranches(t *testing.T) {
|
||||
args, positions := argsForCommandTraversalWithPositions(nil, []string{"chat", "+good"})
|
||||
if !reflect.DeepEqual(args, []string{"chat", "+good"}) || !reflect.DeepEqual(positions, []int{0, 1}) {
|
||||
t.Fatalf("nil-root traversal = args %v, positions %v", args, positions)
|
||||
}
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "table", "")
|
||||
raw := []string{"chat", "+good", "--", "--format", "json"}
|
||||
args, positions = argsForCommandTraversalWithPositions(root, raw)
|
||||
if !reflect.DeepEqual(args, raw) || !reflect.DeepEqual(positions, []int{0, 1, 2, 3, 4}) {
|
||||
t.Fatalf("terminator traversal = args %v, positions %v", args, positions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRewriteCommandPathTokensLeavesArgsWhenRewriteIsEmpty(t *testing.T) {
|
||||
raw := []string{"chat", "+bad", "--query", "project"}
|
||||
for name, test := range map[string]struct {
|
||||
positions []int
|
||||
tokens []string
|
||||
}{
|
||||
"no command positions": {tokens: []string{"chat", "+good"}},
|
||||
"no canonical tokens": {positions: []int{0, 1}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := rewriteCommandPathTokens(raw, test.positions, test.tokens)
|
||||
if !reflect.DeepEqual(got, raw) {
|
||||
t.Fatalf("rewriteCommandPathTokens() = %v, want %v", got, raw)
|
||||
}
|
||||
got[0] = "mutated"
|
||||
if raw[0] != "chat" {
|
||||
t.Fatal("empty rewrite returned aliased argv")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func commandFallbackPipelineEngine(entries map[string]CommandPathFallback) *Engine {
|
||||
engine := NewEngine()
|
||||
engine.SetCommandPathFallbackLookup(func(path string) (CommandPathFallback, bool) {
|
||||
entry, ok := entries[path]
|
||||
return entry, ok
|
||||
})
|
||||
return engine
|
||||
}
|
||||
|
||||
func commandFallbackPipelineRoot() (*cobra.Command, *string) {
|
||||
executed := new(string)
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().String("format", "table", "")
|
||||
chat := &cobra.Command{Use: "chat"}
|
||||
good := &cobra.Command{
|
||||
Use: "+good",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
query, _ := cmd.Flags().GetString("query")
|
||||
*executed = cmd.CommandPath() + ":" + query
|
||||
return nil
|
||||
},
|
||||
}
|
||||
good.Flags().String("query", "", "")
|
||||
good.Flags().String("keyword", "", "")
|
||||
other := &cobra.Command{Use: "+other", Run: func(cmd *cobra.Command, _ []string) { *executed = cmd.CommandPath() }}
|
||||
search := &cobra.Command{
|
||||
Use: "search",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
query, _ := cmd.Flags().GetString("query")
|
||||
*executed = cmd.CommandPath() + ":" + query
|
||||
return nil
|
||||
},
|
||||
}
|
||||
search.Flags().String("query", "", "")
|
||||
group := &cobra.Command{Use: "group"}
|
||||
group.AddCommand(cmdutil.HintSubCmd("search", "use dws chat search"))
|
||||
chat.AddCommand(good, other, search, group)
|
||||
root.AddCommand(chat)
|
||||
return root, executed
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const maxCommandSuggestions = 3
|
||||
|
||||
// validateUnresolvedCommand gives command identity precedence over flag
|
||||
// parsing. Cobra otherwise falls back to the nearest parent when a child is
|
||||
// unknown, so a later flag can incorrectly turn an invented command into an
|
||||
// "unknown flag" error on that parent.
|
||||
//
|
||||
// Only two unambiguous command containers are handled here:
|
||||
// - a top-level service followed by an explicit +shortcut token; and
|
||||
// - a command explicitly annotated as a group container.
|
||||
//
|
||||
// Other unresolved positionals remain Cobra's responsibility so commands that
|
||||
// legitimately accept positional arguments are not reclassified.
|
||||
func validateUnresolvedCommand(target *cobra.Command, remaining []string) error {
|
||||
if target == nil || len(remaining) == 0 {
|
||||
return nil
|
||||
}
|
||||
candidate := strings.TrimSpace(remaining[0])
|
||||
if candidate == "" || candidate == "--" || strings.HasPrefix(candidate, "-") {
|
||||
return nil
|
||||
}
|
||||
|
||||
if isExplicitShortcutCandidate(target, candidate) {
|
||||
return unknownShortcutError(target, candidate)
|
||||
}
|
||||
if cmdutil.IsGroup(target) {
|
||||
return unknownSubcommandError(target, candidate)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isExplicitShortcutCandidate(target *cobra.Command, candidate string) bool {
|
||||
if target == nil || !strings.HasPrefix(candidate, "+") {
|
||||
return false
|
||||
}
|
||||
root := target.Root()
|
||||
if root == nil || target == root || target.Parent() != root {
|
||||
return false
|
||||
}
|
||||
for _, child := range target.Commands() {
|
||||
if strings.HasPrefix(child.Name(), "+") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func unknownShortcutError(parent *cobra.Command, candidate string) error {
|
||||
action := fmt.Sprintf("Run '%s shortcut list --service %s --format json'", parent.Root().Name(), parent.Name())
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("unknown shortcut %q for %q", candidate, parent.CommandPath()),
|
||||
apperrors.WithReason("unknown_shortcut"),
|
||||
apperrors.WithHint(commandSuggestionHint(parent, candidate, action)),
|
||||
apperrors.WithActions(action),
|
||||
)
|
||||
}
|
||||
|
||||
func unknownSubcommandError(parent *cobra.Command, candidate string) error {
|
||||
action := fmt.Sprintf("Run '%s --help' to see available subcommands", parent.CommandPath())
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("unknown subcommand %q for %q", candidate, parent.CommandPath()),
|
||||
apperrors.WithReason("unknown_subcommand"),
|
||||
apperrors.WithHint(commandSuggestionHint(parent, candidate, action)),
|
||||
apperrors.WithActions(action),
|
||||
)
|
||||
}
|
||||
|
||||
func commandSuggestionHint(parent *cobra.Command, candidate, fallback string) string {
|
||||
suggestions := commandSuggestions(parent, candidate)
|
||||
if len(suggestions) > maxCommandSuggestions {
|
||||
suggestions = suggestions[:maxCommandSuggestions]
|
||||
}
|
||||
paths := make([]string, 0, len(suggestions))
|
||||
for _, suggestion := range suggestions {
|
||||
paths = append(paths, fmt.Sprintf("%q", parent.CommandPath()+" "+suggestion))
|
||||
}
|
||||
switch len(paths) {
|
||||
case 0:
|
||||
return fallback
|
||||
case 1:
|
||||
return "Did you mean " + paths[0] + "?"
|
||||
default:
|
||||
return "Did you mean one of: " + strings.Join(paths, ", ") + "?"
|
||||
}
|
||||
}
|
||||
|
||||
// commandSuggestions mirrors Cobra's suggestion rules without mutating
|
||||
// SuggestionsMinimumDistance. Cobra only installs its default distance inside
|
||||
// the final error renderer; this guard runs earlier and must remain safe for
|
||||
// command trees shared by concurrent tests and callers.
|
||||
func commandSuggestions(parent *cobra.Command, candidate string) []string {
|
||||
if parent == nil {
|
||||
return nil
|
||||
}
|
||||
minimumDistance := parent.SuggestionsMinimumDistance
|
||||
if minimumDistance <= 0 {
|
||||
minimumDistance = 2
|
||||
}
|
||||
lowerCandidate := strings.ToLower(candidate)
|
||||
shortcutOnly := strings.HasPrefix(candidate, "+")
|
||||
seen := make(map[string]bool)
|
||||
var suggestions []string
|
||||
for _, child := range parent.Commands() {
|
||||
if !child.IsAvailableCommand() {
|
||||
continue
|
||||
}
|
||||
name := child.Name()
|
||||
if shortcutOnly && !strings.HasPrefix(name, "+") {
|
||||
continue
|
||||
}
|
||||
matched := cmdutil.LevenshteinDist(lowerCandidate, strings.ToLower(name)) <= minimumDistance ||
|
||||
strings.HasPrefix(strings.ToLower(name), lowerCandidate)
|
||||
if !matched {
|
||||
for _, explicit := range child.SuggestFor {
|
||||
if strings.EqualFold(candidate, explicit) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if matched && !seen[name] {
|
||||
seen[name] = true
|
||||
suggestions = append(suggestions, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(suggestions)
|
||||
return suggestions
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesShortcutBeforeFlags(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
chat := &cobra.Command{Use: "chat"}
|
||||
chat.AddCommand(
|
||||
&cobra.Command{Use: "+chat-messages", Run: func(*cobra.Command, []string) {}},
|
||||
&cobra.Command{Use: "+messages-send", Run: func(*cobra.Command, []string) {}},
|
||||
&cobra.Command{Use: "chat-mesages", Run: func(*cobra.Command, []string) {}},
|
||||
)
|
||||
root.AddCommand(chat)
|
||||
|
||||
err := validateUnresolvedCommand(chat, []string{"+chat-mesages", "--keyword", "x"})
|
||||
structured := requireCommandResolutionError(t, err, "unknown_shortcut")
|
||||
if structured.Message != `unknown shortcut "+chat-mesages" for "dws chat"` {
|
||||
t.Fatalf("Message = %q", structured.Message)
|
||||
}
|
||||
if structured.Hint != `Did you mean "dws chat +chat-messages"?` {
|
||||
t.Fatalf("Hint = %q", structured.Hint)
|
||||
}
|
||||
if len(structured.Actions) != 1 || structured.Actions[0] != "Run 'dws shortcut list --service chat --format json'" {
|
||||
t.Fatalf("Actions = %#v", structured.Actions)
|
||||
}
|
||||
if len(structured.AvailableFlags) != 0 {
|
||||
t.Fatalf("AvailableFlags = %#v, want none for command error", structured.AvailableFlags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesOnlyExplicitContainers(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
dev := &cobra.Command{Use: "dev"}
|
||||
app := &cobra.Command{Use: "app"}
|
||||
cmdutil.MarkGroup(app)
|
||||
app.AddCommand(&cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}})
|
||||
dev.AddCommand(app)
|
||||
root.AddCommand(dev)
|
||||
|
||||
err := validateUnresolvedCommand(app, []string{"search", "--keyword", "x"})
|
||||
structured := requireCommandResolutionError(t, err, "unknown_subcommand")
|
||||
if structured.Message != `unknown subcommand "search" for "dws dev app"` {
|
||||
t.Fatalf("Message = %q", structured.Message)
|
||||
}
|
||||
if len(structured.Actions) != 1 || structured.Actions[0] != "Run 'dws dev app --help' to see available subcommands" {
|
||||
t.Fatalf("Actions = %#v", structured.Actions)
|
||||
}
|
||||
|
||||
positional := &cobra.Command{Use: "schema [path]", Run: func(*cobra.Command, []string) {}}
|
||||
root.AddCommand(positional)
|
||||
for name, test := range map[string]struct {
|
||||
target *cobra.Command
|
||||
remaining []string
|
||||
}{
|
||||
"legitimate positional": {target: positional, remaining: []string{"+chat-messages"}},
|
||||
"flag remains a flag": {target: app, remaining: []string{"--keyword", "x"}},
|
||||
"dash terminator": {target: app, remaining: []string{"--", "search"}},
|
||||
"nil target": {target: nil, remaining: []string{"search"}},
|
||||
"empty remaining": {target: app},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := validateUnresolvedCommand(test.target, test.remaining); err != nil {
|
||||
t.Fatalf("validateUnresolvedCommand() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPreParseArgsValidatesCommandsWithoutHandlersAndPrimesPresentation(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "table", "")
|
||||
chat := &cobra.Command{Use: "chat"}
|
||||
chat.AddCommand(&cobra.Command{Use: "+chat-messages", Run: func(*cobra.Command, []string) {}})
|
||||
root.AddCommand(chat)
|
||||
|
||||
ctx, err := RunPreParseArgs(root, nil, []string{
|
||||
"--format", "json", "chat", "+chat-mesages", "--keyword", "x",
|
||||
})
|
||||
if ctx == nil || ctx.Command != "dws chat" {
|
||||
t.Fatalf("Context = %#v", ctx)
|
||||
}
|
||||
requireCommandResolutionError(t, err, "unknown_shortcut")
|
||||
if format, getErr := root.PersistentFlags().GetString("format"); getErr != nil || format != "json" {
|
||||
t.Fatalf("format = %q, %v; want primed json presentation", format, getErr)
|
||||
}
|
||||
|
||||
validRoot := &cobra.Command{Use: "dws"}
|
||||
validChat := &cobra.Command{Use: "chat"}
|
||||
validChat.AddCommand(&cobra.Command{Use: "+chat-messages", Run: func(*cobra.Command, []string) {}})
|
||||
validRoot.AddCommand(validChat)
|
||||
if ctx, err := RunPreParseArgs(validRoot, nil, []string{"chat", "+chat-messages"}); ctx != nil || err != nil {
|
||||
t.Fatalf("valid shortcut = %#v, %v", ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandSuggestionHintIsBoundedAndFallsBack(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
group := &cobra.Command{Use: "demo", SuggestionsMinimumDistance: 100}
|
||||
root.AddCommand(group)
|
||||
for _, name := range []string{"alpha", "beta", "delta", "gamma"} {
|
||||
group.AddCommand(&cobra.Command{Use: name, Run: func(*cobra.Command, []string) {}})
|
||||
}
|
||||
hint := commandSuggestionHint(group, "missing", "fallback")
|
||||
if strings.Count(hint, `"dws demo `) != maxCommandSuggestions || strings.Contains(hint, "gamma") {
|
||||
t.Fatalf("bounded hint = %q", hint)
|
||||
}
|
||||
|
||||
empty := &cobra.Command{Use: "empty"}
|
||||
root.AddCommand(empty)
|
||||
if got := commandSuggestionHint(empty, "missing", "fallback"); got != "fallback" {
|
||||
t.Fatalf("fallback hint = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandResolutionDefensiveAndExplicitSuggestionPaths(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
service := &cobra.Command{Use: "demo"}
|
||||
group := &cobra.Command{Use: "nested"}
|
||||
service.AddCommand(group)
|
||||
root.AddCommand(service)
|
||||
|
||||
if isExplicitShortcutCandidate(root, "+missing") {
|
||||
t.Fatal("root command was treated as a shortcut service")
|
||||
}
|
||||
if isExplicitShortcutCandidate(group, "+missing") {
|
||||
t.Fatal("nested command was treated as a top-level shortcut service")
|
||||
}
|
||||
if got := commandSuggestions(nil, "missing"); got != nil {
|
||||
t.Fatalf("commandSuggestions(nil) = %#v", got)
|
||||
}
|
||||
|
||||
service.SuggestionsMinimumDistance = 1
|
||||
service.AddCommand(&cobra.Command{
|
||||
Use: "canonical",
|
||||
SuggestFor: []string{"invented"},
|
||||
Run: func(*cobra.Command, []string) {},
|
||||
})
|
||||
if got := commandSuggestions(service, "invented"); len(got) != 1 || got[0] != "canonical" {
|
||||
t.Fatalf("explicit SuggestFor suggestions = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func requireCommandResolutionError(t *testing.T, err error, reason string) *apperrors.Error {
|
||||
t.Helper()
|
||||
var structured *apperrors.Error
|
||||
if !stderrors.As(err, &structured) {
|
||||
t.Fatalf("error = %T %v, want *errors.Error", err, err)
|
||||
}
|
||||
if structured.Category != apperrors.CategoryValidation || structured.Reason != reason || structured.ExitCode() != 3 {
|
||||
t.Fatalf("structured error = %#v", structured)
|
||||
}
|
||||
return structured
|
||||
}
|
||||
@@ -21,9 +21,23 @@ import "fmt"
|
||||
// for reads after all handlers have been registered; registration
|
||||
// itself is not concurrent-safe and should be done at startup.
|
||||
type Engine struct {
|
||||
handlers map[Phase][]Handler
|
||||
handlers map[Phase][]Handler
|
||||
commandPathFallbackLookup CommandPathFallbackLookup
|
||||
}
|
||||
|
||||
// CommandPathFallback is the pipeline-local projection of one generated
|
||||
// recovery record. The app injects the cli lookup so pipeline stays independent
|
||||
// from the authored/generator package and can be tested with small fixtures.
|
||||
type CommandPathFallback struct {
|
||||
From string
|
||||
Mode string
|
||||
To string
|
||||
Candidates []string
|
||||
}
|
||||
|
||||
// CommandPathFallbackLookup resolves an exact normalized path.
|
||||
type CommandPathFallbackLookup func(path string) (CommandPathFallback, bool)
|
||||
|
||||
// HandlerError preserves the pipeline location of a handler failure for logs
|
||||
// and diagnostics while keeping the underlying domain error available to
|
||||
// user-facing adapters through Unwrap.
|
||||
@@ -62,6 +76,24 @@ func (e *Engine) RegisterAll(handlers ...Handler) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetCommandPathFallbackLookup installs the build-time generated command-path
|
||||
// recovery lookup. It must be configured before the engine is used.
|
||||
func (e *Engine) SetCommandPathFallbackLookup(lookup CommandPathFallbackLookup) {
|
||||
if e == nil {
|
||||
return
|
||||
}
|
||||
e.commandPathFallbackLookup = lookup
|
||||
}
|
||||
|
||||
func (e *Engine) lookupCommandPathFallback(path string) (CommandPathFallback, bool) {
|
||||
if e == nil || e.commandPathFallbackLookup == nil {
|
||||
return CommandPathFallback{}, false
|
||||
}
|
||||
entry, ok := e.commandPathFallbackLookup(path)
|
||||
entry.Candidates = append([]string(nil), entry.Candidates...)
|
||||
return entry, ok
|
||||
}
|
||||
|
||||
// Handlers returns the registered handlers for a given phase, in
|
||||
// registration order. The returned slice must not be modified.
|
||||
func (e *Engine) Handlers(phase Phase) []Handler {
|
||||
|
||||
@@ -15,6 +15,7 @@ package pipeline
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -62,6 +63,22 @@ func TestNewEngine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackLookupHandlesNilEngine(t *testing.T) {
|
||||
var engine *Engine
|
||||
engine.SetCommandPathFallbackLookup(func(string) (CommandPathFallback, bool) {
|
||||
t.Fatal("nil engine installed or called fallback lookup")
|
||||
return CommandPathFallback{}, false
|
||||
})
|
||||
if entry, ok := engine.lookupCommandPathFallback("chat +bad"); ok || !reflect.DeepEqual(entry, CommandPathFallback{}) {
|
||||
t.Fatalf("nil engine lookup = %#v, %v", entry, ok)
|
||||
}
|
||||
|
||||
engine = NewEngine()
|
||||
if entry, ok := engine.lookupCommandPathFallback("chat +bad"); ok || !reflect.DeepEqual(entry, CommandPathFallback{}) {
|
||||
t.Fatalf("unset engine lookup = %#v, %v", entry, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterAndHandlerCount(t *testing.T) {
|
||||
e := NewEngine()
|
||||
e.Register(newStub("a", PreParse, nil))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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": {},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user