Compare commits
233
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7849116a69 | ||
|
|
b082135e6e | ||
|
|
9ae0191270 | ||
|
|
211a5fa393 | ||
|
|
af199e73e2 | ||
|
|
fd24619437 | ||
|
|
6337058d15 | ||
|
|
d38868c8aa | ||
|
|
de8040ecc2 | ||
|
|
96f406be6b | ||
|
|
0d99d18acc | ||
|
|
9377abc5f6 | ||
|
|
e8ca78fe49 | ||
|
|
b923f522d5 | ||
|
|
28bc577e88 | ||
|
|
82dfee7291 | ||
|
|
1d2edbaa9f | ||
|
|
25a5f5b7d2 | ||
|
|
0b43905697 | ||
|
|
28227b19c7 | ||
|
|
622632908e | ||
|
|
2ec25ebb98 | ||
|
|
bccc9eb056 | ||
|
|
2274fd96f0 | ||
|
|
10fe258e4b | ||
|
|
22ab166c9b | ||
|
|
d3e444cb56 | ||
|
|
0148ad1800 | ||
|
|
4a4a1e0407 | ||
|
|
e38fd9ab93 | ||
|
|
fb33a0b9e0 | ||
|
|
e94c7063ed | ||
|
|
d6b51a04f4 | ||
|
|
cd3a09e153 | ||
|
|
2f925d29fd | ||
|
|
68483f05b2 | ||
|
|
730d3fa27f | ||
|
|
6eb3efa065 | ||
|
|
783e1eeef9 | ||
|
|
9e0a67f728 | ||
|
|
19f9285f8c | ||
|
|
c295027e84 | ||
|
|
3817ac230d | ||
|
|
75b54a9467 | ||
|
|
24437fc1a5 | ||
|
|
851d491d2a | ||
|
|
b55f243780 | ||
|
|
e9850a2e49 | ||
|
|
257ac94fb1 | ||
|
|
9834a84888 | ||
|
|
8097943e3a | ||
|
|
a68c06540c | ||
|
|
44c5ef13b4 | ||
|
|
d5a9a72fa6 | ||
|
|
6f73e5187a | ||
|
|
b7918be6f3 | ||
|
|
b70e109e89 | ||
|
|
b84b56d9f8 | ||
|
|
e66cd95c51 | ||
|
|
7e8b216e07 | ||
|
|
3fc144a699 | ||
|
|
5501c9f1a5 | ||
|
|
4d274c9da3 | ||
|
|
7ffb48c9ae | ||
|
|
a24fd542c0 | ||
|
|
b8418b6a5f | ||
|
|
af71efd253 | ||
|
|
8c19b0048b | ||
|
|
a9751fa74d | ||
|
|
8a0bd34e13 | ||
|
|
5a160cefd8 | ||
|
|
a9c0e0409c | ||
|
|
9616441e54 | ||
|
|
eefe6f05e1 | ||
|
|
6598292b1b | ||
|
|
dea637228d | ||
|
|
a09467f1eb | ||
|
|
34feb348af | ||
|
|
08ee5dc573 | ||
|
|
6b1a1a6201 | ||
|
|
5c45bd57da | ||
|
|
349537e336 | ||
|
|
9f60cdeef1 | ||
|
|
258caa5906 | ||
|
|
a0d59a79aa | ||
|
|
c7148f3ebb | ||
|
|
ef29b48a55 | ||
|
|
5c33ea526e | ||
|
|
6d3b54b25f | ||
|
|
b243b38d65 | ||
|
|
867bb44586 | ||
|
|
819355b31f | ||
|
|
c3d4de52a7 | ||
|
|
4bbd42cc25 | ||
|
|
fd0c3350f3 | ||
|
|
2cc24de505 | ||
|
|
0e14f69aae | ||
|
|
9f14035483 | ||
|
|
f478b7d3e1 | ||
|
|
d84c73e8b2 | ||
|
|
6575301a3a | ||
|
|
8daf5c71cd | ||
|
|
43121f1d8f | ||
|
|
cd02fe71e6 | ||
|
|
431f64be85 | ||
|
|
93a2071837 | ||
|
|
d979d86fa3 | ||
|
|
33730337f3 | ||
|
|
6be12655dc | ||
|
|
cf3bcb380f | ||
|
|
89e8bd7015 | ||
|
|
64e1dcc150 | ||
|
|
f3ecac1ad1 | ||
|
|
b150911da9 | ||
|
|
46ae1c50fe | ||
|
|
37d6a4ea2e | ||
|
|
20c8e0dfec | ||
|
|
5de36d783a | ||
|
|
cde050f146 | ||
|
|
2bc4ded969 | ||
|
|
468f9200f6 | ||
|
|
e02410dae6 | ||
|
|
74d31566ff | ||
|
|
6765a74d83 | ||
|
|
13e5914638 | ||
|
|
8fcc6baee0 | ||
|
|
6774d423b7 | ||
|
|
8156528c05 | ||
|
|
6f61183732 | ||
|
|
332b74e8ce | ||
|
|
e5a60386c6 | ||
|
|
10bb2ec205 | ||
|
|
ac0125e468 | ||
|
|
f06a24ec2e | ||
|
|
8f135ecde6 | ||
|
|
a2e51f2b86 | ||
|
|
3d6e62fc08 | ||
|
|
c6094e291e | ||
|
|
e026c754e0 | ||
|
|
18030f1018 | ||
|
|
6297b6b0c8 | ||
|
|
e8905a1984 | ||
|
|
a097d57510 | ||
|
|
b566afe3e2 | ||
|
|
7405825294 | ||
|
|
7eb39ad5d6 | ||
|
|
eb73b944a1 | ||
|
|
ecaa375be8 | ||
|
|
dbecf23bc4 | ||
|
|
d73e199d97 | ||
|
|
7c9687094f | ||
|
|
05d8c86177 | ||
|
|
115dad3b82 | ||
|
|
b8ac9810f4 | ||
|
|
c1a90c0194 | ||
|
|
0bb2b6ce98 | ||
|
|
2662f87ad0 | ||
|
|
5a5b567eb0 | ||
|
|
c2db909bd2 | ||
|
|
871542ef0c | ||
|
|
1ee37ec4c2 | ||
|
|
2c7d0f3ac4 | ||
|
|
5a345228eb | ||
|
|
06b0a9eef3 | ||
|
|
ea7c66b190 | ||
|
|
83f72377a7 | ||
|
|
b2b6153424 | ||
|
|
59efb4facb | ||
|
|
3605d4f450 | ||
|
|
f34b7741d4 | ||
|
|
2c573ec892 | ||
|
|
50712d3305 | ||
|
|
7e27fa384a | ||
|
|
3027337a34 | ||
|
|
8bf6c15fad | ||
|
|
d0ad33034e | ||
|
|
f79a6fc707 | ||
|
|
5f13876e6e | ||
|
|
5a09204bf5 | ||
|
|
0d11b2be45 | ||
|
|
4bb8c8b586 | ||
|
|
48e522ec00 | ||
|
|
effe7c829e | ||
|
|
0e0007d7b7 | ||
|
|
176b217139 | ||
|
|
7c76dfea4b | ||
|
|
c803cf7eeb | ||
|
|
5c8fd0a48c | ||
|
|
7490bb95c5 | ||
|
|
832d3ab886 | ||
|
|
47f303d3fc | ||
|
|
1199240a36 | ||
|
|
b186e59a01 | ||
|
|
a92f54df3d | ||
|
|
354d39a6f1 | ||
|
|
307c9e797b | ||
|
|
116117e987 | ||
|
|
07ce090eeb | ||
|
|
201949aec1 | ||
|
|
8eeabd1419 | ||
|
|
6035d43899 | ||
|
|
6704eda83a | ||
|
|
ba375b40fa | ||
|
|
0a063662a0 | ||
|
|
4148a90bf5 | ||
|
|
6572010922 | ||
|
|
6738d0f29e | ||
|
|
384b067ead | ||
|
|
c32d10bd43 | ||
|
|
2a7ab22e85 | ||
|
|
757f45df31 | ||
|
|
a3773c4384 | ||
|
|
4110330575 | ||
|
|
5d25a10223 | ||
|
|
2bd6b297b0 | ||
|
|
0d5c6d92e3 | ||
|
|
88f7ea5679 | ||
|
|
c0b562cc07 | ||
|
|
70d58648c8 | ||
|
|
b581426488 | ||
|
|
868d9ff2b0 | ||
|
|
cb2f240c0c | ||
|
|
d800060e06 | ||
|
|
8d8206f791 | ||
|
|
15c2bd50b8 | ||
|
|
711d557b93 | ||
|
|
6102e9fc68 | ||
|
|
ae426f37de | ||
|
|
4dbfaa4cef | ||
|
|
703406df13 | ||
|
|
753d538140 | ||
|
|
f890dda7e7 | ||
|
|
c870d2ebdc |
@@ -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;
|
||||
});
|
||||
+33
-17
@@ -148,6 +148,7 @@ jobs:
|
||||
filename === '.github/actionlint.yaml' ||
|
||||
filename.startsWith('scripts/') ||
|
||||
filename.startsWith('verify/') ||
|
||||
filename.startsWith('internal/helpers/') ||
|
||||
filename.startsWith('internal/generator/') ||
|
||||
filename.startsWith('internal/cli/schema') ||
|
||||
filename.startsWith('internal/interfacesnapshot/') ||
|
||||
@@ -433,6 +434,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
|
||||
@@ -1366,36 +1371,47 @@ jobs:
|
||||
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
|
||||
base_ref="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
candidate_ref="$(git rev-parse 'HEAD^{commit}')"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ "$candidate_ref" != "$PR_HEAD_SHA" ]; then
|
||||
echo "Compatibility checkout $candidate_ref does not match PR head $PR_HEAD_SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
stable_ref="$(git tag --merged "$base_ref" --list 'v[0-9]*' --sort=-version:refname | awk 'index($0, "-") == 0 { print; exit }')"
|
||||
. ./scripts/release/release-lib.sh
|
||||
stable_ref=""
|
||||
for tag in $(git tag --merged "$base_ref" --list 'v*' --sort=-version:refname); do
|
||||
release_is_stable_version "$tag" || continue
|
||||
if git rev-parse --verify --quiet "refs/tags/withdrawn/$tag" >/dev/null; then
|
||||
continue
|
||||
fi
|
||||
stable_ref="$tag"
|
||||
break
|
||||
done
|
||||
if [ -z "$stable_ref" ]; then
|
||||
echo "No stable release tag is reachable from compatibility base $base_ref" >&2
|
||||
exit 1
|
||||
fi
|
||||
git rev-parse --verify "${stable_ref}^{commit}" >/dev/null
|
||||
echo "COMPATIBILITY_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV"
|
||||
printf '%s\n' \
|
||||
"COMPATIBILITY_BASE_REF=$base_ref" \
|
||||
"COMPATIBILITY_STABLE_REF=$stable_ref" \
|
||||
"COMPATIBILITY_CANDIDATE_REF=$candidate_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check historical commands and help compatibility
|
||||
- name: Check historical commands, help, and complete CLI compatibility
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
run: |
|
||||
make authoritative-interface-integrity \
|
||||
BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||
if [ "$(git rev-parse "${COMPATIBILITY_BASE_REF}^{commit}")" != "$(git rev-parse "${COMPATIBILITY_STABLE_REF}^{commit}")" ]; then
|
||||
make authoritative-interface-integrity \
|
||||
BASE_REF="$COMPATIBILITY_STABLE_REF"
|
||||
fi
|
||||
|
||||
- name: Check complete CLI command compatibility
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
run: |
|
||||
./scripts/policy/check-command-compatibility.sh \
|
||||
--base-ref "$COMPATIBILITY_BASE_REF" \
|
||||
--stable-ref "$COMPATIBILITY_STABLE_REF"
|
||||
BASE_REF="$COMPATIBILITY_BASE_REF" \
|
||||
STABLE_REF="$COMPATIBILITY_STABLE_REF" \
|
||||
CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"
|
||||
|
||||
- name: Check complete Schema compatibility
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||
run: |
|
||||
make schema-compatibility \
|
||||
BASE_REF="$COMPATIBILITY_BASE_REF" \
|
||||
STABLE_REF="$COMPATIBILITY_STABLE_REF" \
|
||||
CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"
|
||||
|
||||
- name: Check skill command references
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+100
-2
@@ -6,15 +6,112 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
### Added
|
||||
|
||||
- **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.
|
||||
- **Aitable workflow execution and history** — adds `dws aitable workflow run` for confirmed asynchronous execution of scheduled or record-triggered workflows, plus `dws aitable workflow history` for status-, time-, and page-filtered execution records. The commands map directly to `aitable/run_workflow` and `aitable/get_flow_record_list`, validate trigger-specific arguments locally, and document the `executionId` / `instanceId` correlation.
|
||||
- **Streaming-card mentions** — `chat +messages-send-card` now accepts
|
||||
`--at-open-dingtalk-ids` and `--at-all` for group cards, passing mention
|
||||
targets to the initial card-creation request and prepending its returned
|
||||
`atTag` to the automatic streaming update.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Machine-readable export and download receipts** — `dws doc export`,
|
||||
`dws drive download`, and `dws drive download --version` now keep progress
|
||||
logs on stderr under `--format json` and emit one JSON result on stdout after
|
||||
a successful local write. The result includes the saved path and byte size;
|
||||
document exports additionally report the node, requested format, job/task
|
||||
ID, and final status.
|
||||
### Changed
|
||||
|
||||
- **Minutes `permission apply --policy` type** — `--policy` is now declared as
|
||||
an `int` flag and its required check uses `Flags().Changed`, matching the
|
||||
numeric-parameter convention. `--help` reports `int` instead of `string`;
|
||||
accepted values (2/3/4) and gateway behavior are unchanged.
|
||||
- **Minutes skill references** — document `permission apply` in both Minutes
|
||||
skill references: list it in the command trees, describe its policy values and
|
||||
how it differs from `permission add`, and add its intent routing.
|
||||
|
||||
## [1.0.58-beta.2] - 2026-08-10
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws sheet create-with-data`(新命令)** — 建表并写入初始数据与样式:`--values`(二维数组写默认表)/ `--sheets`(typed table 多工作表,二者必须给一个)/ `--styles`(`cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`,顶层键对齐飞书 snake_case、列表项内字段兼容 camelCase)。所有结构、字段类型与枚举在创建文档之前校验,非法配置不会留下白建的空文档:`--sheets` 按 `table_put` 的输入契约逐字段校验(`columns` 必填且列名非空不重复、`data` 为二维且行宽与 `columns` 一致、单元格仅限字符串/数字/布尔/null、`dtypes`/`formats` 的键须是列名、`mode`/`header`/`allowOverwrite`/`startCell` 类型与取值、单表 30000 单元格上限),并拒绝未知键、snake_case 变体、`{"sheets":"bad"}` 这类畸形包装与 `sheetId`(服务端会静默丢弃写错的键,导致"只写了表头却报成功"的静默丢数据);`--values` 校验单元格为标量并受 30000 单元格 / 2000000 字符上限约束;`--styles` 的顶层键与列表项内字段同样拒绝未知键,避免样式只应用一半。写入后回读校验按 `startCell` / `header` / `mode` 推算的首个预期非空单元格,而非固定 A1。该命令是多步编排(建文档 → 探活 → 定位默认工作表 → 写数据 → 回读 → 可选样式),因此如实声明为独立叶子 `sheet.create_with_data` + `interface_mode: composite`(附评审 reason,按契约不带 `interface_ref`);**`dws sheet create` 保持原样不变**——仍是一次 `create_workspace_sheet` 直连(`interface_mode: mcp`),不新增 flag,避免让 Schema 消费者把编排步骤的参数误当成该 RPC 的入参。
|
||||
- **`dws sheet export-csv`(新命令)** — 同步导出单个工作表为 RFC4180 CSV,支持 `--sheet-id` 选表、`--range` 限定范围、`--value-render-option` 选取值模式;`--output` 落盘(为目录时按 `sheet-export.csv` 命名,落盘走 `AtomicWrite` 原子替换,写入失败时已有文件保持原样;父目录不存在按错误处理,不会自动创建),不传则把纯 CSV 打到 stdout(警告只走 stderr)。数据超出单次读取上限时默认报错、既不输出也不写文件,需 `--allow-truncated` 显式接受不完整结果。响应缺 `csv` 字段或类型不对一律报错,不会用 0 字节覆盖已有文件。该分支读的是 `get_range_as_csv`、与 xlsx 的异步导出任务毫无关系,因此独立成叶子 `sheet.export_csv` 并如实声明 `interface_mode: mcp` + `interface_ref: get_range_as_csv`;**`dws sheet export` 保持原样不变**——仍只导 xlsx(`interface_ref: submit_export_job`),flag 面仍是 `--node` / `--output`,csv 专属 flag 不会出现在它上面(此前挂在同一条命令上时,漏写 `--export-format csv` 会让 `--range` 被静默丢弃而导出整篇工作簿)。
|
||||
- **`sheet update-dimension --size-type`** — `pixel` / `standard`(恢复默认行高列宽)/ `auto`(按内容自适应行高,仅 ROWS)。
|
||||
- **`sheet replace --match-formula`** — 在公式文本中查找替换。
|
||||
- **`sheet range set-style` 扩展样式维度** — 新增 `--font-style`(斜体)/ `--font-line`(下划线、删除线)/ `--font-family` / `--border-styles-json`(四边边框;每条边只接受 `style` / `color`,未知键与非字符串 `color` 直接报错,不再静默忽略而画出无颜色的边框,`set-style` / `batch-set-style` / `create-with-data --styles` 三条路径同源校验)。
|
||||
- **`sheet range batch-set-style --ranges`** — 一组样式刷多个带工作表前缀的区域,组装为一次原子 `batch_update`。
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat message post-send ID handoff** (#897) — CLI Help and bundled Skills
|
||||
now document the `send` → `query-send-status` → `edit`/`recall` workflow,
|
||||
so callers can reuse returned task, message, and conversation IDs instead
|
||||
of searching message history by content.
|
||||
- **Sheet mono/multi Skill alignment** — replaces the oversized mono Sheet
|
||||
reference with the progressive routing layout, aligns all 20 Sheet topic
|
||||
references across the mono and multi bundles, and adds a content-policy guard
|
||||
that prevents the paired topic trees from drifting again.
|
||||
- **`sheet range set-style` 后端切换为 `set_cell_range`** — 样式统一走 cellStyles 路径(仅设样式、保留原值),这是斜体/下划线删除线/字体族/边框唯一可用的通道。`interface_ref` 由 `update_range` 变为 `set_cell_range`,12 个样式 flag 改为 reviewed mapping exclusion。CLI 用法向后兼容、无 flag 删除;schema-compatibility 经 reviewed 豁免判定为兼容(0 changed fields)。
|
||||
- **`sheet range batch-set-style` 改为单次原子提交** — 由本地循环多次 `update_range` 改为一次 `batch_update`,任一项失败默认整批回滚;`--continue-on-error` 由本地控制改为透传服务端。新增批量上限:最多 100 个区域且累计不超过 200000 个单元格。
|
||||
- **`sheet range batch-clear` / `batch-set-style` 的 `--ranges` 拒绝空白工作表前缀**(用户可见行为变更)— 此前只按原始串里 `!` 的位置判断,`" !A1:B2"` 修剪后工作表名成了空串,操作却照样带着 `sheetId: ""` 提交:服务端要么让整批 `batch_update` 失败,要么更糟——落到默认工作表而不是用户指定的那张表,且命令报成功。现在工作表名与范围都必须在修剪之后仍非空,否则在发起任何请求之前报错。`batch-set-style --batch` 的纯空白 `sheetId` / `range` 同样拒绝(此前只挡空字符串);`--batch` 下发仍用原值不替用户修剪,因为 `sheetId` 可以是允许带首尾空格的工作表**名**。两条 `--ranges` 路径现在共用同一个拆分器。
|
||||
- **`sheet insert-dimension` / `delete-dimension` / `update-dimension` 的 `--length` 严格校验**(用户可见行为变更)— 解析由 `fmt.Sscanf("%d")` 改为 `strconv.Atoi`。此前只消费前缀数字,`--length 2x` / `3foo` 会被静默当成 `2` / `3` 并对错误的行列数执行操作(删除方向不可回滚);现在整个值必须是合法正整数,否则报错「`--length` 必须为正整数(>= 1)」且不发起任何请求。**升级影响**:原先依赖这种宽松解析、在传畸形 `--length` 的脚本会开始报错,请把参数修正为纯数字。合法数字值行为不变,上限仍为 5000。`add-dimension` 的 `--length` 是 `Int` 类型 flag,一直由 cobra 严格校验,不受影响。
|
||||
- **CLI 接口兼容门禁支持 reviewed flag 类型豁免**(无用户可见变更)— `authoritative-interface-integrity` 与 `check-command-compatibility.sh` 此前一律拒绝历史命令的 flag 类型变更,即使新类型只是把同一套校验从 RunE 前移到解析期,也没有任何评审通道。现在两道门禁各带一张精确豁免表:命令路径 + flag 名 + 旧类型 → 新类型四元组全等才命中、方向敏感(`string`→`int` 与 `int`→`string` 是两个不同的键,只有被评审的方向可用),且仅当该 flag 的其他契约(shorthand / required / hidden / no-opt / scope)纹丝不动时才放行,因此豁免夹带不了别的破坏。首条也是目前唯一一条登记的是 `dws minutes permission apply --policy` 的 `string` → `int`(配合 #912):旧实现在 RunE 里做 `strconv.ParseInt(v, 10, 64)` 再校验 `[2,4]`,新实现由 pflag 以 `strconv.ParseInt(s, 0, 64)` 解析后仍校验 `[2,4]`,**历史上能成功的调用集是新调用集的子集**(base 0 额外接受 `0x3` 这类写法,只放宽不收紧),非法值依然失败、只是报错文案与时机前移;flag 默认值由 `""` 变 `"0"` 是类型的必然结果,两道门禁都不比较默认值,且该 flag 必须显式给出、默认值不可达。两张表必须逐字一致并有守卫测试锚定漂移——重复是被迫的而非选择:`check-authoritative-interface-baselines.sh` 会把整个 `scripts/policy/interface-baseline` 目录复制进检出历史版本的 worktree 再编译,那份拷贝不能 import 本分支新增的包。
|
||||
- **Schema 兼容门禁支持 reviewed 参数类型豁免**(无用户可见变更)— 接上一条。`schema-compatibility` 是同一个 `Interface Integrity` job 里排在两道 CLI 接口门禁之后的第三道检查,此前也一律拒绝已发布参数的 `type` 变更。由于前两道先失败、`set -e` 让它从未在 CI 上暴露,上一条豁免只解决了三分之二。现在 `checkParameterCompatibility` 也带一张精确豁免表:`<product>/<tool id>` + 参数名 + 旧类型 + 新类型四元组全等才命中、方向敏感,且仅当该参数**除 `type` 外的全部已发布字段逐字段相等**时才放行。这里刻意用相等性比较而非「没有产生其他兼容性错误」:放宽 `required` / `cli_required`、清空 `required_when`、扩宽 `enum`、清空 `interface_type`、经 reviewed mapping exclusion 清空 `property`——这些变化单独看都是兼容的、根本不产生错误,若以错误列表代替相等性检查,它们就能搭着一次已评审的类型迁移一起蒙混过关。结构体整体比较还意味着将来给 `parameterSchema` 新增字段时会自动纳入守卫,而不是悄悄放宽每一条既有条目。唯一条目是 `minutes/minutes.apply_minutes_permission` 的 `policy` 由 `"string"` 迁移到 `"integer"`(配合 #912):该 `type` 由 Cobra flag 类型投影而来(provenance `cobra_flag_type`),描述的是 CLI 如何接受取值;消费方据此拼装的是命令行,而 `--policy 4` 在两种声明下是同一个 argv,加引号的 `--policy "4"` 到 pflag 仍是 4,RunE 也仍校验 `[2,4]`——而且该参数映射的 property `policyId` 一直以数字上报,新声明比旧声明更贴近真实请求。表里的类型值必须是 `schemaType` 实际产出的带引号形态(`"string"` 而非裸 `string`),守卫测试用 `schemaType` 复算并校验类型名属于 JSON Schema 的封闭取值集合——`reviewedInterfaceRefRedirect` 曾因键的书写形态错误两次静默失效,这里不重犯。
|
||||
|
||||
## [1.0.58-beta.1] - 2026-08-07
|
||||
|
||||
### Added
|
||||
|
||||
- **Robot image and file messages** (#867) — `dws chat message send-by-bot`
|
||||
now supports image URLs and local-file uploads through explicit message
|
||||
types, while retaining Markdown as the default and preserving its existing
|
||||
title and text requirements.
|
||||
- **Conversation shortcut-bar management** (#877) — adds `dws chat toolbar`
|
||||
commands to list, add, hide, sort, and manage custom conversation shortcuts,
|
||||
with validation and confirmation for destructive removal.
|
||||
- **Complete AI Table Shortcut surface** (#901) — makes all 92 supported
|
||||
AI Table Shortcuts discoverable through Runtime Schema and adds reliable
|
||||
Base, table, record, attachment, view, dashboard, and workflow operations
|
||||
with explicit confirmation and result-verification semantics for writes.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Doc import upload fallback** — `dws doc import` no longer fails on file
|
||||
formats outside the conversion whitelist (html, pdf, zip, extensionless,
|
||||
and any future format): it now hands the file to the document-space upload
|
||||
chain (the same primitive as `dws drive upload --workspace`), stores the
|
||||
original file at the requested `--folder`/`--workspace` target, and prints
|
||||
an explicit stderr notice with the supported-format list and the
|
||||
convert-to-md alternative. The fallback shares the import file checks
|
||||
(20MB cap, empty-file guard), keeps `--format json` / `--dry-run` output as
|
||||
a single JSON document, and marks the machine-readable result with
|
||||
`fallback: "upload"` and `converted: false` so agents never mistake the
|
||||
stored file for a converted online document. The fallback fails closed
|
||||
unless the commit response parses as JSON and carries a file identity
|
||||
(exposed as `dentry_id`); empty or unverifiable responses surface as
|
||||
errors instead of fabricated success. Importable formats and
|
||||
`dws sheet import` validation are unchanged.
|
||||
- **IM natural-target and history alignment** — Chat shortcuts can resolve natural user/group targets before execution, and message-history workflows expose bounded time ranges, ordering, explicit all-page controls, continuation ledgers, safe local export, and thread-reply pagination without treating empty or incomplete reads as successful results. Bundled mono/multi Skills and intent routing now describe the same executable surface.
|
||||
- **Sheet CSV formula writes** — `dws sheet csv-put` and batch `csv-put` now expose the service contract that CSV fields beginning with `=` are written as formulas. Prefix the field with an apostrophe to write literal text beginning with `=`; CSV content continues to pass through unchanged.
|
||||
- **Release-equivalent PR compatibility gate** (#889) — pull-request
|
||||
admission now runs command-surface compatibility checks against the current
|
||||
release baseline before code reaches `main`.
|
||||
- **Reviewer routing governance** (#903) — updates the Reviewer Router pool
|
||||
used for new ready PRs while retaining the existing current-head review and
|
||||
required-check gates.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Fail-closed IM pagination and audit evidence** — `+chat-messages`, `+search-msg`, `+thread-replies`, `+at-me`, `+my-groups`, conversation lists, and favorites preserve partial-read failures, reject missing or stalled continuation state, deduplicate page boundaries, and publish completion evidence. The live-audit regression suite now rejects empty projections and incomplete reads instead of promoting them to passing results.
|
||||
- **Sheet formula verification** (#873) — `dws sheet formula-verify` now calls
|
||||
the registered remote tool name `verify_formula`; the previous
|
||||
`formula_verify` name failed at gateway dispatch.
|
||||
- **CLI and parameter recovery boundaries** (#864) — command and parameter
|
||||
recovery now fail closed when an Agent-provided path or flag cannot be
|
||||
reconciled with the executable CLI surface, reducing unsafe hallucinated
|
||||
retries.
|
||||
|
||||
## [1.0.57-beta.4] - 2026-08-06
|
||||
|
||||
@@ -149,6 +246,7 @@ and compatibility and CI reliability fixes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Fail-closed IM pagination and audit evidence** — `+chat-messages`, `+search-msg`, `+thread-replies`, `+at-me`, `+my-groups`, conversation lists, and favorites preserve partial-read failures, reject missing or stalled continuation state, deduplicate page boundaries, and publish completion evidence. The live-audit regression suite now rejects empty projections and incomplete reads instead of promoting them to passing results.
|
||||
- **Unified command safety and Shortcut runtime (H0)** — Shortcut leaves now execute through `corecmd.New`, sharing the same typed Safety confirmation gate as Leaf commands. EOF / closed stdin returns `confirmation_required`, and interactive `no` returns the existing non-zero cancellation validation error instead of reporting success for an operation that did not run. Pass `--yes` or `--dry-run` to skip the prompt.
|
||||
- **Constraint "provided" for `at_least_one` / `exactly_one` (H0)** — a flag set to an empty string (`--flag ""`) no longer counts as provided; previously bare Cobra `Changed` satisfied the constraint. Pass a non-blank value for a member of the group.
|
||||
- **Chat media download JSON compatibility** — `dws chat message download-media --format json` once again returns a clean `{success, downloadUrl, output}` result after the file is saved, preserving the temporary URL and resolved local path without progress text corrupting JSON stdout.
|
||||
|
||||
+11
-3
@@ -68,9 +68,17 @@ coverage is additionally selected for platform-sensitive code.
|
||||
3. Include both the commands/results and user-visible or contract-level
|
||||
behavior evidence in the PR description.
|
||||
4. Run `./scripts/policy/check-command-surface.sh --strict` when command
|
||||
paths/flags change. CI also runs
|
||||
`./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>`
|
||||
against both the target branch and latest stable release.
|
||||
paths/flags change. CI resolves the exact merge-base, latest reachable
|
||||
non-withdrawn stable GA tag, and committed candidate SHA, then enters the single compatibility
|
||||
decision seam through
|
||||
`make authoritative-interface-integrity BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`.
|
||||
The Make target delegates to the authoritative wrapper; CI does not invoke a
|
||||
second comparator or the legacy fixture checker. See
|
||||
[CLI flag compatibility migration governance](docs/cli-interface-flag-migrations.md)
|
||||
for the reviewed two-stage `pending` → `consumed` lifecycle.
|
||||
Agent-visible flag migrations must also run
|
||||
`make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`;
|
||||
it consumes the same base-owned ledger rather than a second exception list.
|
||||
5. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may
|
||||
change.
|
||||
6. Run `./scripts/release/verify-package-managers.sh` when packaging or
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.57-beta.4"
|
||||
version "1.0.58-beta.2"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "ff363e258d463732e4dc02aa71ac1b5b1f05c25107c32ddcc5ae11d14931a782"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "1b2b6953f7f1ae1ca6ecb0702424ac0e1a976a6a5ff91e8ffc3b5ae495d98c7c"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "3eafcc4c27931b8457f3611a12ce4ae727f7bd65356fbef80958699defa09acf"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "a1c1b3c58b48e04c0ae520062f9d6ab0dc961eddb635497bdb9b4345316e45f6"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "49eabb4d2c419d8d1fb0fcf71d9c318ef49cf3d198431df1bcbaa71589e11383"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "7f35e3c4734f17b125a8c32f3c95e05d1410f683cf6956be857ee9349f8e4d36"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "fb3903485fe494e1fadb67ec87b2fef19cf575c72b7df25f0b93f80f8b36273f"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "37beb9e39790563cf0584ac23376f713bf2eb2c50cff4222965e831ac9adbb0e"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-skills.zip"
|
||||
sha256 "6bd11363dbd2ce79627f49bc80b8a92f9c79fca083a6bd3ff9bc5c4833594fb9"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-skills.zip"
|
||||
sha256 "7e10fead4192059c98d596c5b1886f77fd550526de5cd18c425cdad6fd64cd3a"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -24,13 +24,13 @@ help:
|
||||
@printf " make format-check - Check all repository Go source files with gofmt\n"
|
||||
@printf " make fmt - Format all repository Go source files\n"
|
||||
@printf " make policy - Check the built dws plus open-source and Schema policies\n"
|
||||
@printf " make interface-integrity - Check historical commands and help contracts still work\n"
|
||||
@printf " make authoritative-interface-integrity BASE_REF=<ref> - Check the Git-owned PR merge-base\n"
|
||||
@printf " make interface-integrity [BASE_REF=<ref>] [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check authoritative CLI history\n"
|
||||
@printf " make authoritative-interface-integrity BASE_REF=<ref> [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check Git-owned CLI history\n"
|
||||
@printf " make coverage-gate BASE_REF=<ref> - Enforce overall non-regression and 100%% changed-code coverage\n"
|
||||
@printf " make coverage-gate-platform BASE_REF=<ref> PROFILE=<file> - Enforce 100%% native changed-code coverage\n"
|
||||
@printf " make update-interface-baseline - Add new CLI contracts without removing history\n"
|
||||
@printf " make reset-interface-baseline - DANGEROUS: replace all CLI compatibility history\n"
|
||||
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
|
||||
@printf " make update-interface-baseline - Update the non-authoritative CLI smoke fixture\n"
|
||||
@printf " make reset-interface-baseline - DANGEROUS: replace the non-authoritative CLI smoke fixture\n"
|
||||
@printf " make schema-compatibility BASE_REF=<ref> [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check the authoritative Schema history\n"
|
||||
@printf " make skill-command-integrity - Check dws commands referenced by skills exist\n"
|
||||
@printf " make skill-context-budget - Check generated Skill drift and common-path context budgets\n"
|
||||
@printf " make multi-im-skill-chain-integrity - Check reviewed IM intents keep one default Skill route\n"
|
||||
@@ -89,6 +89,7 @@ policy: test-auth-legacy-compat
|
||||
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-multi-im-skill-chain.sh
|
||||
@python3 scripts/run_chat_shortcut_live_audit_test.py
|
||||
@$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict
|
||||
@$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-param-concepts.sh
|
||||
@@ -102,10 +103,22 @@ edition-test:
|
||||
$(GO) test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
interface-integrity:
|
||||
@./scripts/policy/check-interface-baseline.sh
|
||||
@base_ref="$(BASE_REF)"; \
|
||||
candidate_ref="$(CANDIDATE_REF)"; \
|
||||
if [ -z "$$base_ref" ]; then base_ref="origin/main"; fi; \
|
||||
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
|
||||
./scripts/policy/check-authoritative-interface-baselines.sh \
|
||||
--base-ref "$$base_ref" \
|
||||
--stable-ref "$(STABLE_REF)" \
|
||||
--candidate-ref "$$candidate_ref"
|
||||
|
||||
authoritative-interface-integrity:
|
||||
@./scripts/policy/check-authoritative-interface-baselines.sh --base-ref "$(BASE_REF)"
|
||||
@candidate_ref="$(CANDIDATE_REF)"; \
|
||||
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
|
||||
./scripts/policy/check-authoritative-interface-baselines.sh \
|
||||
--base-ref "$(BASE_REF)" \
|
||||
--stable-ref "$(STABLE_REF)" \
|
||||
--candidate-ref "$$candidate_ref"
|
||||
|
||||
coverage-gate:
|
||||
@./scripts/policy/check-coverage-gate.sh --base-ref "$(BASE_REF)" --scope-buildable
|
||||
@@ -120,7 +133,12 @@ reset-interface-baseline:
|
||||
@./scripts/policy/check-interface-baseline.sh --reset
|
||||
|
||||
schema-compatibility:
|
||||
@./scripts/policy/check-authoritative-schema-compatibility.sh --base-ref "$(BASE_REF)"
|
||||
@candidate_ref="$(CANDIDATE_REF)"; \
|
||||
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
|
||||
./scripts/policy/check-authoritative-schema-compatibility.sh \
|
||||
--base-ref "$(BASE_REF)" \
|
||||
--stable-ref "$(STABLE_REF)" \
|
||||
--candidate-ref "$$candidate_ref"
|
||||
|
||||
skill-command-integrity:
|
||||
@./scripts/policy/check-skill-commands.sh
|
||||
|
||||
@@ -471,7 +471,7 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
|
||||
<details>
|
||||
<summary><strong>Personal Event Subscription</strong> — real-time DingTalk messages for event-driven agents</summary>
|
||||
|
||||
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, and group title/disband lifecycle events.
|
||||
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and six OA approval task/instance events.
|
||||
|
||||
The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`.
|
||||
|
||||
@@ -481,28 +481,33 @@ For an event-focused installation, use the official convenience installer:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
|
||||
|
||||
# Or install the standalone multi skill from an existing dws installation
|
||||
dws skill setup --mode multi -s event
|
||||
```
|
||||
|
||||
```bash
|
||||
# Inspect the public personal event catalog and schema
|
||||
dws event list
|
||||
dws event schema user_im_message_receive_o2o --flatten
|
||||
dws event list --category oa
|
||||
dws event schema user_oa_approval_task_created --flatten
|
||||
|
||||
# Listen for messages that mention the current user
|
||||
dws event consume user_im_message_receive_at --flatten -f ndjson
|
||||
dws event +listen-im --kind at-me -f ndjson
|
||||
|
||||
# Listen for one-to-one messages with a specified user
|
||||
dws event consume user_im_message_receive_o2o --user <userId> --flatten -f ndjson
|
||||
# Listen for messages from a specified sender
|
||||
dws event +listen-im --kind sender --user <userId> -f ndjson
|
||||
|
||||
# Listen by openDingtalkId (external contact, bot, or cross-organization identity)
|
||||
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> --flatten -f ndjson
|
||||
dws event +listen-im --kind sender --open-dingtalk-id <openDingtalkId> -f ndjson
|
||||
|
||||
# Listen for messages in a specified group
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
|
||||
dws event +listen-im --kind group --chat-id <openConversationId> -f ndjson
|
||||
|
||||
# Listen for all one-to-one or all group messages
|
||||
dws event consume user_im_message_receive_o2o_all --flatten -f ndjson
|
||||
dws event consume user_im_message_receive_group_all --flatten -f ndjson
|
||||
dws event +listen-im --kind all-direct -f ndjson
|
||||
dws event +listen-im --kind all-group -f ndjson
|
||||
|
||||
# Listen for a specified group's title changes, member changes, or disband event
|
||||
dws event consume user_im_group_updated --group <openConversationId> --flatten -f ndjson
|
||||
@@ -510,14 +515,19 @@ dws event consume user_im_group_member_added --group <openConversationId> --flat
|
||||
dws event consume user_im_group_member_exited --group <openConversationId> --flatten -f ndjson
|
||||
dws event consume user_im_group_disbanded --group <openConversationId> --flatten -f ndjson
|
||||
|
||||
# Listen for multiple events for the same user in one process
|
||||
# Listen for messages, reads, and recalls from the same sender in one process
|
||||
dws event +listen-im --kind sender --user <userId> \
|
||||
--events message,read,recall -f ndjson
|
||||
|
||||
# Listen for all six public OA approval events in one process
|
||||
dws event consume \
|
||||
user_im_message_receive_o2o \
|
||||
user_im_message_read_o2o \
|
||||
user_im_message_recall_o2o \
|
||||
--user <userId> \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
user_oa_approval_task_created \
|
||||
user_oa_approval_task_finished \
|
||||
user_oa_approval_task_redirected \
|
||||
user_oa_approval_instance_started \
|
||||
user_oa_approval_instance_terminated \
|
||||
user_oa_approval_instance_finished \
|
||||
--flatten -f ndjson
|
||||
|
||||
# Inspect local consumers and cancel a subscription
|
||||
dws event status
|
||||
@@ -536,7 +546,7 @@ For one-to-one and specified-sender events, use exactly one target identity: `--
|
||||
| Observability | `status` shows remote subscriptions, the personal bus, and local consumers |
|
||||
| Cross-platform | Unix Socket on macOS/Linux, Windows Named Pipe on Windows |
|
||||
|
||||
See `skills/multi/dingtalk-misc/references/event.md` for the Agent workflow and supported event parameters.
|
||||
See `skills/multi/dingtalk-event/SKILL.md` for the Agent workflow and supported event parameters.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+26
-16
@@ -465,7 +465,7 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
<details>
|
||||
<summary><strong>个人事件订阅</strong> — 实时接收钉钉消息,驱动事件触发的 Agent</summary>
|
||||
|
||||
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应,以及群标题变更和群解散事件。
|
||||
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及六个 OA 审批任务/实例事件。
|
||||
|
||||
默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。
|
||||
|
||||
@@ -475,28 +475,33 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
|
||||
|
||||
# 或在已有 dws 环境中安装独立的 multi skill
|
||||
dws skill setup --mode multi -s event
|
||||
```
|
||||
|
||||
```bash
|
||||
# 查看公开个人事件目录和 schema
|
||||
dws event list
|
||||
dws event schema user_im_message_receive_o2o --flatten
|
||||
dws event list --category oa
|
||||
dws event schema user_oa_approval_task_created --flatten
|
||||
|
||||
# 监听当前用户被 @ 的消息
|
||||
dws event consume user_im_message_receive_at --flatten -f ndjson
|
||||
dws event +listen-im --kind at-me -f ndjson
|
||||
|
||||
# 监听与指定用户的单聊消息
|
||||
dws event consume user_im_message_receive_o2o --user <userId> --flatten -f ndjson
|
||||
# 监听指定发送人的消息
|
||||
dws event +listen-im --kind sender --user <userId> -f ndjson
|
||||
|
||||
# 使用 openDingtalkId 监听外部联系人、机器人或跨组织身份
|
||||
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> --flatten -f ndjson
|
||||
dws event +listen-im --kind sender --open-dingtalk-id <openDingtalkId> -f ndjson
|
||||
|
||||
# 监听指定群的消息
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
|
||||
dws event +listen-im --kind group --chat-id <openConversationId> -f ndjson
|
||||
|
||||
# 监听所有单聊或所有群消息
|
||||
dws event consume user_im_message_receive_o2o_all --flatten -f ndjson
|
||||
dws event consume user_im_message_receive_group_all --flatten -f ndjson
|
||||
dws event +listen-im --kind all-direct -f ndjson
|
||||
dws event +listen-im --kind all-group -f ndjson
|
||||
|
||||
# 监听指定群标题变更、成员进退群或群解散
|
||||
dws event consume user_im_group_updated --group <openConversationId> --flatten -f ndjson
|
||||
@@ -504,14 +509,19 @@ dws event consume user_im_group_member_added --group <openConversationId> --flat
|
||||
dws event consume user_im_group_member_exited --group <openConversationId> --flatten -f ndjson
|
||||
dws event consume user_im_group_disbanded --group <openConversationId> --flatten -f ndjson
|
||||
|
||||
# 一个进程监听同一用户的多个事件
|
||||
# 一个进程监听同一发送人的消息、已读和撤回
|
||||
dws event +listen-im --kind sender --user <userId> \
|
||||
--events message,read,recall -f ndjson
|
||||
|
||||
# 一个进程监听全部六个公开 OA 审批事件
|
||||
dws event consume \
|
||||
user_im_message_receive_o2o \
|
||||
user_im_message_read_o2o \
|
||||
user_im_message_recall_o2o \
|
||||
--user <userId> \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
user_oa_approval_task_created \
|
||||
user_oa_approval_task_finished \
|
||||
user_oa_approval_task_redirected \
|
||||
user_oa_approval_instance_started \
|
||||
user_oa_approval_instance_terminated \
|
||||
user_oa_approval_instance_finished \
|
||||
--flatten -f ndjson
|
||||
|
||||
# 查看本地 consume,并取消指定订阅
|
||||
dws event status
|
||||
@@ -530,7 +540,7 @@ dws event stop <subscribe_id>
|
||||
| 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers |
|
||||
| 跨平台 | macOS/Linux 使用 Unix Socket,Windows 使用 Named Pipe |
|
||||
|
||||
Agent 工作流和事件参数详见 `skills/multi/dingtalk-misc/references/event.md`。
|
||||
Agent 工作流和事件参数详见 `skills/multi/dingtalk-event/SKILL.md`。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -17,18 +17,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var newRootCommand = func() *cobra.Command { return app.NewRootCommand() }
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
@@ -112,7 +117,11 @@ func runGenerate(args []string, stdout, stderr io.Writer) error {
|
||||
defer i18n.SetLang(previousLang)
|
||||
i18n.SetLang("en")
|
||||
|
||||
snapshot := interfacesnapshot.Capture(app.NewRootCommand())
|
||||
root := newRootCommand()
|
||||
snapshot := interfacesnapshot.Capture(root)
|
||||
if err := validateHelpRendering(root, snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
if *output == "-" {
|
||||
return interfacesnapshot.Write(stdout, snapshot)
|
||||
}
|
||||
@@ -138,6 +147,16 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
currentPath := flags.String("current", "", "candidate snapshot path")
|
||||
basePath := flags.String("base", "", "target main/development baseline snapshot path")
|
||||
stablePath := flags.String("stable", "", "latest stable GA snapshot path")
|
||||
approvedMigrationsPath := flags.String(
|
||||
"approved-flag-migrations",
|
||||
"",
|
||||
"merge-base-owned approved flag migration manifest",
|
||||
)
|
||||
candidateMigrationsPath := flags.String(
|
||||
"candidate-flag-migrations",
|
||||
"",
|
||||
"candidate flag migration manifest",
|
||||
)
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -150,6 +169,14 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
if *basePath == "" && *stablePath == "" {
|
||||
return false, fmt.Errorf("compare requires --base, --stable, or both")
|
||||
}
|
||||
if (*approvedMigrationsPath == "") != (*candidateMigrationsPath == "") {
|
||||
return false, fmt.Errorf(
|
||||
"--approved-flag-migrations and --candidate-flag-migrations must be provided together",
|
||||
)
|
||||
}
|
||||
if *approvedMigrationsPath != "" && (*basePath == "" || *stablePath == "") {
|
||||
return false, fmt.Errorf("flag migration compare requires both --base and --stable")
|
||||
}
|
||||
|
||||
current, err := readSnapshot(*currentPath)
|
||||
if err != nil {
|
||||
@@ -170,6 +197,25 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
}
|
||||
|
||||
report := interfacesnapshot.CompareAll(current, references)
|
||||
if *approvedMigrationsPath != "" {
|
||||
approved, readErr := readFlagMigrationManifest(*approvedMigrationsPath)
|
||||
if readErr != nil {
|
||||
return false, fmt.Errorf("read approved flag migrations: %w", readErr)
|
||||
}
|
||||
candidate, readErr := readFlagMigrationManifest(*candidateMigrationsPath)
|
||||
if readErr != nil {
|
||||
return false, fmt.Errorf("read candidate flag migrations: %w", readErr)
|
||||
}
|
||||
report, err = interfacesnapshot.CompareAllWithFlagMigrations(
|
||||
current,
|
||||
references,
|
||||
approved,
|
||||
candidate,
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("validate flag migration lifecycle: %w", err)
|
||||
}
|
||||
}
|
||||
encoder := json.NewEncoder(stdout)
|
||||
encoder.SetEscapeHTML(false)
|
||||
encoder.SetIndent("", " ")
|
||||
@@ -179,6 +225,49 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
return report.Compatible, nil
|
||||
}
|
||||
|
||||
func readFlagMigrationManifest(path string) (interfacesnapshot.FlagMigrationManifest, error) {
|
||||
file, err := os.Open(filepath.Clean(path))
|
||||
if err != nil {
|
||||
return interfacesnapshot.FlagMigrationManifest{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
return interfacesnapshot.ReadFlagMigrationManifest(file)
|
||||
}
|
||||
|
||||
func validateHelpRendering(root *cobra.Command, snapshot interfacesnapshot.Snapshot) error {
|
||||
for _, command := range snapshot.Commands {
|
||||
path := strings.TrimPrefix(command.Path, "dws")
|
||||
resolved, remaining, err := root.Find(strings.Fields(path))
|
||||
if err != nil || len(remaining) != 0 || resolved == nil {
|
||||
return fmt.Errorf("resolve %q before help rendering: remaining=%v error=%v", command.Path, remaining, err)
|
||||
}
|
||||
if err := renderCommandHelp(resolved); err != nil {
|
||||
return fmt.Errorf("render %q help: %w", command.Path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderCommandHelp(command *cobra.Command) (err error) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
command.InitDefaultHelpFlag()
|
||||
command.SetOut(&stdout)
|
||||
command.SetErr(&stderr)
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
err = fmt.Errorf("help renderer panicked: %v", recovered)
|
||||
}
|
||||
}()
|
||||
command.HelpFunc()(command, []string{})
|
||||
if stderr.Len() > 0 {
|
||||
return fmt.Errorf("help renderer wrote an error: %s", strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
if stdout.Len() == 0 {
|
||||
return fmt.Errorf("help renderer produced empty output")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
|
||||
file, err := os.Open(filepath.Clean(path))
|
||||
if err != nil {
|
||||
@@ -191,5 +280,5 @@ func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
|
||||
func printUsage(w io.Writer) {
|
||||
fmt.Fprintln(w, "usage:")
|
||||
fmt.Fprintln(w, " interface-snapshot generate [--output FILE]")
|
||||
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE]")
|
||||
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE]")
|
||||
}
|
||||
|
||||
@@ -15,11 +15,16 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRunGenerateCapturesActualRootOffline(t *testing.T) {
|
||||
@@ -56,6 +61,24 @@ func TestCrossPlatformCoverageRunGenerateCapturesActualRootOffline(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunGenerateRejectsHelpRenderingFailure(t *testing.T) {
|
||||
testseam.Swap(t, &newRootCommand, func() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetHelpFunc(func(command *cobra.Command, _ []string) {
|
||||
_, _ = io.WriteString(command.ErrOrStderr(), "injected help failure")
|
||||
})
|
||||
return root
|
||||
})
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exitCode := run([]string{"generate"}, &stdout, &stderr); exitCode != 2 {
|
||||
t.Fatalf("run(generate) exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "injected help failure") {
|
||||
t.Fatalf("run(generate) stderr=%q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareUsesBothSnapshotInputsAndExitCode(t *testing.T) {
|
||||
current := commandSnapshot("dws")
|
||||
mergeBase := commandSnapshot("dws")
|
||||
@@ -83,6 +106,368 @@ func TestCrossPlatformCoverageRunCompareUsesBothSnapshotInputsAndExitCode(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareEnforcesBaseOwnedFlagMigrationLifecycle(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
before := flagMigrationSnapshot(false)
|
||||
after := flagMigrationSnapshot(true)
|
||||
currentPath := writeSnapshot(t, dir, "current.json", after)
|
||||
basePath := writeSnapshot(t, dir, "base.json", before)
|
||||
stablePath := writeSnapshot(t, dir, "stable.json", before)
|
||||
approvedPath := writeManifest(t, dir, "approved.json", flagMigrationManifestJSON("pending"))
|
||||
candidatePath := writeManifest(t, dir, "candidate.json", flagMigrationManifestJSON("consumed"))
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{
|
||||
"compare",
|
||||
"--current", currentPath,
|
||||
"--base", basePath,
|
||||
"--stable", stablePath,
|
||||
"--approved-flag-migrations", approvedPath,
|
||||
"--candidate-flag-migrations", candidatePath,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exact base-owned migration exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"compatible": true`)) {
|
||||
t.Fatalf("exact migration report is not compatible:\n%s", stdout.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
emptyApproved := writeManifest(t, dir, "empty-approved.json", `{"version":1,"migrations":[]}`)
|
||||
exitCode = run([]string{
|
||||
"compare",
|
||||
"--current", currentPath,
|
||||
"--base", basePath,
|
||||
"--stable", stablePath,
|
||||
"--approved-flag-migrations", emptyApproved,
|
||||
"--candidate-flag-migrations", candidatePath,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "must start pending") {
|
||||
t.Fatalf("candidate self-approval exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareRequiresBothFlagMigrationInputs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
|
||||
basePath := writeSnapshot(t, dir, "base.json", commandSnapshot("dws"))
|
||||
approvedPath := writeManifest(t, dir, "approved.json", `{"version":1,"migrations":[]}`)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{
|
||||
"compare",
|
||||
"--current", currentPath,
|
||||
"--base", basePath,
|
||||
"--approved-flag-migrations", approvedPath,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "must be provided together") {
|
||||
t.Fatalf("one-sided migration input exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareRequiresBothReferencesForFlagMigrations(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
|
||||
basePath := writeSnapshot(t, dir, "base.json", commandSnapshot("dws"))
|
||||
stablePath := writeSnapshot(t, dir, "stable.json", commandSnapshot("dws"))
|
||||
approvedPath := writeManifest(t, dir, "approved.json", `{"version":1,"migrations":[]}`)
|
||||
candidatePath := writeManifest(t, dir, "candidate.json", `{"version":1,"migrations":[]}`)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "missing stable",
|
||||
args: []string{"--base", basePath},
|
||||
},
|
||||
{
|
||||
name: "missing base",
|
||||
args: []string{"--stable", stablePath},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
args := []string{"compare", "--current", currentPath}
|
||||
args = append(args, test.args...)
|
||||
args = append(args,
|
||||
"--approved-flag-migrations", approvedPath,
|
||||
"--candidate-flag-migrations", candidatePath,
|
||||
)
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run(args, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "requires both --base and --stable") {
|
||||
t.Fatalf("one-reference migration compare exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPrintsUsageForMissingAndUnknownCommands(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantStderr []string
|
||||
}{
|
||||
{
|
||||
name: "missing command",
|
||||
args: nil,
|
||||
wantStderr: []string{"usage:", "interface-snapshot generate", "--approved-flag-migrations"},
|
||||
},
|
||||
{
|
||||
name: "unknown command",
|
||||
args: []string{"unknown"},
|
||||
wantStderr: []string{`unknown command "unknown"`, "usage:", "interface-snapshot compare"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
|
||||
t.Fatalf("run(%v) exit=%d, want 2", test.args, exitCode)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("run(%v) unexpectedly wrote stdout: %s", test.args, stdout.String())
|
||||
}
|
||||
for _, want := range test.wantStderr {
|
||||
if !strings.Contains(stderr.String(), want) {
|
||||
t.Errorf("run(%v) stderr missing %q:\n%s", test.args, want, stderr.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunRejectsInvalidSubcommandArguments(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "generate unknown flag", args: []string{"generate", "--unknown"}, want: "flag provided but not defined"},
|
||||
{name: "generate positional", args: []string{"generate", "unexpected"}, want: "generate accepts no positional arguments"},
|
||||
{name: "compare unknown flag", args: []string{"compare", "--unknown"}, want: "flag provided but not defined"},
|
||||
{name: "compare positional", args: []string{"compare", "unexpected"}, want: "compare accepts no positional arguments"},
|
||||
{name: "compare missing current", args: []string{"compare", "--base", "base.json"}, want: "compare requires --current"},
|
||||
{name: "compare missing reference", args: []string{"compare", "--current", "current.json"}, want: "compare requires --base, --stable, or both"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
|
||||
t.Fatalf("run(%v) exit=%d, want 2", test.args, exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), test.want) {
|
||||
t.Fatalf("run(%v) stderr missing %q:\n%s", test.args, test.want, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunGenerateRejectsUnsafeOutputPath(t *testing.T) {
|
||||
outputDirectory := t.TempDir()
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{"generate", "--output", outputDirectory}, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "create snapshot") {
|
||||
t.Fatalf("directory output exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunGenerateReportsTemporaryDirectoryFailure(t *testing.T) {
|
||||
missingTempRoot := filepath.Join(t.TempDir(), "missing")
|
||||
for _, name := range []string{"TMPDIR", "TMP", "TEMP"} {
|
||||
t.Setenv(name, missingTempRoot)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{"generate"}, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "create isolated home") {
|
||||
t.Fatalf("invalid temporary root exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareReportsSnapshotReadFailures(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
validPath := writeSnapshot(t, dir, "valid.json", commandSnapshot("dws"))
|
||||
missingPath := filepath.Join(dir, "missing.json")
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "current",
|
||||
args: []string{"compare", "--current", missingPath, "--base", validPath},
|
||||
want: "read current snapshot",
|
||||
},
|
||||
{
|
||||
name: "main",
|
||||
args: []string{"compare", "--current", validPath, "--base", missingPath},
|
||||
want: "read main/development baseline snapshot",
|
||||
},
|
||||
{
|
||||
name: "stable",
|
||||
args: []string{"compare", "--current", validPath, "--stable", missingPath},
|
||||
want: "read stable snapshot",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
|
||||
t.Fatalf("run(compare) exit=%d, want 2", exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), test.want) {
|
||||
t.Fatalf("stderr missing %q:\n%s", test.want, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareReportsEachManifestReadFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
|
||||
validManifest := writeManifest(t, dir, "valid-manifest.json", `{"version":1,"migrations":[]}`)
|
||||
invalidManifest := writeManifest(t, dir, "invalid-manifest.json", `{`)
|
||||
tests := []struct {
|
||||
name string
|
||||
approved string
|
||||
candidate string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "approved manifest",
|
||||
approved: invalidManifest,
|
||||
candidate: validManifest,
|
||||
want: "read approved flag migrations",
|
||||
},
|
||||
{
|
||||
name: "candidate manifest",
|
||||
approved: validManifest,
|
||||
candidate: invalidManifest,
|
||||
want: "read candidate flag migrations",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{
|
||||
"compare",
|
||||
"--current", snapshotPath,
|
||||
"--base", snapshotPath,
|
||||
"--stable", snapshotPath,
|
||||
"--approved-flag-migrations", test.approved,
|
||||
"--candidate-flag-migrations", test.candidate,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), test.want) {
|
||||
t.Fatalf("%s exit=%d stdout=%s stderr=%s", test.name, exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareReportsOutputFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
|
||||
var stderr bytes.Buffer
|
||||
exitCode := run([]string{
|
||||
"compare",
|
||||
"--current", snapshotPath,
|
||||
"--base", snapshotPath,
|
||||
}, failingWriter{}, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "write comparison report") {
|
||||
t.Fatalf("comparison output failure exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadHelpersRejectMissingAndInvalidInputs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
missingPath := filepath.Join(dir, "missing.json")
|
||||
invalidPath := filepath.Join(dir, "invalid.json")
|
||||
if err := os.WriteFile(invalidPath, []byte(`{`), 0o600); err != nil {
|
||||
t.Fatalf("write invalid fixture: %v", err)
|
||||
}
|
||||
|
||||
if _, err := readSnapshot(missingPath); err == nil {
|
||||
t.Fatal("readSnapshot(missing) unexpectedly succeeded")
|
||||
}
|
||||
if _, err := readSnapshot(invalidPath); err == nil {
|
||||
t.Fatal("readSnapshot(invalid) unexpectedly succeeded")
|
||||
}
|
||||
if _, err := readFlagMigrationManifest(missingPath); err == nil {
|
||||
t.Fatal("readFlagMigrationManifest(missing) unexpectedly succeeded")
|
||||
}
|
||||
if _, err := readFlagMigrationManifest(invalidPath); err == nil {
|
||||
t.Fatal("readFlagMigrationManifest(invalid) unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingReportsResolveError(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
err := validateHelpRendering(root, commandSnapshot("dws missing"))
|
||||
if err == nil || !strings.Contains(err.Error(), `resolve "dws missing" before help rendering`) {
|
||||
t.Fatalf("validateHelpRendering resolve error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingReportsTemplateError(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetHelpTemplate(`{{index .Commands 99}}`)
|
||||
err := validateHelpRendering(root, commandSnapshot("dws"))
|
||||
if err == nil || !strings.Contains(err.Error(), `render "dws" help`) {
|
||||
t.Fatalf("validateHelpRendering template error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingRecoversTemplatePanic(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetHelpTemplate("{{")
|
||||
err := validateHelpRendering(root, commandSnapshot("dws"))
|
||||
if err == nil || !strings.Contains(err.Error(), `render "dws" help`) {
|
||||
t.Fatalf("validateHelpRendering template panic = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingRejectsCustomHelpStderr(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetHelpFunc(func(command *cobra.Command, _ []string) {
|
||||
_, _ = io.WriteString(command.ErrOrStderr(), "injected help failure")
|
||||
})
|
||||
err := validateHelpRendering(root, commandSnapshot("dws"))
|
||||
if err == nil || !strings.Contains(err.Error(), "injected help failure") {
|
||||
t.Fatalf("validateHelpRendering custom stderr = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingRejectsEmptyOutput(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetHelpFunc(func(*cobra.Command, []string) {})
|
||||
err := validateHelpRendering(root, commandSnapshot("dws"))
|
||||
if err == nil || !strings.Contains(err.Error(), "empty") {
|
||||
t.Fatalf("validateHelpRendering empty output = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingAcceptsNormalOutput(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws", Short: "root command"}
|
||||
if err := validateHelpRendering(root, commandSnapshot("dws")); err != nil {
|
||||
t.Fatalf("validateHelpRendering normal output: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type failingWriter struct{}
|
||||
|
||||
func (failingWriter) Write([]byte) (int, error) {
|
||||
return 0, errors.New("injected write failure")
|
||||
}
|
||||
|
||||
var _ io.Writer = failingWriter{}
|
||||
|
||||
func commandSnapshot(paths ...string) interfacesnapshot.Snapshot {
|
||||
commands := make([]interfacesnapshot.Command, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
@@ -120,6 +505,68 @@ func writeSnapshot(t *testing.T, dir, name string, snapshot interfacesnapshot.Sn
|
||||
return path
|
||||
}
|
||||
|
||||
func writeManifest(t *testing.T, dir, name, contents string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func flagMigrationSnapshot(after bool) interfacesnapshot.Snapshot {
|
||||
legacy := interfacesnapshot.Flag{
|
||||
Name: "legacy-id",
|
||||
Shorthand: "l",
|
||||
Type: "string",
|
||||
Default: "",
|
||||
NoOpt: "auto",
|
||||
Required: true,
|
||||
}
|
||||
flags := []interfacesnapshot.Flag{legacy}
|
||||
if after {
|
||||
legacy.Required = false
|
||||
legacy.Hidden = true
|
||||
legacy.AliasOf = "message-id"
|
||||
flags = []interfacesnapshot.Flag{
|
||||
legacy,
|
||||
{Name: "message-id", Type: "string", Default: "", Required: true},
|
||||
}
|
||||
}
|
||||
return interfacesnapshot.Snapshot{
|
||||
SchemaVersion: interfacesnapshot.SchemaVersion,
|
||||
Rules: interfacesnapshot.Rules{
|
||||
ExcludedCommandSubtrees: []string{},
|
||||
ExcludedFlags: []string{},
|
||||
},
|
||||
Commands: []interfacesnapshot.Command{
|
||||
{Path: "dws", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
|
||||
{Path: "dws chat send", Runnable: true, Aliases: []string{}, LocalFlags: flags, InheritedFlags: []interfacesnapshot.Flag{}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func flagMigrationManifestJSON(state string) string {
|
||||
return strings.Replace(`{
|
||||
"version": 1,
|
||||
"migrations": [{
|
||||
"command": "dws chat send",
|
||||
"legacy": {
|
||||
"name": "legacy-id",
|
||||
"before": {"present": true, "type": "string", "required": true, "shorthand": "l", "no_opt": "auto", "scope": "local"},
|
||||
"after": {"present": true, "type": "string", "hidden": true, "shorthand": "l", "no_opt": "auto", "scope": "local", "alias_of": "message-id"}
|
||||
},
|
||||
"canonical": {
|
||||
"name": "message-id",
|
||||
"before": {"present": false},
|
||||
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
|
||||
},
|
||||
"state": "STATE",
|
||||
"reason": "reviewed exact migration"
|
||||
}]
|
||||
}`, "STATE", state, 1)
|
||||
}
|
||||
|
||||
func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == name && flag.Type == flagType {
|
||||
|
||||
+39
-8
@@ -157,15 +157,33 @@ expected to repeat every CI job locally:
|
||||
```sh
|
||||
make build
|
||||
make policy
|
||||
make interface-integrity
|
||||
make authoritative-interface-integrity BASE_REF=<merge-base>
|
||||
make schema-compatibility BASE_REF=<merge-base>
|
||||
make interface-integrity BASE_REF=<merge-base> STABLE_REF=<stable-tag> CANDIDATE_REF=<candidate-sha>
|
||||
make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<stable-tag> CANDIDATE_REF=<candidate-sha>
|
||||
make skill-command-integrity
|
||||
make cli-smoke
|
||||
make mock-mcp-smoke
|
||||
go test -v -count=1 ./pkg/editiontest/...
|
||||
```
|
||||
|
||||
CI 先解析并核对精确的 merge-base、最近可达且未撤回的 stable GA tag 和已提交的 candidate
|
||||
SHA,再调用 `make authoritative-interface-integrity`。本地 `make interface-integrity`
|
||||
与该 CI target 都只委托给同一个 modern authoritative wrapper,不存在第二个比较
|
||||
入口。省略 `BASE_REF` 时本地 target 默认比较 `origin/main`,省略 `STABLE_REF` 时自动
|
||||
选择该 base 可达且未撤回的最近 stable GA tag,省略 `CANDIDATE_REF` 时比较已提交的 `HEAD`。
|
||||
需要逐字复现某次 CI 时,应显式传入该次运行记录的 merge-base、stable tag 和
|
||||
candidate SHA。
|
||||
|
||||
`make update-interface-baseline` / `make reset-interface-baseline` 只维护
|
||||
`test/fixtures/cli-interface-baseline.txt` 这一份非权威 CLI Smoke fixture。底层旧
|
||||
`check-interface-baseline.sh` 不再作为本地或 CI 的兼容性审批入口,也不能用于批准
|
||||
flag 迁移。
|
||||
|
||||
Schema compatibility 使用同一组 base、stable、candidate refs 和同一份 base-owned flag
|
||||
migration ledger。merge-base-owned checker 分别规范化 merge-base 与 stable 的完整
|
||||
Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过 Interface
|
||||
lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会
|
||||
放宽其他 Schema 历史字段。
|
||||
|
||||
For an exact CHANGELOG-only branch:
|
||||
|
||||
```sh
|
||||
@@ -186,11 +204,24 @@ are evaluated by the same block-deduplicating checker; supporting policy and
|
||||
shortcut profiles contribute to changed-code coverage only. The checked-in
|
||||
badge is presentation only and is never read as a gate input.
|
||||
|
||||
Compatibility checks derive authoritative Interface snapshots from the PR
|
||||
merge-base and the latest reachable stable release. The candidate cannot bless
|
||||
a breaking change by editing a fixture. Schema additions are allowed;
|
||||
historical products, tools, parameters, mappings, positional execution fields,
|
||||
constraints, and safety semantics remain protected.
|
||||
CLI 兼容检查只使用 modern Interface Snapshot 这一处权威比较 seam,并从 PR
|
||||
merge-base 和最近的可达 stable release 生成权威快照。本治理机制合入后,
|
||||
merge-base 拥有生成器、比较器和已审批迁移清单,因此 candidate 不能通过修改
|
||||
helper、fixture 或在同一 PR 新增 self-approval 记录来放行 breaking change。首次
|
||||
bootstrap 仍由 merge-base 已有的 modern helper 做无豁免比较,并只接受 candidate
|
||||
提交中的规范空清单;完整边界见下方治理文档。
|
||||
|
||||
精确的两阶段 flag 迁移生命周期见
|
||||
[CLI flag 兼容迁移治理](cli-interface-flag-migrations.md)。治理 PR 只能在
|
||||
surface 未变化时新增 `pending`;后续产品 PR 达到审批的精确 surface 后,才能
|
||||
消费 base-owned 记录并改为 `consumed`。在 main 与 stable 都达到 after 状态前
|
||||
必须保留该回执,之后再由单独 PR 清理。机制只放行记录中的 legacy
|
||||
visible-to-hidden,以及 canonical required 新增或提升;删除、type、scope、
|
||||
shorthand、no-opt 和任何无关漂移仍然阻塞。Schema 可以新增;历史 product、
|
||||
tool、parameter、mapping、positional execution、constraint 与 safety 语义继续
|
||||
受保护。`alias_of` 只是一项由 `FlagSpec.Aliases` 产生的框架关系证据,不是 payload
|
||||
等价证明;产品 PR 仍须证明 canonical 与 legacy 的最终运行 payload 等价并在 transport
|
||||
前拒绝冲突输入。当前迁移清单为空,不授权 PR #904。
|
||||
|
||||
## Required GitHub repository settings
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
# CLI flag 兼容迁移治理
|
||||
|
||||
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 提升为必填。它只解决这一种精确变更,不是通用 breaking-change 豁免。
|
||||
|
||||
同名 flag 的精确类型迁移属于另一类评审机制,只能进入
|
||||
`internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename
|
||||
只能进入本文的 JSON lifecycle ledger。一项迁移不得跨两种机制组合授权。
|
||||
|
||||
## 唯一比较入口与信任边界
|
||||
|
||||
PR 与本地兼容性审批的唯一权威比较入口是 modern Interface Snapshot:
|
||||
|
||||
- `cmd/interface-snapshot` 生成和比较快照;
|
||||
- `internal/interfacesnapshot` 实现兼容规则和迁移生命周期;
|
||||
- `scripts/policy/check-command-compatibility.sh` 组装 candidate、PR merge-base 和最近可达且未撤回的 stable GA 三份快照;
|
||||
- `scripts/policy/check-authoritative-interface-baselines.sh` 只保留为 Makefile 的兼容包装,不再维护第二套判断逻辑。
|
||||
|
||||
紧随其后的 Schema compatibility 不是第二份审批清单。它从同一 merge-base-owned
|
||||
ledger 和同一组三方 Interface Snapshot 取得已经完成 lifecycle 校验的
|
||||
authorization。merge-base-owned checker 会分别规范化 merge-base 与 stable 的完整
|
||||
Schema,并让 candidate 对两份历史 contract 独立执行检查;授权的 flag rename 只会
|
||||
精确投影到当前被检查的历史副本。candidate 不能为 CLI 与 Schema 分别提供两套例外。
|
||||
|
||||
`make interface-integrity` 也调用上述 authoritative wrapper;默认 base 为
|
||||
`origin/main`,stable 可由包装脚本自动解析,candidate 默认为已提交的 `HEAD`。旧
|
||||
`scripts/policy/check-interface-baseline.sh` 只供
|
||||
`make update-interface-baseline` / `make reset-interface-baseline` 维护非权威 CLI
|
||||
Smoke fixture,不参与迁移审批。
|
||||
|
||||
直接调用 `interface-snapshot compare` 时,只要提供 migration manifest 参数,就必须
|
||||
同时提供 `--base` 与 `--stable`;核心 lifecycle 也拒绝缺失 stable 的非空清单,避免
|
||||
调用方因漏传历史参考而提前清理 consumed receipt。
|
||||
|
||||
PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入本机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空清单,不会让 candidate 新增的 comparator 决定本 PR 是否兼容。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此本治理 PR 的新 parser、lifecycle、launcher 与 hostile tests 仍是必须由真人评审的受保护策略变更;它们合入后才成为后续 PR 的 base-owned authority。
|
||||
|
||||
这条边界保护比较规则和审批数据,不是任意代码沙箱。GitHub workflow / launcher 的变更仍由仓库保护规则和真人评审负责;candidate Cobra 构建也会执行 candidate 代码,因此对同一 runner 上的主动恶意代码,需要独立进程或文件系统隔离,不能把本门禁描述成已经解决。
|
||||
|
||||
已审批清单固定为:
|
||||
|
||||
```text
|
||||
scripts/policy/interface-migrations/approved-flag-migrations-v1.json
|
||||
```
|
||||
|
||||
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。当前清单为空,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。
|
||||
|
||||
## 两阶段迁移与回执清理
|
||||
|
||||
每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期:
|
||||
|
||||
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|
||||
|---|---|---|
|
||||
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
|
||||
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 达到记录的必填状态 |
|
||||
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
|
||||
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
|
||||
|
||||
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。
|
||||
|
||||
下面只是清单结构示例,不代表已审批命令;实际字段必须从 Interface Snapshot 核对:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"migrations": [
|
||||
{
|
||||
"command": "dws chat message recall",
|
||||
"legacy": {
|
||||
"name": "msg-id",
|
||||
"before": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"scope": "local"
|
||||
},
|
||||
"after": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"hidden": true,
|
||||
"scope": "local",
|
||||
"alias_of": "message-id"
|
||||
}
|
||||
},
|
||||
"canonical": {
|
||||
"name": "message-id",
|
||||
"before": { "present": false },
|
||||
"after": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"scope": "local"
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将规范 flag 设为唯一可见入口"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
产品迁移 PR 必须保持同一条记录的命令、flag、before/after 和 reason 不变,只把 `pending` 改成 `consumed`。
|
||||
|
||||
## `alias_of` 是框架来源的受评审关系证据
|
||||
|
||||
`alias_of` 不是 Schema 同义词、参数概念词典或任意文字声明。它只能由 `FlagSpec.Aliases` 写入,并与内部 origin `corecmd.flag_spec_aliases.v1` 成对出现;每次 Interface Integrity 都会在已提交的 detached candidate 上执行源码门禁,禁止其他生产文件写入或复刻这些 evidence token。Interface Snapshot 会验证:
|
||||
|
||||
- legacy 与 canonical 位于同一个可执行命令;
|
||||
- canonical flag 确实存在;
|
||||
- legacy 与 canonical 类型一致;
|
||||
- legacy 不是指向自身,也不存在 alias chain;
|
||||
- legacy 的 after 状态精确指向该记录中的 canonical flag。
|
||||
|
||||
通过命令框架声明 `FlagSpec.Aliases` 时,框架会自动注册隐藏的兼容 flag,并写入两项 relation annotation;仅手写 `alias_of`、伪造 origin、重复值或不精确值都会让快照生成失败。不要用 Schema overlay、迁移清单或手写 Cobra annotation 伪造关系。
|
||||
|
||||
这项关系证据只证明 legacy/canonical 经过受控框架路径建立关系,不证明最终 transport payload 等价,也不会替产品代码实现命令特有的值同步。当前框架还禁止把
|
||||
`MarkRequired` 与 `FlagSpec.Aliases` 直接组合,因为 Cobra 的 hard-required
|
||||
校验只识别 canonical spelling。若产品迁移同时需要 canonical 的 Cobra required
|
||||
标记和 legacy spelling,产品 PR 必须提供明确的运行时方案,并通过 canonical / legacy
|
||||
最终 payload 等价、同值输入一致、冲突输入在 transport 前失败、legacy 仍可调用但 Help 隐藏等测试;迁移清单和 relation evidence 都不能替代这些证明。
|
||||
|
||||
## 豁免边界
|
||||
|
||||
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
|
||||
|
||||
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
|
||||
2. canonical flag 的 `required_flag_added`(新增时即必填)或 `flag_became_required`(已有 flag 从可选变必填)。
|
||||
|
||||
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
|
||||
|
||||
- 删除 legacy、canonical、命令或其他 flag;
|
||||
- flag 类型或迁移记录中的 scope、shorthand、`no_opt` 漂移;
|
||||
- `alias_of` 缺失、指向变化或 alias chain;
|
||||
- 命令路径及任何无关的阻塞性接口变化;
|
||||
- 不精确、部分完成、超出记录范围的 surface 变化。
|
||||
|
||||
## Schema 投影边界
|
||||
|
||||
Agent-visible command 会把 visible Cobra flag 投影为 Schema parameter,因此合法的
|
||||
legacy hidden 迁移会同时表现为历史 parameter 消失,constraint member 也可能从
|
||||
legacy 名改为 canonical 名。Schema adapter 只接受已经由三方 Interface Snapshot
|
||||
判定为 authorized 的迁移,并按 tool 的精确 `primary_cli_path` 绑定:
|
||||
|
||||
- reference 仍处于 `before` 且该 flag 有 Schema surface 时,baseline legacy parameter
|
||||
必须存在,candidate legacy parameter 必须消失,candidate canonical parameter 必须存在;
|
||||
如果 baseline 只有 canonical、没有 legacy,则 adapter 不得借 CLI ledger 提升
|
||||
`required` / `cli_required` 或重写 constraint;
|
||||
- rename 前后的 `type`、`property`、`interface_type`、default、format、enum 与
|
||||
`required_when` 必须完全一致;
|
||||
- `required` / `cli_required` 只能保持不变或按审批从 `false` 提升为 `true`,禁止降低;
|
||||
- constraint 只允许在同一 tool 内按已枚举的 legacy → canonical map 做 member 替换、
|
||||
排序与去重;group kind、非迁移 member 或 group 增删仍然阻塞;
|
||||
- 多个 legacy 指向同一 canonical 时,所有历史 parameter signature 必须一致,否则
|
||||
fail closed。
|
||||
|
||||
adapter 先构造经过上述验证的历史 contract 副本,再调用原 Schema checker;它不会按
|
||||
错误字符串删除 finding。这样既能处理纯 rename,也能阻止“旧 required 参数改名后意外
|
||||
变为 optional”或 property 漂移等伪兼容。`consumed` 回执在 merge-base Schema 已经处于
|
||||
canonical-only `after` 状态时不需要再次投影;adapter 保持 baseline 不变,由原 checker
|
||||
验证 candidate 是否仍与该 canonical contract 兼容。
|
||||
|
||||
## 本地验证
|
||||
|
||||
先确保 merge-base 和 stable tag 已在本地,然后运行与 CI 相同的权威门禁:
|
||||
|
||||
```sh
|
||||
make interface-integrity \
|
||||
BASE_REF=<merge-base> \
|
||||
STABLE_REF=<stable-tag> \
|
||||
CANDIDATE_REF=<candidate-sha>
|
||||
|
||||
make schema-compatibility \
|
||||
BASE_REF=<merge-base> \
|
||||
STABLE_REF=<stable-tag> \
|
||||
CANDIDATE_REF=<candidate-sha>
|
||||
```
|
||||
|
||||
`STABLE_REF` 必须解析到从该 merge-base 可达的最高未撤回 stable GA tag;primary checker 会按 release contract 独立核对,不能用任意 after commit 或已撤回版本提前清理回执。包装脚本可以在省略时自动解析。`CANDIDATE_REF` 省略时固定为命令启动时的已提交 `HEAD`;评审和复现 CI 时应显式传入 candidate SHA,避免 surface 与清单来自不同 revision。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -70,16 +70,17 @@
|
||||
|
||||
> 已落地:minutes `+detail`(✅ smart `+detail`)、minutes `+word-replace`(✅ smart `+replace-batch`,批量+去重)、base `+record-share-link-create`(✅ smart `+record-share-links`,>20 去重+分片+合并)、im `+threads-messages-list`(✅ smart `chat +thread-replies`,list_topic_replies + 投影)、im `+chat-list`(✅ smart `chat +chat-list`)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
|
||||
|
||||
### im → chat(5)
|
||||
### im → chat(7)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+chat-list` ✅ | read | **已建 smart `chat +chat-list`**:`list_all_conversations` + 默认仅群聊 + `--types group/p2p` 当前页过滤 + `--exclude-muted` + page-size/page-token 别名 + openConversationId/name/conversationType 投影。剩余未做:sort/sort-type、bot 身份 p2p 剥离(DWS 无对应身份模型) |
|
||||
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊 list_conversation_message_v2 / list_individual_chat_message 互斥 + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
|
||||
| `+chat-search` | read | dws 无群名模糊搜索v2对应 tool(search_common_groups/find 语义不同),缺 query规范化、mode映射、mute过滤、meta投影 |
|
||||
| `+chat-list` ✅ | read | **已建 smart `chat +chat-list`**:`list_all_conversations` + 默认仅群聊 + `--types group/p2p` + `--exclude-muted` + page-size/page-token 别名 + `--page-all/--page-limit` 数字 cursor 自动翻页、跨页去重、合并后类型过滤和完整性 ledger。剩余未做:sort/sort-type、bot 身份 p2p 剥离(DWS 无对应身份模型) |
|
||||
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊互斥解析、时间范围、asc/desc、时间边界全量翻页、reaction、资源下载与完整性 ledger |
|
||||
| `+chat-search` ✅ | read | **已建 smart `chat +chat-search`**:真实 `search_groups` 关键词搜索 + page-size/page-token 别名 + `--page-all/--page-limit` 不透明 cursor 自动翻页、跨页去重和完整性 ledger。Lark v2 的 member/type/mode/manager/sort 过滤没有可验证的钉钉对应参数,未伪造 |
|
||||
| `+flag-list` ✅ | read | **已建 smart `chat +flag-list`**:真实 `list_message_favorites` 的 `items + hasMore + 数字 nextCursor`,支持 page-size/page-token、`--page-all/--page-limit`、跨页去重和完整性 ledger;仅对齐 message favorite,不模拟 Lark Feed thread flag |
|
||||
| `+messages-resources-download` | write | dws download-media 走 get_resource_download_url 拿URL,缺分片Range下载/重试/扩展名推断/安全落盘路径校验 |
|
||||
| `+messages-search` | read | dws 有 search_messages_by_keyword/by_time_range/by_sender/at_me 多个原子 tool,但各自单点,缺统一多维filter编排+mget+chat上下文富化+跨字段Validate |
|
||||
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:list_topic_replies + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
|
||||
| `+messages-search` ✅ | read | **已建 smart `chat +search-msg`**:统一多维过滤、精确时间范围、asc/desc、cursor 全量翻页、mget 富化、reaction、资源下载与完整性 ledger。剩余差异是 Lark chat 上下文和部分 sender/attachment 类型过滤 |
|
||||
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:支持主消息 ID 自动只读解析 conversation/thread,也支持显式 group + thread/topic ID;list_topic_replies + sender/text/time/reaction/resource 投影 + 下层毫秒级 nextCursor 有界自动翻页、跨页去重、完整性 ledger,以及全量结果 asc/desc。与 Lark 的剩余差异是钉钉底层没有服务端 asc 单页,因此 DWS 的 asc 明确要求 `--page-all`,避免伪全局排序 |
|
||||
|
||||
### task → todo(3)
|
||||
|
||||
|
||||
@@ -1,209 +1,926 @@
|
||||
{
|
||||
"generated_at": "2026-08-05T22:43:52.497190",
|
||||
"count": 294,
|
||||
"generated_at": "2026-08-06T21:08:26.697858",
|
||||
"count": 357,
|
||||
"results": [
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+advperm-disable",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "关闭高级权限会使自定义角色失效,显式发布高风险事实。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+advperm-enable",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "开启 Base 高级权限总开关的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+attachment-put",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "完整执行 prepare、HTTP PUT、attachment cell 覆盖和读回验证;空写回包只有最终附件可证明时成功。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+attachment-remove",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "清空或按文件名移除附件;若读回缺剩余 fileToken,会在写前明确停止而不破坏其他附件。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+attachment-upload",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "申请附件 OSS 直传地址和 fileToken;只负责上传准备,不伪造文件已上传。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-bootstrap",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "声明式创建 Base、表和字段,字段自动分片,逐层读回验证并报告部分副作用。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-copy",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一完整复制与仅结构复制意图,并把目标目录映射为可执行参数。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "不可逆删除整个 Base 的兼容入口,显式发布高风险确认事实。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "保留 Base 目录读取的 Shortcut 兼容入口;底层 leaf 已提供同等读取能力。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-get-primary-doc-id",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "已知 base/table/record 时读取主键文档 dentryUuid 的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "把最近访问 Base 响应稳定投影为 baseId/baseName 列表,作为未知 baseId 时的浏览入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-schema-snapshot",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "完整读取 Base、tables、fields、views,逐层区分合法空集合与缺失响应契约。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按名称关键词定位 Base,并稳定投影可继续操作的 baseId/baseName。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+base-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "更新 Base 名称和备注的一对一写入,复用统一确认门。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+chart-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "删除图表及布局项,显式发布不可逆高风险事实。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+chart-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取指定图表完整配置的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+chart-share-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取图表分享配置的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+chart-share-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一开启、关闭和设置图表分享类型。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+chart-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一更新图表配置或布局,并要求显式 config 输入。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+chart-widgets-example",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "返回所有图表类型的 widget config 示例,供构造合法图表配置。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+dashboard-arrange",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "触发服务端智能布局重排并保留统一写入确认。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+dashboard-config-example",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "返回 dashboard config 的服务端结构示例,供后续写入构造合法参数。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+dashboard-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "级联删除仪表盘及图表,显式发布不可逆高风险事实。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+dashboard-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "读取仪表盘详情及 charts summary,作为图表操作导航入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+dashboard-share-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取仪表盘分享配置的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+dashboard-share-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一开启、关闭和设置仪表盘分享类型。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+dashboard-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "更新仪表盘配置的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+export-data",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "统一创建导出任务与按 taskId 续等,直到得到可下载的导出结果或明确失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+field-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "不可逆删除字段的一对一入口,显式发布数据丢失风险。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+field-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "批量获取字段完整类型配置,避免仅返回名称的损失投影。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+field-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一字段名称、类型配置和 AI 配置的增量更新输入。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+find-record",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "将关键词查询投影为稳定的 recordId/cells 结果,适合先定位再写入。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+form-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "不可逆删除表单视图的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+form-field-hide",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "用隐藏/显示布尔语义切换表单字段可见性。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+form-field-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "列出表单当前字段顺序、可见性、必填和描述配置。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+form-field-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一更新表单字段必填状态与描述。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+form-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "从表的视图中稳定筛出表单视图并返回表单目录。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+form-share-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取当前视图分享表单配置的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+form-share-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一开启或关闭指定视图的分享表单。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+form-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一更新表单标题和描述。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+import-data",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一将已上传文件导入新表或追加到已有表的目标语义。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+import-upload",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "申请导入 OSS 直传地址和 importId;只负责上传准备,不伪造导入完成。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+list-tables",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "把 Base 目录稳定投影为 tableId/tableName 列表,隐藏非表节点。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-bulk-patch",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "先完整解析显式选择范围和 max-matches,再将同一 cells patch 自动分片写入并逐条读回验证。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "自动按 100 条分片删除;每批仅在所有目标 recordId 读回均不存在时成功,失败提供 nextOffset。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-history-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "按 recordId 查询变更历史的一对一只读入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-primary-doc-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "以幂等意图为 primaryDoc 字段创建关联文档。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-primary-doc-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取记录主键文档 nodeId 的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-query",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一 ID、筛选、关键词和分页查询,并投影稳定的 records/count/cursor 结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-query-empty",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "扫描记录并按用户字段为空的语义过滤空行,避免调用方手工判断 cells。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-share-links",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "对超过 20 条 recordId 去重分片调用并合并分享链接及逐批结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-share-url",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "将最多 20 个 recordId 统一映射为可分享链接列表。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "自动按 100 条分片更新并逐条读回验证;失败返回可续跑的 nextOffset。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-upsert",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按 recordId 拆分 create/update,自动按 100 条分片;创建必须返回新 recordId,全部结果读回验证。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+record-upsert-by-key",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "先按唯一 fieldId/value 完整查询,0 条创建、1 条更新、2 条以上停止,并读回验证最终 cells。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+resolve-base",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按名称搜索并要求唯一匹配,0 或多结果时显式失败而不猜 baseId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+resolve-table",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在 Base 内按名称要求唯一匹配,0 或多结果时显式失败而不猜 tableId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+role-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一角色名称、成员与权限配置的创建输入。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+role-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "不可逆删除自定义角色的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+role-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取单个角色完整权限配置的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+role-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "列出 Base 全部自定义角色的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+role-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按 PATCH 语义增量更新角色配置,未提供字段保持不变。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+section-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "在 Base 目录树创建用于组织表和仪表盘的文件夹。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+section-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "不可逆删除目录文件夹的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+section-list-empty",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "扫描目录树并只返回没有子节点的空文件夹。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+section-list-nodes",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "列出当前版本全部 nsheet 节点,提供表、仪表盘和文件夹统一目录。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+section-move-node",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一移动任意 nsheet 节点到文件夹或 Base 根目录,并可指定位置。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+section-rename",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "重命名目录文件夹的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+section-reorder",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按当前父目录语义调整文件夹展示顺序。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+table-copy",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在没有服务端 table-copy task 的情况下,本地重建安全字段、映射 fieldId,并可分片复制及读回验证全部记录。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+table-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "不可逆删除数据表的一对一入口,显式发布高风险确认事实。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+table-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "批量读取表级信息、字段和视图目录,为后续 Schema 与记录操作提供 ID。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+table-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "统一表名、备注和行命名规则的 PATCH 风格更新。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+template-search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按名称关键词搜索 AI 表格模板并返回可用于创建的 templateId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+url-resolve",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "严格解析 alidocs AI 表格 URL 的 base/table/view/record ID,并可通过最深层只读接口验证目标存在。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "不可逆删除视图的一对一入口,显式发布高风险确认事实。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-duplicate",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "复制现有视图配置并生成新视图的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "读取视图完整列顺序、筛选、排序和分组配置。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-get-frozen-cols",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取 Grid 视图冻结列数的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-get-lock",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取视图锁定状态的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-get-row-height",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取视图行高像素值的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-lock",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "用默认锁定与 --off 解锁统一表达视图锁状态切换。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-preset-apply",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按精确名称幂等创建或更新视图预设,拒绝同名歧义和类型漂移,并读回校验 config。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-set-fill-color-rule",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "以全量覆盖语义设置条件填色规则,并用空数组明确清空。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-set-frozen-cols",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "设置冻结列数并以 0 明确表达取消冻结。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-set-row-height",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "把行高限制为服务支持的 32/56/88/128 像素档位后写入。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+view-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一视图名称、描述、可见列、筛选、排序和分组配置更新。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+workflow-deploy",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "创建或更新完整 workflow-dsl/v1,强制 valid=true/flowId,并可启用后从 list 验证 RUNNING。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+workflow-disable",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "禁用工作流会影响业务自动化,显式发布高风险确认事实。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+workflow-enable",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "启用指定自动化工作流的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+workflow-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取单个自动化工作流详情的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+workflow-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "分页列出 Base 自动化工作流的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
@@ -1964,7 +2681,7 @@
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "一体化创建会话、上传、确认转换并轮询导入结果。",
|
||||
"semantic_delta": "一体化创建会话、上传、确认转换并轮询导入结果;白名单外格式自动改走文件上传原样入库(fallback=upload、converted=false)。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -101,5 +101,5 @@ skills/mono/
|
||||
## 7. 变更流程
|
||||
|
||||
1. 改 / 增内容 → 更新 `skills/content-qa/mono-multi-coverage.yaml`(coverage 或 omit)
|
||||
2. 跑 `make skill-mono-multi-content`(或 `make policy`)
|
||||
2. 跑 `make skill-mono-multi-content`(该独立门禁不包含在默认 `make policy` 中)
|
||||
3. 失败则修内容或更新 reviewed omit(disposition + 原因),**禁止**用安装默认值绕过
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。
|
||||
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。
|
||||
> 执行:`make skill-mono-multi-content`(已挂入 `make policy`)。
|
||||
> 执行:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)。
|
||||
|
||||
## 1. 质检矩阵
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
| **G1 形状** | 结构 | `skills/multi/*` | 仅 `dingtalk-*`(含必选 `dingtalk-shared`);每目录有 `SKILL.md` |
|
||||
| **G2 结构** | 结构 | 各 `SKILL.md` frontmatter | `name`==目录名;非空 `description`;`category`∈{product,shared};`requires.bins` 含 `dws` |
|
||||
| **G3 覆盖** | 覆盖 | mono `references/products/*` 顶层 stem | 每 stem ∈ `coverage` 或 `omit_coverage`;coverage 目标 skill/refs 存在 |
|
||||
| **G4 漂移** | 漂移 | scripts、成对文件、全局协议 | orphan 脚本 ∈ allowlist;paired 一致;全局协议存在或 ∈ `omit_global` |
|
||||
| **G4 漂移** | 漂移 | scripts、成对文件、全局协议 | orphan 脚本 ∈ allowlist;paired 内容一致(允许合同声明的布局链接替换);全局协议存在或 ∈ `omit_global` |
|
||||
| **G5 链接** | 可达性 | 合同覆盖的 paired Markdown | 内联相对链接目标文件或目录存在且不逃出仓库;外链、纯锚点和锚点内容不在检查范围 |
|
||||
|
||||
已有门禁(继续复用,不替代本矩阵):`check-skill-commands`、`check-skill-context-budget`、`check-multi-im-skill-chain`、`skill_docs_policy`、whiteboard 成对测试。
|
||||
|
||||
@@ -37,6 +38,16 @@ orphan_scripts_allowlist:
|
||||
- path: dingtalk-misc/scripts/report_received_today.py
|
||||
disposition: defer
|
||||
reason: "pending report.md reference"
|
||||
|
||||
paired_files:
|
||||
- mono: references/products/sheet.md
|
||||
multi: dingtalk-misc/references/sheet.md
|
||||
mode: link-normalized
|
||||
link_substitutions:
|
||||
- mono: "../url-patterns.md"
|
||||
multi: "../../dingtalk-shared/references/url-patterns.md"
|
||||
- mono: "../intent-guide.md"
|
||||
multi: "sheet-intent-guide.md"
|
||||
```
|
||||
|
||||
**处置原则**:质检失败 → 修**内容**或更新 reviewed omit;**不**改安装/升级默认。
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
> 合同短文:[skill-content-framework.md](skill-content-framework.md)
|
||||
> 质检规格:[skill-mono-multi-qa.md](skill-mono-multi-qa.md)
|
||||
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`
|
||||
> 门禁:`make skill-mono-multi-content`(已入 `make policy`)
|
||||
> 门禁:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)
|
||||
>
|
||||
> 撰写 / 收窄 / 质检增补 / 执行:2026-08-05
|
||||
> 工作树:`/Users/john/GolandProjects/open-source/dws-multi-skill-align`
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageReceiptActionsBindToRunnableCommands(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
payload map[string]any
|
||||
}{
|
||||
{
|
||||
name: "send receipt awaiting status",
|
||||
payload: chatmsg.ProjectMessageSendReceipt(map[string]any{
|
||||
"openTaskId": "task-pending",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "send receipt ready for message actions",
|
||||
payload: chatmsg.ProjectMessageSendReceipt(map[string]any{
|
||||
"openTaskId": "task-ready",
|
||||
"openMessageId": "message-ready",
|
||||
"openConversationId": "conversation-ready",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "send status awaiting message reference",
|
||||
payload: chatmsg.ProjectMessageSendStatus(map[string]any{
|
||||
"status": "PENDING",
|
||||
}, "task-pending"),
|
||||
},
|
||||
{
|
||||
name: "send status ready for message actions",
|
||||
payload: chatmsg.ProjectMessageSendStatus(map[string]any{
|
||||
"openTaskId": "task-ready",
|
||||
"openMessageId": "message-ready",
|
||||
"openConversationId": "conversation-ready",
|
||||
"status": "SUCCESS",
|
||||
}, "task-ready"),
|
||||
},
|
||||
}
|
||||
|
||||
root := NewRootCommand()
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
actions, ok := testCase.payload["nextActions"].([]map[string]any)
|
||||
if !ok || len(actions) == 0 {
|
||||
t.Fatalf("nextActions = %#v, want non-empty []map[string]any", testCase.payload["nextActions"])
|
||||
}
|
||||
|
||||
for index, action := range actions {
|
||||
cliPath, ok := action["cliPath"].(string)
|
||||
if !ok || strings.TrimSpace(cliPath) == "" {
|
||||
t.Fatalf("nextActions[%d].cliPath = %#v, want non-empty string", index, action["cliPath"])
|
||||
}
|
||||
|
||||
command, remaining, err := root.Find(strings.Fields(cliPath))
|
||||
if err != nil {
|
||||
t.Fatalf("nextActions[%d].cliPath %q does not bind: %v", index, cliPath, err)
|
||||
}
|
||||
if command == nil || len(remaining) != 0 || !command.Runnable() {
|
||||
t.Fatalf("nextActions[%d].cliPath %q resolved to command=%v remaining=%v runnable=%v", index, cliPath, command, remaining, command != nil && command.Runnable())
|
||||
}
|
||||
|
||||
arguments, ok := action["arguments"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("nextActions[%d].arguments = %#v, want map[string]any", index, action["arguments"])
|
||||
}
|
||||
for name := range arguments {
|
||||
if command.Flags().Lookup(name) == nil && command.InheritedFlags().Lookup(name) == nil {
|
||||
t.Errorf("nextActions[%d] argument %q is not a flag of runnable command %q", index, name, cliPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -80,12 +80,12 @@ func newEventCommand() *cobra.Command {
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "event",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "订阅/消费个人消息、动作与群生命周期事件,并管理订阅生命周期",
|
||||
AgentSummary: "实时监听当前用户相关的个人 IM 与 OA 审批事件,并管理订阅生命周期",
|
||||
UseWhen: []string{
|
||||
"需要实时监听个人消息接收、全量消息、已读、撤回、表情回应或群生命周期事件,或管理个人事件订阅生命周期",
|
||||
"需要实时监听未来发生的个人消息、消息动作、群生命周期或 OA 审批任务/实例事件,或管理个人事件订阅生命周期",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"查历史聊天或主动发消息分别用 chat 查询/发送命令",
|
||||
"查历史聊天或主动发消息用 chat;查询或处理审批实例/任务用 oa;配置开放平台应用事件回调用 dev app event",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -333,7 +333,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
f.BoolVar(&force, "force", false,
|
||||
"仅 --foreground 模式生效:跳过单实例锁 (慎用:会让云事件被随机切分)")
|
||||
f.BoolVar(&dryRun, "dry-run", false,
|
||||
"仅打印解析后的配置,不连接 bus / 云端")
|
||||
"仅打印解析后的配置;不创建订阅、不连接 bus;复用 --subscribe-id 时会只读查询控制面")
|
||||
f.BoolVar(&foreground, "foreground", false,
|
||||
"当前进程直接跑 bus 服务、不 fork、不打印事件(给 systemd/k8s 托管用);读事件不要用它")
|
||||
f.StringVar(&personalOpts.SubscribeID, "subscribe-id", "",
|
||||
@@ -398,22 +398,21 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
Reason: "Reviewed composite workflow: the command creates or reuses a remote personal-event subscription and coordinates the local event bus and Stream consumer; no single pinned RPC represents the workflow.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "订阅并持续消费一个或多个兼容的个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
|
||||
AgentSummary: "消费 OA、群生命周期或需要底层控制的个人事件流;Agent 通常使用 --flatten 输出 NDJSON",
|
||||
UseWhen: []string{
|
||||
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
|
||||
"用户明确要求监听当前身份的所有单聊或所有群消息",
|
||||
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
|
||||
"需要监听六个公开 OA 审批任务/实例 EventKey 中的一个或多个事件",
|
||||
"需要监听指定群的标题变更、成员进退群或群解散事件",
|
||||
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标",
|
||||
"同一目标、同一过滤条件需要同时监听多个兼容事件",
|
||||
"用户显式给出原始 EventKey、Filter DSL、subscribe_id,要求原始 transport envelope,或需要普通 IM facade 不提供的高级多事件控制",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"普通 @我、指定发送人/群、全部单聊/群聊及 message/reaction/read/recall 监听优先使用 event +listen-im",
|
||||
"只查历史聊天记录时用 chat 查询命令",
|
||||
"查询、同意、拒绝、转交、撤销或发起审批时用 oa;配置应用事件回调时用 dev app event",
|
||||
"只看事件目录/字段时用 event list / event schema",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_receive_o2o user_im_message_read_o2o --user test-user-001 --flatten --max-events 2 --format ndjson",
|
||||
"dws event consume user_oa_approval_task_created user_oa_approval_instance_finished --flatten --duration 10m --format ndjson",
|
||||
"dws event consume user_im_group_member_added --group cid-example --flatten --max-events 1 --format ndjson",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -71,7 +71,7 @@ func newEventListenIMCommand() *cobra.Command {
|
||||
Use: "+listen-im",
|
||||
Short: "按 IM 意图解析目标并监听一个或多个个人消息事件",
|
||||
Long: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey," +
|
||||
"自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期。",
|
||||
"自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期;本命令只处理 IM,不接收 OA 审批事件。",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
@@ -143,12 +143,12 @@ func newEventListenIMCommand() *cobra.Command {
|
||||
Reason: "Reviewed IM event facade: it deterministically maps kind/events to public personal EventKeys, resolves one natural user/chat target with the shared typed resolver, then delegates one single- or multi-event invocation to the existing subscription, bus, ready-marker, NDJSON, rollback, cancellation, and cleanup lifecycle.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按 @我、姓名、群名或全量范围监听一个或多个 IM 消息事件",
|
||||
AgentSummary: "按 @我、发送人、群或全量范围监听普通 IM message/reaction/read/recall 事件",
|
||||
UseWhen: []string{
|
||||
"已知要监听 @我、指定发送人、指定群、全部单聊或全部群聊的 message/reaction/read/recall 事件时使用;姓名用 --user-query、群名用 --chat-query,CLI 会唯一解析目标并把多个兼容事件合并到一个消费生命周期。",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"需要群标题/成员/解散等生命周期事件、显式 EventKey、复用 subscribe_id、Filter DSL、原始 transport envelope 或其它底层 consume 控制时使用 event consume;只查历史消息时使用 chat 查询入口",
|
||||
"OA 审批事件、群标题/成员/解散等生命周期事件、显式 EventKey、复用 subscribe_id、Filter DSL、原始 transport envelope 或其它底层控制使用 event consume;只查历史消息使用 chat 查询入口",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws event +listen-im --kind at-me --max-events 1",
|
||||
|
||||
@@ -152,8 +152,8 @@ func TestCrossPlatformCoveragePersonalSubscriptionProtectionCoversAllPublicEvent
|
||||
}
|
||||
}
|
||||
|
||||
if publicCount != 16 {
|
||||
t.Fatalf("public personal events = %d, want 16", publicCount)
|
||||
if publicCount != 22 {
|
||||
t.Fatalf("public personal events = %d, want 22 (16 IM + 6 OA)", publicCount)
|
||||
}
|
||||
for _, ruleType := range []string{"at", "all", "singleChat", "sender", "group"} {
|
||||
if !ruleTypes[ruleType] {
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
@@ -201,12 +202,15 @@ func newEventSchemaCommand() *cobra.Command {
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
|
||||
UseWhen: []string{"已知任一公开个人 IM event_key,消费前需要理解输出字段或保守 payload 契约"},
|
||||
UseWhen: []string{"已知任一公开个人 IM 或 OA event_key,消费前需要理解 --flatten 输出字段或 payload 契约"},
|
||||
AvoidWhen: []string{
|
||||
"查询 CLI 命令参数契约时用顶层 dws schema",
|
||||
"要实际收事件时用 event consume",
|
||||
},
|
||||
Examples: []string{"dws event schema user_im_message_receive_at --flatten --format json"},
|
||||
Examples: []string{
|
||||
"dws event schema user_im_message_receive_at --flatten --format json",
|
||||
"dws event schema user_oa_approval_task_created --flatten --format json",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -262,6 +266,9 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
rawFormat := ""
|
||||
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
|
||||
rawFormat = opts.Common.FormatRaw
|
||||
@@ -290,11 +297,18 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
if opts.Common.DryRun {
|
||||
if strings.TrimSpace(opts.SubscribeID) == "" {
|
||||
if err := validatePersonalSubscriptionOptions(opts); err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
} else {
|
||||
_, eventKey, _, err := personalEnsureSubscription(ctx, client, identity, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
opts.EventKey = eventKey
|
||||
}
|
||||
cfg := consume.Config{
|
||||
WorkDir: workDir,
|
||||
@@ -369,7 +383,6 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
}
|
||||
}
|
||||
|
||||
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
var attempt *personalSubscriptionAttemptReservation
|
||||
if strings.TrimSpace(opts.SubscribeID) == "" {
|
||||
attempt, err = reservePersonalSubscriptionAttempts(
|
||||
@@ -408,9 +421,17 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
)
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
cleanup := func(cleanupCtx context.Context) {
|
||||
_ = personalDeleteSubscription(client, cleanupCtx, sub.SubscribeID)
|
||||
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
|
||||
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
|
||||
ownsSubscription := selfCreated || opts.Ephemeral
|
||||
var cleanupOnce sync.Once
|
||||
cleanupOwnedSubscription := func(cleanupCtx context.Context) {
|
||||
if !ownsSubscription {
|
||||
return
|
||||
}
|
||||
cleanupOnce.Do(func() {
|
||||
_ = personalDeleteSubscription(client, cleanupCtx, sub.SubscribeID)
|
||||
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
|
||||
})
|
||||
}
|
||||
if err := personalUpsertRunState(workDir, personal.RunState{
|
||||
SubscribeID: sub.SubscribeID,
|
||||
@@ -421,19 +442,19 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
IdentityHash: identityHash,
|
||||
}); err != nil {
|
||||
wrapped := fmt.Errorf("save run state: %w", err)
|
||||
cleanupCtx := context.Background()
|
||||
if personalSubscriptionCanceled(ctx, wrapped) {
|
||||
cleanupCtx = ctx
|
||||
}
|
||||
if attempt != nil {
|
||||
cleanupCtx := context.Background()
|
||||
if personalSubscriptionCanceled(ctx, wrapped) {
|
||||
cleanupCtx = ctx
|
||||
}
|
||||
classification := personalSubscriptionLocalFailure()
|
||||
wrapped = attempt.completeFailure(ctx, 0, 0, wrapped, &classification)
|
||||
cleanup(cleanupCtx)
|
||||
}
|
||||
cleanupOwnedSubscription(cleanupCtx)
|
||||
return fmt.Errorf("event consume --as user: %w", wrapped)
|
||||
}
|
||||
if err := attempt.completeSuccess(); err != nil {
|
||||
cleanup(context.Background())
|
||||
cleanupOwnedSubscription(context.Background())
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
// Ownership-based cleanup: a subscription this run CREATED is
|
||||
@@ -442,9 +463,8 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
// leaks server-side. A subscription REUSED via --subscribe-id is left
|
||||
// intact — the caller owns its lifecycle. --ephemeral forces cleanup
|
||||
// either way.
|
||||
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
|
||||
if opts.Ephemeral || selfCreated {
|
||||
defer cleanup(context.Background())
|
||||
if ownsSubscription {
|
||||
defer cleanupOwnedSubscription(context.Background())
|
||||
}
|
||||
|
||||
cfg.EventKey = eventKey
|
||||
@@ -466,17 +486,9 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
Source: foregroundSource,
|
||||
}
|
||||
bus.ApplyEnvTuning(&busCfg)
|
||||
err = personalBusRun(ctx, busCfg)
|
||||
if err != nil && !opts.Ephemeral {
|
||||
cleanup(context.Background())
|
||||
}
|
||||
return err
|
||||
return personalBusRun(ctx, busCfg)
|
||||
}
|
||||
err = personalConsumeRun(ctx, cfg)
|
||||
if err != nil && !opts.Ephemeral {
|
||||
cleanup(context.Background())
|
||||
}
|
||||
return err
|
||||
return personalConsumeRun(ctx, cfg)
|
||||
}
|
||||
|
||||
type personalMultiSubscription struct {
|
||||
@@ -688,6 +700,9 @@ func preparePersonalMultiOptions(opts personalConsumeOptions) ([]personalConsume
|
||||
if !def.Public {
|
||||
return nil, personal.PublicAvailabilityError(eventKey)
|
||||
}
|
||||
if err := validatePersonalOAOptions(eventKey, opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch def.RuleType {
|
||||
case "singleChat", "sender":
|
||||
hasUserScope = true
|
||||
@@ -814,6 +829,9 @@ func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOption
|
||||
}
|
||||
|
||||
func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error {
|
||||
if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
|
||||
RuleType: opts.Rule,
|
||||
UserID: opts.UserID,
|
||||
@@ -826,6 +844,37 @@ func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func validatePersonalOAOptions(eventKey string, opts personalConsumeOptions) error {
|
||||
changed := personalOAOptionNames(opts)
|
||||
if len(changed) == 0 {
|
||||
return nil
|
||||
}
|
||||
def, ok := personalLookupDefinition(strings.TrimSpace(eventKey))
|
||||
if !ok || def.Category != "oa" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s not supported for OA event %s", strings.Join(changed, ", "), eventKey)
|
||||
}
|
||||
|
||||
func personalOAOptionNames(opts personalConsumeOptions) []string {
|
||||
var changed []string
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{name: "--user", value: opts.UserID},
|
||||
{name: "--open-dingtalk-id", value: opts.OpenDingTalkID},
|
||||
{name: "--group", value: opts.GroupID},
|
||||
{name: "--query", value: opts.QueryCSV},
|
||||
{name: "--filter-json", value: opts.FilterJSON},
|
||||
} {
|
||||
if strings.TrimSpace(item.value) != "" {
|
||||
changed = append(changed, item.name)
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
type personalPreparedSubscription struct {
|
||||
EventKey string
|
||||
RuleType string
|
||||
@@ -839,6 +888,9 @@ func preparePersonalSubscription(identity personal.Identity, opts personalConsum
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return personalPreparedSubscription{}, err
|
||||
}
|
||||
if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil {
|
||||
return personalPreparedSubscription{}, err
|
||||
}
|
||||
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
|
||||
RuleType: opts.Rule,
|
||||
UserID: opts.UserID,
|
||||
@@ -885,13 +937,32 @@ func ensurePersonalSubscription(ctx context.Context, client *personal.Client, id
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
eventKey := firstNonEmptyPersonalString(opts.EventKey, sub.EventKey)
|
||||
if sub == nil {
|
||||
return nil, "", "", errors.New("personal event: server returned an empty subscription")
|
||||
}
|
||||
requestedEventKey := strings.TrimSpace(opts.EventKey)
|
||||
actualEventKey := strings.TrimSpace(sub.EventKey)
|
||||
if requestedEventKey != "" && actualEventKey != "" && requestedEventKey != actualEventKey {
|
||||
return nil, "", "", fmt.Errorf(
|
||||
"event_key %q does not match reused subscription %q event_key %q",
|
||||
requestedEventKey,
|
||||
strings.TrimSpace(opts.SubscribeID),
|
||||
actualEventKey,
|
||||
)
|
||||
}
|
||||
eventKey := actualEventKey
|
||||
if eventKey == "" {
|
||||
eventKey = requestedEventKey
|
||||
}
|
||||
if eventKey == "" {
|
||||
return nil, "", "", fmt.Errorf("event_key is required when --subscribe-id lookup returns no event_key")
|
||||
}
|
||||
if err := ensurePublicPersonalEvent(eventKey); err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
if err := validatePersonalOAOptions(eventKey, opts); err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
ruleType := firstNonEmptyPersonalString(sub.RuleType, opts.Rule)
|
||||
if ruleType == "" {
|
||||
if def, ok := personal.Lookup(eventKey); ok {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
|
||||
eventtransport "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -36,18 +37,20 @@ func TestCrossPlatformCoveragePersonalEventRemainingSchemaAndSubscriptionCoverag
|
||||
}
|
||||
}
|
||||
|
||||
oldGet := personalGetSubscription
|
||||
oldCreate := personalCreateSubscription
|
||||
t.Cleanup(func() {
|
||||
personalGetSubscription = oldGet
|
||||
personalCreateSubscription = oldCreate
|
||||
})
|
||||
testseam.Protect(t, &personalGetSubscription)
|
||||
testseam.Protect(t, &personalCreateSubscription)
|
||||
client := personal.NewClient("https://example.test", personal.Identity{})
|
||||
wantErr := errors.New("subscription")
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) { return nil, wantErr }
|
||||
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("get subscription error = %v", err)
|
||||
}
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
return nil, nil
|
||||
}
|
||||
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); err == nil || !strings.Contains(err.Error(), "empty subscription") {
|
||||
t.Fatalf("nil subscription = %v", err)
|
||||
}
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
return &personal.Subscription{}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestPersonalOAEventListAndSchemaCommands(t *testing.T) {
|
||||
list := newEventListCommand()
|
||||
list.SilenceUsage = true
|
||||
list.SilenceErrors = true
|
||||
var listOut bytes.Buffer
|
||||
list.SetOut(&listOut)
|
||||
list.SetArgs([]string{"--category", "oa"})
|
||||
if err := list.Execute(); err != nil {
|
||||
t.Fatalf("event list --category oa error = %v", err)
|
||||
}
|
||||
tests := []struct {
|
||||
eventKey string
|
||||
properties []string
|
||||
}{
|
||||
{
|
||||
eventKey: personal.EventOAApprovalTaskCreated,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "task_id", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: personal.EventOAApprovalTaskFinished,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "task_id", "title", "status", "result", "create_time",
|
||||
"finish_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: personal.EventOAApprovalTaskRedirected,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "task_id", "title", "status", "result", "create_time",
|
||||
"finish_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: personal.EventOAApprovalInstanceStarted,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: personal.EventOAApprovalInstanceTerminated,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "title", "status", "create_time", "finish_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: personal.EventOAApprovalInstanceFinished,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "title", "status", "result", "create_time", "finish_time",
|
||||
"event_time",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
eventKey := tt.eventKey
|
||||
if !strings.Contains(listOut.String(), eventKey) {
|
||||
t.Fatalf("OA event list missing %s:\n%s", eventKey, listOut.String())
|
||||
}
|
||||
|
||||
schema := newEventSchemaCommand()
|
||||
schema.SilenceUsage = true
|
||||
schema.SilenceErrors = true
|
||||
var schemaOut bytes.Buffer
|
||||
schema.SetOut(&schemaOut)
|
||||
schema.SetArgs([]string{eventKey, "--flatten"})
|
||||
if err := schema.Execute(); err != nil {
|
||||
t.Fatalf("event schema %s --flatten error = %v", eventKey, err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(schemaOut.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("decode schema for %s: %v\n%s", eventKey, err, schemaOut.String())
|
||||
}
|
||||
if doc["event_key"] != eventKey || doc["rule_type"] != "all" || doc["jq_root_path"] != "." {
|
||||
t.Fatalf("schema document for %s = %#v", eventKey, doc)
|
||||
}
|
||||
schemaBody, ok := doc["schema"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema body for %s = %#v", eventKey, doc["schema"])
|
||||
}
|
||||
properties, ok := schemaBody["properties"].(map[string]any)
|
||||
if !ok || len(properties) != len(tt.properties) {
|
||||
t.Fatalf("schema properties for %s = %#v, want %d fields", eventKey, schemaBody["properties"], len(tt.properties))
|
||||
}
|
||||
for _, name := range tt.properties {
|
||||
if _, ok := properties[name].(map[string]any); !ok {
|
||||
t.Fatalf("schema property %s for %s = %#v", name, eventKey, properties[name])
|
||||
}
|
||||
}
|
||||
if _, ok := properties["payload"]; ok {
|
||||
t.Fatalf("schema for %s exposed generic payload: %#v", eventKey, properties)
|
||||
}
|
||||
}
|
||||
if strings.Contains(listOut.String(), personal.EventMention) {
|
||||
t.Fatalf("OA category list leaked IM event:\n%s", listOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalOAEventConsumeDryRunAndValidation(t *testing.T) {
|
||||
oldIdentity := personalResolveEventIdentity
|
||||
oldGet := personalGetSubscription
|
||||
t.Cleanup(func() {
|
||||
personalResolveEventIdentity = oldIdentity
|
||||
personalGetSubscription = oldGet
|
||||
})
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{
|
||||
AccessToken: "token",
|
||||
LocalSubject: "subject",
|
||||
ClientID: "client",
|
||||
SourceID: "open",
|
||||
}, nil
|
||||
}
|
||||
personalGetSubscription = func(_ *personal.Client, _ context.Context, subscribeID string) (*personal.Subscription, error) {
|
||||
switch subscribeID {
|
||||
case "oa-sub-task":
|
||||
return &personal.Subscription{
|
||||
SubscribeID: subscribeID,
|
||||
EventKey: personal.EventOAApprovalTaskCreated,
|
||||
RuleType: "all",
|
||||
}, nil
|
||||
case "im-sub-at":
|
||||
return &personal.Subscription{
|
||||
SubscribeID: subscribeID,
|
||||
EventKey: personal.EventMention,
|
||||
RuleType: "at",
|
||||
}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected subscription lookup %q", subscribeID)
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
oaEvents := []string{
|
||||
personal.EventOAApprovalTaskCreated,
|
||||
personal.EventOAApprovalTaskFinished,
|
||||
personal.EventOAApprovalTaskRedirected,
|
||||
personal.EventOAApprovalInstanceStarted,
|
||||
personal.EventOAApprovalInstanceTerminated,
|
||||
personal.EventOAApprovalInstanceFinished,
|
||||
}
|
||||
for _, eventKey := range oaEvents {
|
||||
t.Run(eventKey+"/dry-run", func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SetArgs([]string{eventKey, "--dry-run"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("OA dry-run error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "event_types : "+eventKey) {
|
||||
t.Fatalf("OA dry-run does not select %s:\n%s", eventKey, stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"--user", "user-1"},
|
||||
{"--open-dingtalk-id", "open-user-1"},
|
||||
{"--group", "cid-1"},
|
||||
{"--query", "urgent"},
|
||||
{"--filter-json", `{"field":"content","op":"eq","value":"urgent"}`},
|
||||
} {
|
||||
name := strings.TrimPrefix(args[0], "--")
|
||||
t.Run(eventKey+"/reject-"+name, func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(append([]string{eventKey}, append(args, "--dry-run")...))
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "not supported") {
|
||||
t.Fatalf("OA consume %s error = %v, want unsupported option", args[0], err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("multi-dry-run", func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SetArgs(append(append([]string(nil), oaEvents...), "--dry-run"))
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("multi OA dry-run error = %v", err)
|
||||
}
|
||||
for _, eventKey := range oaEvents {
|
||||
want := "event_key=" + eventKey + " rule_type=all rule_param={}"
|
||||
if !strings.Contains(stderr.String(), want) {
|
||||
t.Fatalf("multi OA dry-run missing %q:\n%s", want, stderr.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
reuseOverrides := [][]string{
|
||||
{"--user", "user-1"},
|
||||
{"--open-dingtalk-id", "open-user-1"},
|
||||
{"--group", "cid-1"},
|
||||
{"--query", "urgent"},
|
||||
{"--filter-json", `{"field":"content","op":"eq","value":"urgent"}`},
|
||||
}
|
||||
t.Run("reuse-dry-run/implicit-event-key/resolves-oa-event", func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SetArgs([]string{"--subscribe-id", "oa-sub-task", "--dry-run"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("implicit reused OA dry-run error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "event_types : "+personal.EventOAApprovalTaskCreated) {
|
||||
t.Fatalf("implicit reused OA dry-run did not resolve event key:\n%s", stderr.String())
|
||||
}
|
||||
})
|
||||
for _, explicitEventKey := range []bool{true, false} {
|
||||
mode := "implicit-event-key"
|
||||
if explicitEventKey {
|
||||
mode = "explicit-event-key"
|
||||
}
|
||||
for _, override := range reuseOverrides {
|
||||
flag := override[0]
|
||||
t.Run("reuse-dry-run/"+mode+"/"+strings.TrimPrefix(flag, "--"), func(t *testing.T) {
|
||||
args := make([]string, 0, 6)
|
||||
if explicitEventKey {
|
||||
args = append(args, personal.EventOAApprovalTaskCreated)
|
||||
}
|
||||
args = append(args, "--subscribe-id", "oa-sub-task", flag, override[1], "--dry-run")
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(args)
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), flag+" not supported for OA event") {
|
||||
t.Fatalf("%s reused OA dry-run %s error = %v", mode, flag, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("reuse-dry-run/implicit-im-remains-supported", func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"--subscribe-id", "im-sub-at", "--query", "urgent", "--dry-run"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("implicit reused IM dry-run error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, override := range reuseOverrides {
|
||||
flag, value := override[0], override[1]
|
||||
t.Run("multi-reject-"+strings.TrimPrefix(flag, "--"), func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
args := append([]string(nil), oaEvents...)
|
||||
args = append(args, flag, value, "--dry-run")
|
||||
cmd.SetArgs(args)
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "not supported for OA event") {
|
||||
t.Fatalf("multi OA consume %s error = %v", flag, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "message query remains supported",
|
||||
args: []string{personal.EventMention, "--query", "urgent", "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "single group lifecycle filter remains supported",
|
||||
args: []string{
|
||||
personal.EventGroupUpdated,
|
||||
"--group", "cid-1",
|
||||
"--filter-json", `{"field":"future","op":"eq","value":"value"}`,
|
||||
"--dry-run",
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(test.args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("existing IM consume behavior changed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersonalOAValidationBranches(t *testing.T) {
|
||||
invalid := personalConsumeOptions{
|
||||
EventKey: personal.EventOAApprovalTaskCreated,
|
||||
UserID: "user-1",
|
||||
}
|
||||
if err := validatePersonalSubscriptionOptions(invalid); err == nil ||
|
||||
!strings.Contains(err.Error(), "--user not supported for OA event") {
|
||||
t.Fatalf("validatePersonalSubscriptionOptions() error = %v", err)
|
||||
}
|
||||
if _, err := preparePersonalSubscription(personal.Identity{}, invalid); err == nil ||
|
||||
!strings.Contains(err.Error(), "--user not supported for OA event") {
|
||||
t.Fatalf("preparePersonalSubscription() error = %v", err)
|
||||
}
|
||||
|
||||
oldGet := personalGetSubscription
|
||||
t.Cleanup(func() { personalGetSubscription = oldGet })
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
return &personal.Subscription{
|
||||
SubscribeID: "oa-sub-without-event-key",
|
||||
RuleType: "all",
|
||||
}, nil
|
||||
}
|
||||
_, _, _, err := ensurePersonalSubscription(
|
||||
context.Background(),
|
||||
nil,
|
||||
personal.Identity{},
|
||||
personalConsumeOptions{
|
||||
SubscribeID: "oa-sub-without-event-key",
|
||||
EventKey: personal.EventOAApprovalTaskCreated,
|
||||
UserID: "user-1",
|
||||
},
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "--user not supported for OA event") {
|
||||
t.Fatalf("ensurePersonalSubscription() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalOAMultiConsumeCreatesIndependentAllSubscriptionsOnSharedBus(t *testing.T) {
|
||||
restoreMany := installPersonalManySeams(t)
|
||||
defer restoreMany()
|
||||
oldCreate := personalCreateSubscription
|
||||
defer func() { personalCreateSubscription = oldCreate }()
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
identity := personal.Identity{
|
||||
AccessToken: "token",
|
||||
LocalSubject: "subject",
|
||||
ClientID: "client",
|
||||
SourceID: "open",
|
||||
}
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return identity, nil
|
||||
}
|
||||
var requests []personal.CreateSubscriptionRequest
|
||||
personalCreateSubscription = func(_ *personal.Client, _ context.Context, req personal.CreateSubscriptionRequest) (*personal.Subscription, error) {
|
||||
requests = append(requests, req)
|
||||
return &personal.Subscription{SubscribeID: "sub-" + req.EventKey}, nil
|
||||
}
|
||||
personalEnsureSubscription = ensurePersonalSubscription
|
||||
var states []personal.RunState
|
||||
personalUpsertRunState = func(_ string, state personal.RunState) error {
|
||||
states = append(states, state)
|
||||
return nil
|
||||
}
|
||||
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return nil }
|
||||
personalRemoveRunStates = func(string, []string) error { return nil }
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
runManyCalls := 0
|
||||
var gotSpecs []consume.ConsumerSpec
|
||||
personalConsumeRunMany = func(_ context.Context, _ consume.Config, specs []consume.ConsumerSpec) error {
|
||||
runManyCalls++
|
||||
gotSpecs = append([]consume.ConsumerSpec(nil), specs...)
|
||||
return nil
|
||||
}
|
||||
|
||||
eventKeys := []string{
|
||||
personal.EventOAApprovalTaskCreated,
|
||||
personal.EventOAApprovalTaskFinished,
|
||||
personal.EventOAApprovalTaskRedirected,
|
||||
personal.EventOAApprovalInstanceStarted,
|
||||
personal.EventOAApprovalInstanceTerminated,
|
||||
personal.EventOAApprovalInstanceFinished,
|
||||
}
|
||||
if err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
EventKeys: eventKeys,
|
||||
Flatten: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("multi OA consume error = %v", err)
|
||||
}
|
||||
if runManyCalls != 1 {
|
||||
t.Fatalf("RunMany calls = %d, want one shared-bus consume call", runManyCalls)
|
||||
}
|
||||
if len(requests) != len(eventKeys) || len(states) != len(eventKeys) || len(gotSpecs) != len(eventKeys) {
|
||||
t.Fatalf("requests=%d states=%d specs=%d, want %d each", len(requests), len(states), len(gotSpecs), len(eventKeys))
|
||||
}
|
||||
for i, eventKey := range eventKeys {
|
||||
req := requests[i]
|
||||
if req.EventKey != eventKey || req.RuleType != "all" || req.RuleParam == nil || len(req.RuleParam) != 0 || req.Filter != nil {
|
||||
t.Fatalf("subscription request[%d] = %#v, want %s all/{}", i, req, eventKey)
|
||||
}
|
||||
if states[i].EventKey != eventKey || states[i].RuleType != "all" {
|
||||
t.Fatalf("run state[%d] = %#v", i, states[i])
|
||||
}
|
||||
wantSpec := consume.ConsumerSpec{
|
||||
EventKey: eventKey,
|
||||
EventTypes: []string{eventKey},
|
||||
SubscribeID: "sub-" + eventKey,
|
||||
ReadySubscribeID: "sub-" + eventKey,
|
||||
}
|
||||
if !reflect.DeepEqual(gotSpecs[i], wantSpec) {
|
||||
t.Fatalf("consumer spec[%d] = %#v, want %#v", i, gotSpecs[i], wantSpec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalOAReusedSubscriptionRejectsDefinitionOverridesAtRuntime(t *testing.T) {
|
||||
oldGet := personalGetSubscription
|
||||
t.Cleanup(func() { personalGetSubscription = oldGet })
|
||||
getCalls := 0
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
getCalls++
|
||||
return &personal.Subscription{
|
||||
SubscribeID: "oa-sub-task",
|
||||
EventKey: personal.EventOAApprovalTaskCreated,
|
||||
RuleType: "all",
|
||||
}, nil
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
set func(*personalConsumeOptions)
|
||||
}{
|
||||
{name: "user", set: func(opts *personalConsumeOptions) { opts.UserID = "user-1" }},
|
||||
{name: "open-dingtalk-id", set: func(opts *personalConsumeOptions) { opts.OpenDingTalkID = "open-user-1" }},
|
||||
{name: "group", set: func(opts *personalConsumeOptions) { opts.GroupID = "cid-1" }},
|
||||
{name: "query", set: func(opts *personalConsumeOptions) { opts.QueryCSV = "urgent" }},
|
||||
{name: "filter-json", set: func(opts *personalConsumeOptions) { opts.FilterJSON = `{"field":"content","op":"eq","value":"urgent"}` }},
|
||||
}
|
||||
for _, explicitEventKey := range []bool{true, false} {
|
||||
mode := "implicit-event-key"
|
||||
if explicitEventKey {
|
||||
mode = "explicit-event-key"
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(mode+"/"+test.name, func(t *testing.T) {
|
||||
opts := personalConsumeOptions{SubscribeID: "oa-sub-task"}
|
||||
if explicitEventKey {
|
||||
opts.EventKey = personal.EventOAApprovalTaskCreated
|
||||
}
|
||||
test.set(&opts)
|
||||
before := getCalls
|
||||
_, _, _, err := ensurePersonalSubscription(
|
||||
context.Background(),
|
||||
nil,
|
||||
personal.Identity{},
|
||||
opts,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "--"+test.name+" not supported for OA event") {
|
||||
t.Fatalf("reused OA subscription %s error = %v", test.name, err)
|
||||
}
|
||||
if getCalls != before+1 {
|
||||
t.Fatalf("subscription lookup calls = %d, want %d", getCalls, before+1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalOAImplicitReuseRuntimeLooksUpEventBeforeValidation(t *testing.T) {
|
||||
oldIdentity := personalResolveEventIdentity
|
||||
oldGet := personalGetSubscription
|
||||
t.Cleanup(func() {
|
||||
personalResolveEventIdentity = oldIdentity
|
||||
personalGetSubscription = oldGet
|
||||
})
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{
|
||||
AccessToken: "token",
|
||||
LocalSubject: "subject",
|
||||
ClientID: "client",
|
||||
SourceID: "open",
|
||||
}, nil
|
||||
}
|
||||
getCalls := 0
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
getCalls++
|
||||
return &personal.Subscription{
|
||||
SubscribeID: "oa-sub-task",
|
||||
EventKey: personal.EventOAApprovalTaskCreated,
|
||||
RuleType: "all",
|
||||
}, nil
|
||||
}
|
||||
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"--subscribe-id", "oa-sub-task", "--group", "cid-1"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "--group not supported for OA event "+personal.EventOAApprovalTaskCreated) {
|
||||
t.Fatalf("implicit reused OA runtime error = %v", err)
|
||||
}
|
||||
if getCalls != 1 {
|
||||
t.Fatalf("subscription lookup calls = %d, want 1", getCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalIMReusedSubscriptionWithExistingOverridesRemainsSupported(t *testing.T) {
|
||||
oldGet := personalGetSubscription
|
||||
t.Cleanup(func() { personalGetSubscription = oldGet })
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
return &personal.Subscription{
|
||||
SubscribeID: "im-sub",
|
||||
EventKey: personal.EventSingleChat,
|
||||
RuleType: "singleChat",
|
||||
}, nil
|
||||
}
|
||||
|
||||
sub, eventKey, ruleType, err := ensurePersonalSubscription(
|
||||
context.Background(),
|
||||
nil,
|
||||
personal.Identity{},
|
||||
personalConsumeOptions{
|
||||
SubscribeID: "im-sub",
|
||||
EventKey: personal.EventSingleChat,
|
||||
UserID: "user-1",
|
||||
QueryCSV: "urgent",
|
||||
FilterJSON: `{"field":"content","op":"eq","value":"urgent"}`,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("reused IM subscription error = %v", err)
|
||||
}
|
||||
if sub.SubscribeID != "im-sub" || eventKey != personal.EventSingleChat || ruleType != "singleChat" {
|
||||
t.Fatalf("reused IM subscription = %#v, event=%q rule=%q", sub, eventKey, ruleType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalOAStatusAndStopCommandWiring(t *testing.T) {
|
||||
oldStatus := eventRunPersonalStatus
|
||||
oldStop := eventRunPersonalStop
|
||||
t.Cleanup(func() {
|
||||
eventRunPersonalStatus = oldStatus
|
||||
eventRunPersonalStop = oldStop
|
||||
})
|
||||
|
||||
var statusOpts personalStatusOptions
|
||||
eventRunPersonalStatus = func(_ *cobra.Command, opts personalStatusOptions) error {
|
||||
statusOpts = opts
|
||||
return nil
|
||||
}
|
||||
status := newEventStatusCommand()
|
||||
status.SilenceUsage = true
|
||||
status.SilenceErrors = true
|
||||
status.SetOut(io.Discard)
|
||||
status.SetErr(io.Discard)
|
||||
status.SetArgs([]string{
|
||||
"--event", personal.EventOAApprovalTaskCreated,
|
||||
"--subscribe-id", "oa-sub-task",
|
||||
"--status", "all",
|
||||
})
|
||||
if err := status.Execute(); err != nil {
|
||||
t.Fatalf("OA event status error = %v", err)
|
||||
}
|
||||
if statusOpts.EventKey != personal.EventOAApprovalTaskCreated ||
|
||||
statusOpts.SubscribeID != "oa-sub-task" ||
|
||||
statusOpts.Status != "all" {
|
||||
t.Fatalf("OA status options = %#v", statusOpts)
|
||||
}
|
||||
|
||||
var stopOpts personalStopOptions
|
||||
eventRunPersonalStop = func(_ *cobra.Command, opts personalStopOptions) error {
|
||||
stopOpts = opts
|
||||
return nil
|
||||
}
|
||||
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
event := &cobra.Command{Use: "event"}
|
||||
event.AddCommand(newEventStopCommand())
|
||||
root.AddCommand(event)
|
||||
root.SetArgs([]string{"event", "stop", "oa-sub-task", "--yes"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("OA event stop error = %v", err)
|
||||
}
|
||||
if stopOpts.SubscribeID != "oa-sub-task" || stopOpts.All {
|
||||
t.Fatalf("OA stop options = %#v", stopOpts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
|
||||
)
|
||||
|
||||
func TestPersonalConsumeCleanupOwnershipRuntimeMatrix(t *testing.T) {
|
||||
runErr := errors.New("runtime failed")
|
||||
for _, foreground := range []bool{false, true} {
|
||||
for _, selfCreated := range []bool{false, true} {
|
||||
for _, ephemeral := range []bool{false, true} {
|
||||
for _, failRuntime := range []bool{false, true} {
|
||||
name := strings.Join([]string{
|
||||
map[bool]string{false: "background", true: "foreground"}[foreground],
|
||||
map[bool]string{false: "reused", true: "self-created"}[selfCreated],
|
||||
map[bool]string{false: "persistent", true: "ephemeral"}[ephemeral],
|
||||
map[bool]string{false: "success", true: "error"}[failRuntime],
|
||||
}, "/")
|
||||
t.Run(name, func(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
t.Cleanup(restore)
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
oldNewSource := personalNewStreamSource
|
||||
oldBusRun := personalBusRun
|
||||
oldConsumeRun := personalConsumeRun
|
||||
t.Cleanup(func() {
|
||||
personalNewStreamSource = oldNewSource
|
||||
personalBusRun = oldBusRun
|
||||
personalConsumeRun = oldConsumeRun
|
||||
})
|
||||
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{
|
||||
AccessToken: "token",
|
||||
ClientID: "client",
|
||||
SourceID: "open",
|
||||
LocalSubject: "subject",
|
||||
}, nil
|
||||
}
|
||||
personalEnsureSubscription = func(
|
||||
context.Context,
|
||||
*personal.Client,
|
||||
personal.Identity,
|
||||
personalConsumeOptions,
|
||||
) (*personal.Subscription, string, string, error) {
|
||||
return &personal.Subscription{SubscribeID: "sub-one"}, personal.EventMention, "at", nil
|
||||
}
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
|
||||
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
||||
|
||||
deleteCalls := 0
|
||||
removeCalls := 0
|
||||
personalDeleteSubscription = func(*personal.Client, context.Context, string) error {
|
||||
deleteCalls++
|
||||
return nil
|
||||
}
|
||||
personalRemoveRunStates = func(string, []string) error {
|
||||
removeCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
|
||||
return nil, nil
|
||||
}
|
||||
personalBusRun = func(context.Context, bus.Config) error {
|
||||
if failRuntime {
|
||||
return runErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
personalConsumeRun = func(context.Context, consume.Config) error {
|
||||
if failRuntime {
|
||||
return runErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
opts := personalConsumeOptions{
|
||||
EventKey: personal.EventMention,
|
||||
Ephemeral: ephemeral,
|
||||
ControlBaseURL: "https://mcp.example.test/dws",
|
||||
Common: commonConsumeOptions{
|
||||
Foreground: foreground,
|
||||
},
|
||||
}
|
||||
if !selfCreated {
|
||||
opts.SubscribeID = "sub-one"
|
||||
}
|
||||
|
||||
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), opts)
|
||||
if failRuntime {
|
||||
if !errors.Is(err, runErr) {
|
||||
t.Fatalf("runtime error = %v, want %v", err, runErr)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatalf("consume error = %v", err)
|
||||
}
|
||||
|
||||
wantCleanup := 0
|
||||
if selfCreated || ephemeral {
|
||||
wantCleanup = 1
|
||||
}
|
||||
if deleteCalls != wantCleanup || removeCalls != wantCleanup {
|
||||
t.Fatalf(
|
||||
"cleanup delete/remove = %d/%d, want %d/%d",
|
||||
deleteCalls,
|
||||
removeCalls,
|
||||
wantCleanup,
|
||||
wantCleanup,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalConsumeCleanupOwnershipOnRunStateFailure(t *testing.T) {
|
||||
stateErr := errors.New("save state failed")
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
selfCreated bool
|
||||
ephemeral bool
|
||||
wantCleanup int
|
||||
}{
|
||||
{name: "self-created", selfCreated: true, wantCleanup: 1},
|
||||
{name: "reused persistent", wantCleanup: 0},
|
||||
{name: "reused ephemeral", ephemeral: true, wantCleanup: 1},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
t.Cleanup(restore)
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}, nil
|
||||
}
|
||||
personalEnsureSubscription = func(
|
||||
context.Context,
|
||||
*personal.Client,
|
||||
personal.Identity,
|
||||
personalConsumeOptions,
|
||||
) (*personal.Subscription, string, string, error) {
|
||||
return &personal.Subscription{SubscribeID: "sub-one"}, personal.EventMention, "at", nil
|
||||
}
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalUpsertRunState = func(string, personal.RunState) error { return stateErr }
|
||||
deleteCalls := 0
|
||||
removeCalls := 0
|
||||
personalDeleteSubscription = func(*personal.Client, context.Context, string) error {
|
||||
deleteCalls++
|
||||
return nil
|
||||
}
|
||||
personalRemoveRunStates = func(string, []string) error {
|
||||
removeCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
opts := personalConsumeOptions{
|
||||
EventKey: personal.EventMention,
|
||||
Ephemeral: test.ephemeral,
|
||||
ControlBaseURL: "https://mcp.example.test/dws",
|
||||
}
|
||||
if !test.selfCreated {
|
||||
opts.SubscribeID = "sub-one"
|
||||
}
|
||||
if err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), opts); !errors.Is(err, stateErr) {
|
||||
t.Fatalf("state error = %v, want %v", err, stateErr)
|
||||
}
|
||||
if deleteCalls != test.wantCleanup || removeCalls != test.wantCleanup {
|
||||
t.Fatalf(
|
||||
"cleanup delete/remove = %d/%d, want %d/%d",
|
||||
deleteCalls,
|
||||
removeCalls,
|
||||
test.wantCleanup,
|
||||
test.wantCleanup,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalReusedSubscriptionEventKeyResolution(t *testing.T) {
|
||||
oldGet := personalGetSubscription
|
||||
t.Cleanup(func() { personalGetSubscription = oldGet })
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
requested string
|
||||
actual string
|
||||
wantKey string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "matching key uses actual",
|
||||
requested: personal.EventMention,
|
||||
actual: personal.EventMention,
|
||||
wantKey: personal.EventMention,
|
||||
},
|
||||
{
|
||||
name: "implicit key uses actual",
|
||||
actual: personal.EventOAApprovalTaskCreated,
|
||||
wantKey: personal.EventOAApprovalTaskCreated,
|
||||
},
|
||||
{
|
||||
name: "missing actual falls back to requested",
|
||||
requested: personal.EventOAApprovalTaskCreated,
|
||||
wantKey: personal.EventOAApprovalTaskCreated,
|
||||
},
|
||||
{
|
||||
name: "requested IM mismatches actual OA",
|
||||
requested: personal.EventMention,
|
||||
actual: personal.EventOAApprovalTaskCreated,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "requested OA mismatches actual IM",
|
||||
requested: personal.EventOAApprovalTaskCreated,
|
||||
actual: personal.EventMention,
|
||||
wantErr: true,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
return &personal.Subscription{
|
||||
SubscribeID: "sub-one",
|
||||
EventKey: test.actual,
|
||||
}, nil
|
||||
}
|
||||
_, eventKey, _, err := ensurePersonalSubscription(
|
||||
context.Background(),
|
||||
nil,
|
||||
personal.Identity{},
|
||||
personalConsumeOptions{SubscribeID: "sub-one", EventKey: test.requested},
|
||||
)
|
||||
if test.wantErr {
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match reused subscription") {
|
||||
t.Fatalf("mismatch error = %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), test.requested) || !strings.Contains(err.Error(), test.actual) {
|
||||
t.Fatalf("mismatch error does not identify both keys: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("resolve reused subscription: %v", err)
|
||||
}
|
||||
if eventKey != test.wantKey {
|
||||
t.Fatalf("resolved event key = %q, want %q", eventKey, test.wantKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalReusedSubscriptionMismatchStopsDryRunAndRuntime(t *testing.T) {
|
||||
for _, mode := range []struct {
|
||||
name string
|
||||
dryRun bool
|
||||
foreground bool
|
||||
}{
|
||||
{name: "dry-run", dryRun: true},
|
||||
{name: "background"},
|
||||
{name: "foreground", foreground: true},
|
||||
} {
|
||||
t.Run(mode.name, func(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
t.Cleanup(restore)
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
oldGet := personalGetSubscription
|
||||
oldNewSource := personalNewStreamSource
|
||||
oldBusRun := personalBusRun
|
||||
oldConsumeRun := personalConsumeRun
|
||||
t.Cleanup(func() {
|
||||
personalGetSubscription = oldGet
|
||||
personalNewStreamSource = oldNewSource
|
||||
personalBusRun = oldBusRun
|
||||
personalConsumeRun = oldConsumeRun
|
||||
})
|
||||
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}, nil
|
||||
}
|
||||
personalEnsureSubscription = ensurePersonalSubscription
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
return &personal.Subscription{
|
||||
SubscribeID: "sub-one",
|
||||
EventKey: personal.EventOAApprovalTaskCreated,
|
||||
RuleType: "all",
|
||||
}, nil
|
||||
}
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
|
||||
|
||||
upsertCalls := 0
|
||||
consumeCalls := 0
|
||||
busCalls := 0
|
||||
personalUpsertRunState = func(string, personal.RunState) error {
|
||||
upsertCalls++
|
||||
return nil
|
||||
}
|
||||
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
|
||||
return nil, nil
|
||||
}
|
||||
personalBusRun = func(context.Context, bus.Config) error {
|
||||
busCalls++
|
||||
return nil
|
||||
}
|
||||
personalConsumeRun = func(context.Context, consume.Config) error {
|
||||
consumeCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
EventKey: personal.EventMention,
|
||||
SubscribeID: "sub-one",
|
||||
ControlBaseURL: "https://mcp.example.test/dws",
|
||||
Common: commonConsumeOptions{
|
||||
DryRun: mode.dryRun,
|
||||
Foreground: mode.foreground,
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match reused subscription") {
|
||||
t.Fatalf("mismatch error = %v", err)
|
||||
}
|
||||
if upsertCalls != 0 || consumeCalls != 0 || busCalls != 0 {
|
||||
t.Fatalf(
|
||||
"mismatch reached upsert/consumer/bus = %d/%d/%d",
|
||||
upsertCalls,
|
||||
consumeCalls,
|
||||
busCalls,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalReusedSubscriptionUsesActualKeyInDryRunAndRuntime(t *testing.T) {
|
||||
for _, dryRun := range []bool{true, false} {
|
||||
name := map[bool]string{false: "runtime", true: "dry-run"}[dryRun]
|
||||
t.Run(name, func(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
t.Cleanup(restore)
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
oldGet := personalGetSubscription
|
||||
oldConsumeRun := personalConsumeRun
|
||||
t.Cleanup(func() {
|
||||
personalGetSubscription = oldGet
|
||||
personalConsumeRun = oldConsumeRun
|
||||
})
|
||||
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}, nil
|
||||
}
|
||||
personalEnsureSubscription = ensurePersonalSubscription
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
return &personal.Subscription{
|
||||
SubscribeID: "sub-oa",
|
||||
EventKey: personal.EventOAApprovalInstanceFinished,
|
||||
RuleType: "all",
|
||||
}, nil
|
||||
}
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
|
||||
personalUpsertRunState = func(_ string, state personal.RunState) error {
|
||||
if state.EventKey != personal.EventOAApprovalInstanceFinished {
|
||||
t.Fatalf("run state event key = %q", state.EventKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var got consume.Config
|
||||
personalConsumeRun = func(_ context.Context, cfg consume.Config) error {
|
||||
got = cfg
|
||||
return nil
|
||||
}
|
||||
if err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
SubscribeID: "sub-oa",
|
||||
ControlBaseURL: "https://mcp.example.test/dws",
|
||||
Common: commonConsumeOptions{
|
||||
DryRun: dryRun,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("reuse subscription: %v", err)
|
||||
}
|
||||
if got.EventKey != personal.EventOAApprovalInstanceFinished ||
|
||||
len(got.EventTypes) != 1 || got.EventTypes[0] != personal.EventOAApprovalInstanceFinished ||
|
||||
got.SubscribeID != "sub-oa" {
|
||||
t.Fatalf("consume config = %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumeDryRunHelpDescribesReuseLookup(t *testing.T) {
|
||||
usage := newEventConsumeCommand().Flags().Lookup("dry-run").Usage
|
||||
for _, want := range []string{"不创建订阅", "不连接 bus", "复用 --subscribe-id", "只读查询控制面"} {
|
||||
if !strings.Contains(usage, want) {
|
||||
t.Fatalf("dry-run help %q missing %q", usage, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageEventAgentSelectionBoundaries(t *testing.T) {
|
||||
_ = NewRootCommand()
|
||||
|
||||
eventProduct, ok := contract.LookupProductDecl("event")
|
||||
if !ok {
|
||||
t.Fatal("event ProductDecl is not registered")
|
||||
}
|
||||
assertSelectionContains(t, "event product", eventProduct.Selection.AgentSummary,
|
||||
[]string{"IM", "OA"})
|
||||
assertSelectionContains(t, "event product use_when", strings.Join(eventProduct.Selection.UseWhen, "\n"),
|
||||
[]string{"消息", "群生命周期", "OA"})
|
||||
assertSelectionContains(t, "event product avoid_when", strings.Join(eventProduct.Selection.AvoidWhen, "\n"),
|
||||
[]string{"chat", "oa", "dev app event"})
|
||||
|
||||
listenMeta, ok := cli.ResolveMeta("event +listen-im")
|
||||
if !ok {
|
||||
t.Fatal("event +listen-im metadata is not registered")
|
||||
}
|
||||
assertSelectionContains(t, "event.listen_im use_when", strings.Join(listenMeta.Selection.UseWhen, "\n"),
|
||||
[]string{"@我", "message/reaction/read/recall"})
|
||||
assertSelectionContains(t, "event.listen_im avoid_when", strings.Join(listenMeta.Selection.AvoidWhen, "\n"),
|
||||
[]string{"OA 审批事件", "群标题", "Filter DSL", "event consume", "历史消息"})
|
||||
|
||||
consumeMeta, ok := cli.ResolveMeta("event consume")
|
||||
if !ok {
|
||||
t.Fatal("event consume metadata is not registered")
|
||||
}
|
||||
consumeUse := strings.Join(consumeMeta.Selection.UseWhen, "\n")
|
||||
assertSelectionContains(t, "event.consume use_when", consumeUse,
|
||||
[]string{"OA", "群", "EventKey", "Filter DSL", "subscribe_id", "transport envelope", "高级多事件"})
|
||||
consumeAvoid := strings.Join(consumeMeta.Selection.AvoidWhen, "\n")
|
||||
assertSelectionContains(t, "event.consume avoid_when", consumeAvoid,
|
||||
[]string{"event +listen-im", "历史聊天", "oa", "dev app event"})
|
||||
|
||||
schemaMeta, ok := cli.ResolveMeta("event schema")
|
||||
if !ok {
|
||||
t.Fatal("event schema metadata is not registered")
|
||||
}
|
||||
assertSelectionContains(t, "event.schema use_when", strings.Join(schemaMeta.Selection.UseWhen, "\n"),
|
||||
[]string{"IM", "OA", "--flatten"})
|
||||
|
||||
for productID, want := range map[string]string{
|
||||
"chat": "event +listen-im",
|
||||
"oa": "event consume",
|
||||
} {
|
||||
decl, found := contract.LookupProductDecl(productID)
|
||||
if !found {
|
||||
t.Fatalf("%s ProductDecl is not registered", productID)
|
||||
}
|
||||
assertSelectionContains(t, productID+" avoid_when", strings.Join(decl.Selection.AvoidWhen, "\n"), []string{want})
|
||||
}
|
||||
}
|
||||
|
||||
func assertSelectionContains(t *testing.T, label, text string, fragments []string) {
|
||||
t.Helper()
|
||||
for _, fragment := range fragments {
|
||||
if !strings.Contains(text, fragment) {
|
||||
t.Errorf("%s = %q, want fragment %q", label, text, fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
|
||||
copyArgs[key] = value
|
||||
}
|
||||
c.calls = append(c.calls, paramAliasToolCall{server: server, tool: tool, args: copyArgs})
|
||||
text := paramAliasResponseForTool(tool)
|
||||
text := c.paramAliasResponseForTool(tool)
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
|
||||
// print the transport result and need an empty object; smart shortcuts that
|
||||
// inspect a read response receive the smallest shape that lets their full RunE
|
||||
// complete without falling back to a validation error.
|
||||
func paramAliasResponseForTool(tool string) string {
|
||||
func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string {
|
||||
switch tool {
|
||||
case "list_calendar_events":
|
||||
return `{"result":{"events":[]}}`
|
||||
@@ -62,9 +62,22 @@ func paramAliasResponseForTool(tool string) string {
|
||||
return `{"result":[{"name":"Fixture User","userId":"fixture-user","openDingTalkId":"D-fixture-user"}]}`
|
||||
case "list_doc_versions":
|
||||
return `{"result":{"items":[{"version":3}]}}`
|
||||
case "revert_doc_version":
|
||||
return `{"version":3}`
|
||||
case "search_doc_templates":
|
||||
return `{"result":[{"templateId":"fixture-template-id"}]}`
|
||||
case "create_document":
|
||||
return `{"nodeId":"fixture-node"}`
|
||||
case "get_document_content":
|
||||
for index := len(c.calls) - 2; index >= 0; index-- {
|
||||
call := c.calls[index]
|
||||
for _, key := range []string{"jsonml", "markdown"} {
|
||||
if content, ok := call.args[key].(string); ok {
|
||||
encoded, _ := json.Marshal(map[string]any{"revision": 1, key: content})
|
||||
return string(encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
return `{"revision":1}`
|
||||
default:
|
||||
return `{}`
|
||||
@@ -173,6 +186,24 @@ func executeParamAliasDryRunE2E(t *testing.T, args ...string) (*pipeline.Context
|
||||
return ctx, preview, append([]executor.Invocation(nil), rejectRunner.attempts...), executeErr
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagListDryRunStopsBeforeReadDispatch(t *testing.T) {
|
||||
_, preview, attempts, err := executeParamAliasDryRunE2E(t,
|
||||
"chat", "+flag-list", "--page-size", "20", "--cursor", "0", "--dry-run",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("flag-list dry-run error = %v", err)
|
||||
}
|
||||
if len(attempts) != 0 {
|
||||
t.Fatalf("flag-list dry-run crossed dispatch boundary: %#v", attempts)
|
||||
}
|
||||
if !preview.DryRun || preview.Executed || preview.Tool != "list_message_favorites" {
|
||||
t.Fatalf("flag-list dry-run preview = %#v", preview)
|
||||
}
|
||||
if preview.Arguments["cursor"] != float64(0) || preview.Arguments["size"] != "20" {
|
||||
t.Fatalf("flag-list dry-run arguments = %#v", preview.Arguments)
|
||||
}
|
||||
}
|
||||
|
||||
func executeParamAliasE2E(t *testing.T, caller *paramAliasCaptureCaller, args ...string) (*pipeline.Context, error) {
|
||||
t.Helper()
|
||||
originalArgs := os.Args
|
||||
|
||||
@@ -48,7 +48,7 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"chat +feed-group-query-item": {"chat", "+feed-group-query-item", "--category-id", "7", "--conversation-ids", "fixture-conversation"},
|
||||
"chat +flag-cancel": {"chat", "+flag-cancel", "--conversation-id", "fixture-conversation", "--message-id", "message-1", "--yes"},
|
||||
"chat +flag-create": {"chat", "+flag-create", "--conversation-id", "fixture-conversation", "--message-id", "message-1", "--yes"},
|
||||
"chat +flag-list": {"chat", "+flag-list", "--cursor", "0", "--size", "7"},
|
||||
"chat +flag-list": {"chat", "+flag-list", "--cursor", "0", "--page-size", "7"},
|
||||
"chat +messages-combine-forward": {"chat", "+messages-combine-forward", "--src-conversation-id", "fixture-source", "--msg-ids", "message-1,message-2", "--dest-conversation-id", "fixture-destination", "--yes"},
|
||||
"chat +messages-forward": {"chat", "+messages-forward", "--src-conversation-id", "fixture-source", "--msg-id", "message-1", "--dest-conversation-id", "fixture-destination", "--yes"},
|
||||
"chat +messages-forward-topic": {"chat", "+messages-forward-topic", "--src-msg-id", "message-1", "--src-conversation-id", "fixture-source", "--src-thread-id", "convThread-fixture", "--dest-conversation-id", "fixture-destination", "--yes"},
|
||||
@@ -123,7 +123,7 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"doc +version-list": {"doc", "+version-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"doc +version-revert": {"doc", "+version-revert", "--node", "node-1", "--version", "3", "--yes"},
|
||||
"doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"},
|
||||
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "append", "--content", "fixture body", "--expected-revision", "1", "--yes"},
|
||||
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"},
|
||||
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"},
|
||||
@@ -212,7 +212,7 @@ var paramAliasNewIMCases = []struct {
|
||||
{command: "chat +chat-update", emitted: "open-conversation-id", canonical: "group"},
|
||||
{command: "chat +chat-update", emitted: "title", canonical: "name"},
|
||||
{command: "chat +chat-update", emitted: "new-title", canonical: "name"},
|
||||
{command: "chat +flag-list", emitted: "limit", canonical: "size"},
|
||||
{command: "chat +flag-list", emitted: "limit", canonical: "page-size"},
|
||||
{command: "chat +chat-members-list", emitted: "chat-id", canonical: "conversation-id"},
|
||||
{command: "chat +chat-members-list", emitted: "id", canonical: "conversation-id"},
|
||||
{command: "chat +conversation-set-top", emitted: "open-conversation-id", canonical: "conversation-id"},
|
||||
|
||||
@@ -296,7 +296,14 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles {
|
||||
t.Fatalf("write dry-run fixture %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
return agentExampleFiles{root: root, markdown: markdown, json: jsonFile, batch: batch, binary: binary, image: image}
|
||||
return agentExampleFiles{
|
||||
root: root,
|
||||
markdown: "./" + filepath.Base(markdown),
|
||||
json: "./" + filepath.Base(jsonFile),
|
||||
batch: "./" + filepath.Base(batch),
|
||||
binary: "./" + filepath.Base(binary),
|
||||
image: "./" + filepath.Base(image),
|
||||
}
|
||||
}
|
||||
|
||||
func materializeAgentExampleArgv(argv []string, files agentExampleFiles) []string {
|
||||
|
||||
@@ -34,7 +34,12 @@ func TestReviewedRoutedInterfacesReachFinalSchema(t *testing.T) {
|
||||
{
|
||||
canonical: "sheet.range_batch_set_style",
|
||||
mode: "composite",
|
||||
reason: "The CLI reads a local batch file and performs multiple sheet/update_range calls with local continue-on-error control; the workflow has no single direct MCP interface.",
|
||||
reason: "The CLI assembles style cell matrices locally from --ranges or a local batch file and submits them as one sheet/batch_update operations array; no single direct MCP interface represents the wrapper input shape.",
|
||||
},
|
||||
{
|
||||
canonical: "sheet.create_with_data",
|
||||
mode: "composite",
|
||||
reason: "Reviewed composite workflow: the command calls sheet/create_workspace_sheet, waits for the new document to become writable, resolves the default worksheet, writes the initial data through sheet/set_range_from_csv or sheet/table_put, reads it back with sheet/get_range_as_csv, and optionally applies sheet/set_cell_range, sheet/update_dimension and sheet/merge_cells; no single pinned RPC represents the workflow.",
|
||||
},
|
||||
{
|
||||
canonical: "sheet.range_read",
|
||||
|
||||
@@ -26,6 +26,14 @@ func TestReviewedMutationSafetyReachesFinalSchema(t *testing.T) {
|
||||
wants := []finalSchemaSafetyWant{
|
||||
{canonical: "aitable.form_field_hide", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "idempotent", provenance: declared},
|
||||
{canonical: "chat.dismiss_group", effect: "destructive", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
// Card update intentionally layers confirmation: the atomic typed command
|
||||
// preserves its original contract, while the Agent-facing shortcut owns
|
||||
// the outer confirmation boundary.
|
||||
{canonical: "chat.update_streaming_card", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "chat.shortcut_messages_send", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "chat.shortcut_messages_send_by_webhook", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "chat.shortcut_messages_send_card", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "chat.shortcut_messages_update_card", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "drive.recycle_restore", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "minutes.create_speaker_summary", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "sheet.clear_range", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
|
||||
@@ -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"])
|
||||
@@ -129,6 +129,75 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryDocUpdateShortcutPublishesCompleteConditionalContract(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", "doc +update")
|
||||
if got, want := schemaContractString(leaf["confirmation"]), "user_required"; got != want {
|
||||
t.Fatalf("confirmation = %q, want %q", got, want)
|
||||
}
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
if got, want := len(parameters), 11; got != want {
|
||||
t.Fatalf("parameter count = %d, want %d: %#v", got, want, parameters)
|
||||
}
|
||||
if required, _ := parameters["node"]["required"].(bool); !required {
|
||||
t.Errorf("--node required = %#v, want true", parameters["node"]["required"])
|
||||
}
|
||||
if required, _ := parameters["command"]["required"].(bool); required {
|
||||
t.Errorf("--command required = true, want runtime custom validation")
|
||||
}
|
||||
wantProperties := map[string]string{
|
||||
"node": "node", "doc": "node", "command": "command", "content": "content", "text": "content", "doc-format": "docFormat",
|
||||
"block-id": "blockId", "after-block-id": "afterBlockId", "old": "old", "new": "new",
|
||||
"expected-revision": "expectedRevision",
|
||||
}
|
||||
for name, want := range wantProperties {
|
||||
if got := schemaContractString(parameters[name]["property"]); got != want {
|
||||
t.Errorf("--%s property = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"content", "block-id", "after-block-id", "old", "new"} {
|
||||
parameter := parameters[name]
|
||||
if required, _ := parameter["required"].(bool); required {
|
||||
t.Errorf("--%s required = true, want runtime custom validation", name)
|
||||
}
|
||||
if got := schemaContractString(parameter["required_when"]); got != "" {
|
||||
t.Errorf("--%s required_when = %q, want compatibility-safe custom validation", name, got)
|
||||
}
|
||||
}
|
||||
if constraints, exists := leaf["constraints"]; exists && constraints != nil {
|
||||
t.Fatalf("enum-discriminated requirements must not be mispublished as relationship constraints: %#v", constraints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryDocCommentExportImportContractsAreCanonical(t *testing.T) {
|
||||
comment := executeShortcutSchemaQuery(t, "--cli-path", "doc +comment-create")
|
||||
commentParameters := schemaContractMap(comment["parameters"])
|
||||
for _, name := range []string{"node", "content", "selection", "block-id", "start", "end", "selected-text", "mention"} {
|
||||
if _, ok := commentParameters[name]; !ok {
|
||||
t.Errorf("comment-create missing --%s: %#v", name, commentParameters)
|
||||
}
|
||||
}
|
||||
for name, want := range map[string]string{"node": "node", "mention": "mention"} {
|
||||
if got := schemaContractString(commentParameters[name]["property"]); got != want {
|
||||
t.Errorf("comment-create --%s property = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
export := executeShortcutSchemaQuery(t, "--cli-path", "doc +export")
|
||||
exportFormat := schemaContractMap(export["parameters"])["export-format"]
|
||||
if required, _ := exportFormat["required"].(bool); required {
|
||||
t.Fatalf("export --export-format required = true, want compatibility default")
|
||||
}
|
||||
if defaultValue := schemaContractString(exportFormat["default"]); defaultValue != "docx" {
|
||||
t.Fatalf("export --export-format default = %q, want docx", defaultValue)
|
||||
}
|
||||
|
||||
importLeaf := executeShortcutSchemaQuery(t, "--cli-path", "doc +import")
|
||||
constraints, _ := importLeaf["constraints"].(map[string]any)
|
||||
if requireOneOf, ok := constraints["require_one_of"]; ok && !schemaContractJSONEqual(requireOneOf, [][]string{}) {
|
||||
t.Fatalf("import unexpectedly requires a target: %#v", constraints)
|
||||
}
|
||||
}
|
||||
|
||||
func executeShortcutSchemaQuery(t testing.TB, args ...string) map[string]any {
|
||||
t.Helper()
|
||||
root := NewRootCommand()
|
||||
|
||||
@@ -81,6 +81,7 @@ func newServerFailureAPIError(
|
||||
apperrors.WithReason(fallbackReason),
|
||||
apperrors.WithServerKey(serverKey),
|
||||
apperrors.WithHint(fallbackHint),
|
||||
apperrors.WithActions("运行 dws doctor 检查登录态、网络和本地环境;持续失败时保留 Trace ID 和 Server Code"),
|
||||
apperrors.WithServerDiag(diag),
|
||||
}
|
||||
if classified, ok := classifyServerFailure(message, diag); ok {
|
||||
|
||||
@@ -95,6 +95,9 @@ func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing
|
||||
if typed.Reason != "business_error" || typed.Origin != "" || typed.FailureStage != "" || typed.ExecutionStarted != nil {
|
||||
t.Fatalf("unexpected fallback classification: %#v", typed)
|
||||
}
|
||||
if len(typed.Actions) == 0 || !strings.Contains(typed.Actions[0], "dws doctor") {
|
||||
t.Fatalf("fallback error has no stable troubleshooting entry: %#v", typed.Actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *testing.T) {
|
||||
|
||||
+577
-1
@@ -51,6 +51,8 @@ var (
|
||||
skillSetupInstallMono = installSkillToHomes
|
||||
skillSetupInstallMulti = installMultiSkillToHomes
|
||||
skillSetupCopyDir = copyDir
|
||||
skillSetupMkdirTemp = os.MkdirTemp
|
||||
skillSetupRename = os.Rename
|
||||
skillSetupRunForm = (*huh.Form).Run
|
||||
skillSetupInteractive = isInteractiveTerminal
|
||||
skillSetupReadDir = os.ReadDir
|
||||
@@ -136,6 +138,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
// multi 模式枚举 src 下的子 skill 名,供确认信息与安装步骤共用
|
||||
var multiSkillNames []string
|
||||
var foldedEventMiscTargets []string
|
||||
var migrateEventMiscTargets []string
|
||||
var installsEventMiscCompanion bool
|
||||
if mode == skillSetupModeMulti {
|
||||
allMultiSkillNames, listErr := skillSetupListMulti(skillSrc)
|
||||
if listErr != nil {
|
||||
@@ -151,6 +156,30 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
// dingtalk-shared carries the global rules every product skill declares as a
|
||||
// PREREQUISITE; it must ship even when --skill / --exclude narrows the set.
|
||||
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
|
||||
|
||||
foldedEventMiscTargets = findFoldedEventMiscTargets(dests)
|
||||
if len(foldedEventMiscTargets) > 0 {
|
||||
hasEvent := containsSkillName(multiSkillNames, multiEventSkill)
|
||||
hasMisc := containsSkillName(multiSkillNames, multiMiscSkill)
|
||||
switch {
|
||||
case normalizedSkillListContains(excludeRaw, multiEventSkill):
|
||||
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;不能显式 --exclude event,请先完成 dingtalk-event 迁移")
|
||||
case hasMisc && !hasEvent:
|
||||
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;不能只覆盖 dingtalk-misc,必须同时迁移 dingtalk-event")
|
||||
case hasEvent:
|
||||
if normalizedSkillListContains(excludeRaw, multiMiscSkill) {
|
||||
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;本次安装 dingtalk-event 必须同时迁移 dingtalk-misc,不能显式 --exclude misc")
|
||||
}
|
||||
if !containsSkillName(allMultiSkillNames, multiMiscSkill) {
|
||||
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由,但当前 multi 源缺少迁移所需的 %s", multiMiscSkill)
|
||||
}
|
||||
if err := validateEventMiscMigrationSource(skillSrc); err != nil {
|
||||
return err
|
||||
}
|
||||
migrateEventMiscTargets = append(migrateEventMiscTargets, foldedEventMiscTargets...)
|
||||
installsEventMiscCompanion = !hasMisc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --dry-run:仅预览将安装的内容与目标目录,不写入任何文件、不弹确认。
|
||||
@@ -162,11 +191,15 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
if mode == skillSetupModeMulti && len(multiSkillNames) > 0 {
|
||||
fmt.Fprintf(out, "子 skill:%s\n", strings.Join(multiSkillNames, ", "))
|
||||
printEventMiscMigrationPreview(out, migrateEventMiscTargets, installsEventMiscCompanion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !autoYes {
|
||||
if mode == skillSetupModeMulti {
|
||||
printEventMiscMigrationPreview(out, migrateEventMiscTargets, installsEventMiscCompanion)
|
||||
}
|
||||
ok, err := skillSetupConfirm(out, mode, skillSrc, dests, multiSkillNames)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -182,7 +215,14 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
case skillSetupModeMono:
|
||||
installed, skipped, err = skillSetupInstallMono(skillSrc, dests, out, errOut)
|
||||
case skillSetupModeMulti:
|
||||
installed, skipped, err = skillSetupInstallMulti(skillSrc, multiSkillNames, dests, out, errOut)
|
||||
installed, skipped, err = installMultiSkillsWithEventMigration(
|
||||
skillSrc,
|
||||
multiSkillNames,
|
||||
dests,
|
||||
migrateEventMiscTargets,
|
||||
out,
|
||||
errOut,
|
||||
)
|
||||
default:
|
||||
return fmt.Errorf("内部错误:未知 mode %q", mode)
|
||||
}
|
||||
@@ -191,6 +231,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, "\n✅ Skill 安装完成(mode=%s, installed=%d, skipped=%d)\n", mode, installed, skipped)
|
||||
fmt.Fprintln(out, "ℹ️ 若 Agent 会话已打开,请重启 Agent 或重新加载 Skills 后再验证路由。")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -204,6 +245,226 @@ const multiSkillPrefix = "dingtalk-"
|
||||
// dingtalk-shared that was never installed.
|
||||
const multiSharedSkill = "dingtalk-shared"
|
||||
|
||||
// legacyMultiSharedSkill is the retired name shipped by older multi-skill
|
||||
// bundles. Once the replacement has been installed successfully, remove this
|
||||
// exact directory so Agent discovery cannot load both routing contracts.
|
||||
const legacyMultiSharedSkill = "dws-shared"
|
||||
|
||||
const (
|
||||
multiEventSkill = "dingtalk-event"
|
||||
multiMiscSkill = "dingtalk-misc"
|
||||
)
|
||||
|
||||
var eventMigrationRequiredReferences = []string{
|
||||
"event-im.md",
|
||||
"event-im-keys.md",
|
||||
"event-im-lifecycle.md",
|
||||
"event-im-operations.md",
|
||||
"event-im-output.md",
|
||||
"event-oa.md",
|
||||
}
|
||||
|
||||
func containsSkillName(names []string, want string) bool {
|
||||
for _, name := range names {
|
||||
if name == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizedSkillListContains(raw []string, want string) bool {
|
||||
for _, name := range raw {
|
||||
if normalizeMultiSkillName(name) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// findFoldedEventMiscTargets identifies the short-lived multi-skill layout in
|
||||
// which personal Event routing lived inside dingtalk-misc. Both markers are
|
||||
// required so an unrelated misc install is never treated as a migration target.
|
||||
func findFoldedEventMiscTargets(dests []string) []string {
|
||||
var targets []string
|
||||
for _, dest := range dests {
|
||||
miscRoot := filepath.Join(dest, multiMiscSkill)
|
||||
skillBody, err := os.ReadFile(filepath.Join(miscRoot, "SKILL.md"))
|
||||
if err != nil || !containsPersonalEventRoute(skillBody) {
|
||||
continue
|
||||
}
|
||||
eventRef, err := skillSetupStat(filepath.Join(miscRoot, "references", "event.md"))
|
||||
if err != nil || eventRef.IsDir() {
|
||||
continue
|
||||
}
|
||||
targets = append(targets, dest)
|
||||
}
|
||||
sort.Strings(targets)
|
||||
return targets
|
||||
}
|
||||
|
||||
func containsPersonalEventRoute(skillBody []byte) bool {
|
||||
body := strings.ToLower(string(skillBody))
|
||||
for _, marker := range []string{
|
||||
"dws event",
|
||||
"个人 event",
|
||||
"个人 im 事件",
|
||||
"个人 im/oa",
|
||||
"personal event",
|
||||
} {
|
||||
if strings.Contains(body, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func printEventMiscMigrationPreview(out io.Writer, targets []string, installsCompanion bool) {
|
||||
if len(targets) == 0 {
|
||||
return
|
||||
}
|
||||
action := "将原子切换 dingtalk-event 与本次已选择的干净 dingtalk-misc"
|
||||
if installsCompanion {
|
||||
action = "将原子切换 dingtalk-event,并额外安装干净的 dingtalk-misc 作为迁移伴侣(仅限以下目标)"
|
||||
}
|
||||
fmt.Fprintf(out, "Event Skill 迁移:%s:\n", action)
|
||||
for _, target := range targets {
|
||||
fmt.Fprintf(out, " - %s\n", target)
|
||||
}
|
||||
}
|
||||
|
||||
func validateEventMiscMigrationSource(src string) error {
|
||||
if err := validateEventMigrationSkillRoot(filepath.Join(src, multiEventSkill)); err != nil {
|
||||
return fmt.Errorf("event Skill 迁移源无效: %w", err)
|
||||
}
|
||||
if err := validateMigrationSkillRoot(filepath.Join(src, multiMiscSkill), multiMiscSkill, nil); err != nil {
|
||||
return fmt.Errorf("event Skill 迁移源无效: %w", err)
|
||||
}
|
||||
|
||||
if err := validateCleanEventMiscRoot(filepath.Join(src, multiMiscSkill)); err != nil {
|
||||
return fmt.Errorf("event Skill 迁移源无效: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateEventMigrationSkillRoot(root string) error {
|
||||
required := make([]string, 0, len(eventMigrationRequiredReferences))
|
||||
for _, name := range eventMigrationRequiredReferences {
|
||||
required = append(required, filepath.Join("references", name))
|
||||
}
|
||||
return validateMigrationSkillRoot(root, multiEventSkill, required)
|
||||
}
|
||||
|
||||
func validateMigrationSkillRoot(root, expectedName string, requiredFiles []string) error {
|
||||
skillPath := filepath.Join(root, "SKILL.md")
|
||||
skillBody, err := os.ReadFile(skillPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法读取 %s: %w", skillPath, err)
|
||||
}
|
||||
name, err := parseMigrationSkillFrontmatter(skillBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s 无效: %w", skillPath, err)
|
||||
}
|
||||
if name != expectedName {
|
||||
return fmt.Errorf("%s 的 name=%q,期望 %q", skillPath, name, expectedName)
|
||||
}
|
||||
for _, rel := range requiredFiles {
|
||||
path := filepath.Join(root, rel)
|
||||
info, statErr := skillSetupStat(path)
|
||||
if statErr != nil || info.IsDir() {
|
||||
if statErr == nil {
|
||||
statErr = errors.New("is a directory")
|
||||
}
|
||||
return fmt.Errorf("缺少有效文件 %s: %w", path, statErr)
|
||||
}
|
||||
body, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("无法读取 %s: %w", path, readErr)
|
||||
}
|
||||
if strings.TrimSpace(string(body)) == "" {
|
||||
return fmt.Errorf("文件为空 %s", path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseMigrationSkillFrontmatter(body []byte) (string, error) {
|
||||
normalized := strings.ReplaceAll(string(body), "\r\n", "\n")
|
||||
lines := strings.Split(normalized, "\n")
|
||||
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
|
||||
return "", errors.New("缺少 YAML frontmatter")
|
||||
}
|
||||
name := ""
|
||||
description := ""
|
||||
closingLine := -1
|
||||
for i := 1; i < len(lines); i++ {
|
||||
rawLine := lines[i]
|
||||
line := strings.TrimSpace(rawLine)
|
||||
if line == "---" {
|
||||
closingLine = i
|
||||
break
|
||||
}
|
||||
// Only inspect top-level frontmatter keys. Nested metadata may legally
|
||||
// contain its own `name` without changing the Skill identity.
|
||||
if strings.TrimLeft(rawLine, " \t") != rawLine {
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value = strings.Trim(strings.TrimSpace(value), "\"'")
|
||||
switch strings.TrimSpace(key) {
|
||||
case "name":
|
||||
if name != "" {
|
||||
return "", errors.New("frontmatter 含重复 name")
|
||||
}
|
||||
name = value
|
||||
case "description":
|
||||
description = value
|
||||
}
|
||||
}
|
||||
if closingLine < 0 {
|
||||
return "", errors.New("YAML frontmatter 未闭合")
|
||||
}
|
||||
if name == "" {
|
||||
return "", errors.New("frontmatter 缺少 name")
|
||||
}
|
||||
if description == "" {
|
||||
return "", errors.New("frontmatter 缺少 description")
|
||||
}
|
||||
if strings.TrimSpace(strings.Join(lines[closingLine+1:], "\n")) == "" {
|
||||
return "", errors.New("SKILL.md 正文为空")
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func validateCleanEventMiscRoot(miscRoot string) error {
|
||||
miscSkillPath := filepath.Join(miscRoot, "SKILL.md")
|
||||
miscBody, err := os.ReadFile(miscSkillPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法读取 %s: %w", miscSkillPath, err)
|
||||
}
|
||||
if containsPersonalEventRoute(miscBody) {
|
||||
return fmt.Errorf("%s 仍包含个人 Event 路由", miscSkillPath)
|
||||
}
|
||||
refsRoot := filepath.Join(miscRoot, "references")
|
||||
entries, err := skillSetupReadDir(refsRoot)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法检查 %s: %w", refsRoot, err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
name := strings.ToLower(entry.Name())
|
||||
if !entry.IsDir() && strings.HasPrefix(name, "event") && strings.HasSuffix(name, ".md") {
|
||||
return fmt.Errorf("%s 仍存在折叠 Event 参考页", filepath.Join(refsRoot, entry.Name()))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureMandatorySharedSkill guarantees the shared dependency skill is included
|
||||
// whenever it exists in the source, even if --skill / --exclude narrowed it out.
|
||||
func ensureMandatorySharedSkill(selected, all []string) []string {
|
||||
@@ -607,6 +868,21 @@ func cleanupMutualExclusion(dest, mode string, out, errOut io.Writer) {
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupLegacyMultiSharedSkill(dest string, out, errOut io.Writer) {
|
||||
legacyPath := filepath.Join(dest, legacyMultiSharedSkill)
|
||||
if _, err := skillSetupStat(legacyPath); err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
fmt.Fprintf(errOut, " ⚠️ 无法检查已退役 Skill 残留 %s: %v\n", legacyPath, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := skillSetupRemoveAll(legacyPath); err != nil {
|
||||
fmt.Fprintf(errOut, " ⚠️ 已退役 Skill 清理失败(已安装 %s) %s: %v\n", multiSharedSkill, legacyPath, err)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(out, " × 已清理已退役 Skill 残留 %s\n", legacyPath)
|
||||
}
|
||||
|
||||
func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (installed, skipped int, err error) {
|
||||
sort.Strings(dests)
|
||||
for _, dest := range dests {
|
||||
@@ -634,6 +910,299 @@ func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (ins
|
||||
return installed, skipped, nil
|
||||
}
|
||||
|
||||
func installMultiSkillsWithEventMigration(
|
||||
src string,
|
||||
skillNames []string,
|
||||
dests []string,
|
||||
migrationTargets []string,
|
||||
out, errOut io.Writer,
|
||||
) (installed, skipped int, err error) {
|
||||
if len(migrationTargets) == 0 {
|
||||
return skillSetupInstallMulti(src, skillNames, dests, out, errOut)
|
||||
}
|
||||
|
||||
migrationSet := make(map[string]struct{}, len(migrationTargets))
|
||||
for _, dest := range migrationTargets {
|
||||
migrationSet[dest] = struct{}{}
|
||||
}
|
||||
var ordinaryTargets []string
|
||||
for _, dest := range dests {
|
||||
if _, migrates := migrationSet[dest]; !migrates {
|
||||
ordinaryTargets = append(ordinaryTargets, dest)
|
||||
}
|
||||
}
|
||||
|
||||
if len(ordinaryTargets) > 0 {
|
||||
var n, nSkipped int
|
||||
n, nSkipped, err = skillSetupInstallMulti(src, skillNames, ordinaryTargets, out, errOut)
|
||||
installed += n
|
||||
skipped += nSkipped
|
||||
if err != nil {
|
||||
return installed, skipped, err
|
||||
}
|
||||
if nSkipped > 0 {
|
||||
return installed, skipped, fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped)
|
||||
}
|
||||
}
|
||||
|
||||
// The folded pair is excluded from the ordinary best-effort installer. All
|
||||
// other selected skills (especially dingtalk-shared) must succeed before the
|
||||
// old Event route is touched.
|
||||
for _, dest := range migrationTargets {
|
||||
cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut)
|
||||
}
|
||||
var prerequisiteNames []string
|
||||
for _, name := range skillNames {
|
||||
if name != multiEventSkill && name != multiMiscSkill {
|
||||
prerequisiteNames = append(prerequisiteNames, name)
|
||||
}
|
||||
}
|
||||
if len(prerequisiteNames) > 0 {
|
||||
var n, nSkipped int
|
||||
n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, migrationTargets, out, errOut)
|
||||
installed += n
|
||||
skipped += nSkipped
|
||||
if err != nil {
|
||||
return installed, skipped, err
|
||||
}
|
||||
if nSkipped > 0 {
|
||||
return installed, skipped, fmt.Errorf("event Skill 迁移前置安装不完整(skipped=%d);已保留折叠版 Event/misc", nSkipped)
|
||||
}
|
||||
}
|
||||
|
||||
migrated, migrationErr := migrateEventMiscAtomically(src, migrationTargets, out, errOut)
|
||||
installed += migrated
|
||||
if migrationErr != nil {
|
||||
return installed, skipped, migrationErr
|
||||
}
|
||||
return installed, skipped, nil
|
||||
}
|
||||
|
||||
type eventMiscMigration struct {
|
||||
dest string
|
||||
|
||||
stageRoot string
|
||||
stagedEvent string
|
||||
stagedMisc string
|
||||
backupEvent string
|
||||
backupMisc string
|
||||
|
||||
eventPath string
|
||||
miscPath string
|
||||
|
||||
eventBackedUp bool
|
||||
miscBackedUp bool
|
||||
newEventEnabled bool
|
||||
newMiscEnabled bool
|
||||
}
|
||||
|
||||
func prepareEventMiscMigration(src, dest string) (*eventMiscMigration, error) {
|
||||
stageRoot, err := skillSetupMkdirTemp(dest, ".dws-event-migration-")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法在目标文件系统创建 Event Skill 迁移 staging %s: %w", dest, err)
|
||||
}
|
||||
migration := &eventMiscMigration{
|
||||
dest: dest,
|
||||
stageRoot: stageRoot,
|
||||
stagedEvent: filepath.Join(stageRoot, "new-event"),
|
||||
stagedMisc: filepath.Join(stageRoot, "new-misc"),
|
||||
backupEvent: filepath.Join(stageRoot, "old-event"),
|
||||
backupMisc: filepath.Join(stageRoot, "old-misc"),
|
||||
eventPath: filepath.Join(dest, multiEventSkill),
|
||||
miscPath: filepath.Join(dest, multiMiscSkill),
|
||||
}
|
||||
cleanupOnError := func(cause error) (*eventMiscMigration, error) {
|
||||
if cleanupErr := skillSetupRemoveAll(stageRoot); cleanupErr != nil {
|
||||
cause = errors.Join(cause, fmt.Errorf("清理 staging %s 失败: %w", stageRoot, cleanupErr))
|
||||
}
|
||||
return nil, cause
|
||||
}
|
||||
|
||||
if err := skillSetupCopyDir(filepath.Join(src, multiEventSkill), migration.stagedEvent); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("预备 dingtalk-event 失败 %s: %w", dest, err))
|
||||
}
|
||||
if err := skillSetupCopyDir(filepath.Join(src, multiMiscSkill), migration.stagedMisc); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("预备 dingtalk-misc 失败 %s: %w", dest, err))
|
||||
}
|
||||
if err := validateEventMigrationSkillRoot(migration.stagedEvent); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedEvent, err))
|
||||
}
|
||||
if err := validateMigrationSkillRoot(migration.stagedMisc, multiMiscSkill, nil); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedMisc, err))
|
||||
}
|
||||
if err := validateCleanEventMiscRoot(migration.stagedMisc); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedMisc, err))
|
||||
}
|
||||
return migration, nil
|
||||
}
|
||||
|
||||
func migrateEventMiscAtomically(src string, dests []string, out, errOut io.Writer) (int, error) {
|
||||
sortedDests := append([]string(nil), dests...)
|
||||
sort.Strings(sortedDests)
|
||||
migrations := make([]*eventMiscMigration, 0, len(sortedDests))
|
||||
|
||||
// Stage every target before switching any target. This prevents a source or
|
||||
// copy failure on a later Agent home from leaving earlier homes upgraded.
|
||||
for _, dest := range sortedDests {
|
||||
migration, err := prepareEventMiscMigration(src, dest)
|
||||
if err != nil {
|
||||
if cleanupErr := cleanupEventMiscStages(migrations, false, errOut); cleanupErr != nil {
|
||||
err = errors.Join(err, cleanupErr)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
migrations = append(migrations, migration)
|
||||
}
|
||||
|
||||
committed := make([]*eventMiscMigration, 0, len(migrations))
|
||||
for _, migration := range migrations {
|
||||
if err := commitEventMiscMigration(migration); err != nil {
|
||||
rollbackErr := rollbackEventMiscMigrations(committed)
|
||||
if rollbackErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf("已切换目标回滚失败: %w", rollbackErr))
|
||||
}
|
||||
var recoveryRoots []string
|
||||
for _, candidate := range migrations {
|
||||
if eventMiscMigrationNeedsRecovery(candidate) {
|
||||
recoveryRoots = append(recoveryRoots, candidate.stageRoot)
|
||||
}
|
||||
}
|
||||
if len(recoveryRoots) > 0 {
|
||||
err = errors.Join(err, fmt.Errorf("回滚不完整,已保留恢复目录(请勿删除): %s", strings.Join(recoveryRoots, ", ")))
|
||||
}
|
||||
if cleanupErr := cleanupEventMiscStages(migrations, true, errOut); cleanupErr != nil {
|
||||
err = errors.Join(err, cleanupErr)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
committed = append(committed, migration)
|
||||
}
|
||||
|
||||
for _, migration := range migrations {
|
||||
fmt.Fprintf(out, " ✓ %s\n", migration.eventPath)
|
||||
fmt.Fprintf(out, " ✓ %s(Event 原子迁移)\n", migration.miscPath)
|
||||
}
|
||||
if cleanupErr := cleanupEventMiscStages(migrations, false, errOut); cleanupErr != nil {
|
||||
fmt.Fprintf(errOut, " ⚠️ Event Skill 迁移已完成,但 staging 清理不完整: %v\n", cleanupErr)
|
||||
}
|
||||
return len(migrations) * 2, nil
|
||||
}
|
||||
|
||||
func cleanupEventMiscStages(migrations []*eventMiscMigration, preserveRecovery bool, errOut io.Writer) error {
|
||||
var cleanupErr error
|
||||
for _, migration := range migrations {
|
||||
if preserveRecovery && eventMiscMigrationNeedsRecovery(migration) {
|
||||
fmt.Fprintf(errOut, " ⚠️ 已保留 Event Skill 恢复目录 %s\n", migration.stageRoot)
|
||||
continue
|
||||
}
|
||||
if err := skillSetupRemoveAll(migration.stageRoot); err != nil {
|
||||
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("清理 Event Skill staging %s 失败: %w", migration.stageRoot, err))
|
||||
}
|
||||
}
|
||||
return cleanupErr
|
||||
}
|
||||
|
||||
func eventMiscMigrationNeedsRecovery(migration *eventMiscMigration) bool {
|
||||
return migration.eventBackedUp || migration.miscBackedUp || migration.newEventEnabled || migration.newMiscEnabled
|
||||
}
|
||||
|
||||
func commitEventMiscMigration(migration *eventMiscMigration) error {
|
||||
eventExists, err := skillSetupPathExists(migration.eventPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法检查旧 dingtalk-event %s: %w", migration.dest, err)
|
||||
}
|
||||
miscExists, err := skillSetupPathExists(migration.miscPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法检查旧 dingtalk-misc %s: %w", migration.dest, err)
|
||||
}
|
||||
if !miscExists {
|
||||
return fmt.Errorf("event Skill 迁移中止:折叠版 dingtalk-misc 已不存在 %s", migration.dest)
|
||||
}
|
||||
|
||||
rollbackFailure := func(cause error) error {
|
||||
if rollbackErr := rollbackEventMiscMigration(migration); rollbackErr != nil {
|
||||
return errors.Join(cause, fmt.Errorf("回滚 Event/misc 失败 %s: %w", migration.dest, rollbackErr))
|
||||
}
|
||||
return cause
|
||||
}
|
||||
if eventExists {
|
||||
if err := skillSetupRename(migration.eventPath, migration.backupEvent); err != nil {
|
||||
return fmt.Errorf("备份旧 dingtalk-event 失败 %s: %w", migration.dest, err)
|
||||
}
|
||||
migration.eventBackedUp = true
|
||||
}
|
||||
if err := skillSetupRename(migration.stagedEvent, migration.eventPath); err != nil {
|
||||
return rollbackFailure(fmt.Errorf("切换 dingtalk-event 失败 %s: %w", migration.dest, err))
|
||||
}
|
||||
migration.newEventEnabled = true
|
||||
if err := skillSetupRename(migration.miscPath, migration.backupMisc); err != nil {
|
||||
return rollbackFailure(fmt.Errorf("备份旧 dingtalk-misc 失败 %s: %w", migration.dest, err))
|
||||
}
|
||||
migration.miscBackedUp = true
|
||||
if err := skillSetupRename(migration.stagedMisc, migration.miscPath); err != nil {
|
||||
return rollbackFailure(fmt.Errorf("切换 dingtalk-misc 失败 %s: %w", migration.dest, err))
|
||||
}
|
||||
migration.newMiscEnabled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func rollbackEventMiscMigrations(migrations []*eventMiscMigration) error {
|
||||
var rollbackErr error
|
||||
for i := len(migrations) - 1; i >= 0; i-- {
|
||||
if err := rollbackEventMiscMigration(migrations[i]); err != nil {
|
||||
rollbackErr = errors.Join(rollbackErr, err)
|
||||
}
|
||||
}
|
||||
return rollbackErr
|
||||
}
|
||||
|
||||
func rollbackEventMiscMigration(migration *eventMiscMigration) error {
|
||||
move := func(enabled *bool, from, to, label string) error {
|
||||
if !*enabled {
|
||||
return nil
|
||||
}
|
||||
if err := skillSetupRename(from, to); err != nil {
|
||||
return fmt.Errorf("%s: %w", label, err)
|
||||
}
|
||||
*enabled = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop at the first rollback failure. In particular, do not remove the
|
||||
// already-working standalone Event while the folded misc route has not been
|
||||
// restored: even an incomplete rollback must leave at least one Event entry
|
||||
// point live and preserve the remaining assets in staging for recovery.
|
||||
steps := []struct {
|
||||
enabled *bool
|
||||
from string
|
||||
to string
|
||||
label string
|
||||
}{
|
||||
{&migration.newMiscEnabled, migration.miscPath, migration.stagedMisc, "移出新 dingtalk-misc"},
|
||||
{&migration.miscBackedUp, migration.backupMisc, migration.miscPath, "恢复旧 dingtalk-misc"},
|
||||
{&migration.newEventEnabled, migration.eventPath, migration.stagedEvent, "移出新 dingtalk-event"},
|
||||
{&migration.eventBackedUp, migration.backupEvent, migration.eventPath, "恢复旧 dingtalk-event"},
|
||||
}
|
||||
for _, step := range steps {
|
||||
if err := move(step.enabled, step.from, step.to, step.label); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func skillSetupPathExists(path string) (bool, error) {
|
||||
_, err := skillSetupStat(path)
|
||||
switch {
|
||||
case err == nil:
|
||||
return true, nil
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
return false, nil
|
||||
default:
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// installMultiSkillToHomes installs each subdir of src (dingtalk-*) into
|
||||
// dest as a sibling skill directory. installed/skipped is counted per
|
||||
// (agent-home × sub-skill) pair so the user sees granular progress.
|
||||
@@ -649,6 +1218,7 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
|
||||
continue
|
||||
}
|
||||
|
||||
sharedInstalled := false
|
||||
for _, name := range skillNames {
|
||||
subSrc := filepath.Join(src, name)
|
||||
subDest := filepath.Join(dest, name)
|
||||
@@ -664,6 +1234,12 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
|
||||
}
|
||||
fmt.Fprintf(out, " ✓ %s\n", subDest)
|
||||
installed++
|
||||
if name == multiSharedSkill {
|
||||
sharedInstalled = true
|
||||
}
|
||||
}
|
||||
if sharedInstalled {
|
||||
cleanupLegacyMultiSharedSkill(dest, out, errOut)
|
||||
}
|
||||
}
|
||||
return installed, skipped, nil
|
||||
|
||||
@@ -58,8 +58,9 @@ func TestMaterializeEmbeddedSkillSourceMono(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestMaterializeEmbeddedSkillSourceMulti verifies that the peer multi bundle
|
||||
// contains both the shared routing skill and misc (including folded PAT docs). Structured
|
||||
// Schema hints are build inputs and must not become a third installable mode.
|
||||
// contains the standalone Event skill, shared routing skill, and clean misc
|
||||
// (including PAT docs). Structured Schema hints are build inputs and must not
|
||||
// become a third installable mode.
|
||||
func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
|
||||
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
|
||||
if err != nil {
|
||||
@@ -71,6 +72,8 @@ func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
|
||||
t.Fatalf("extracted dir %s is not a valid multi skill source root", dir)
|
||||
}
|
||||
for _, rel := range []string{
|
||||
filepath.Join("dingtalk-event", "SKILL.md"),
|
||||
filepath.Join("dingtalk-event", "references", "event-oa.md"),
|
||||
filepath.Join("dingtalk-shared", "SKILL.md"),
|
||||
filepath.Join("dingtalk-misc", "SKILL.md"),
|
||||
filepath.Join("dingtalk-misc", "references", "pat.md"),
|
||||
@@ -79,6 +82,11 @@ func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
|
||||
t.Errorf("expected embedded multi skill to contain %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "dingtalk-misc", "references", "event.md")); err == nil {
|
||||
t.Fatal("embedded misc must not retain the folded personal Event reference")
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatalf("stat embedded misc event reference: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "schema-hints")); err == nil {
|
||||
t.Fatal("embedded multi skill must not contain build-only schema-hints")
|
||||
} else if !os.IsNotExist(err) {
|
||||
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -120,12 +122,112 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiSharedSkill, "dingtalk-chat"})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
legacyPath := filepath.Join(home, legacyMultiSharedSkill)
|
||||
customPath := filepath.Join(home, "custom-skill")
|
||||
for _, path := range []string{legacyPath, customPath} {
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte("legacy or custom\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(
|
||||
src,
|
||||
[]string{multiSharedSkill, "dingtalk-chat"},
|
||||
[]string{home},
|
||||
&out,
|
||||
&errOut,
|
||||
)
|
||||
if err != nil || installed != 2 || skipped != 0 {
|
||||
t.Fatalf("install = %d/%d, err=%v, stderr=%s", installed, skipped, err, errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("legacy shared skill still exists: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, multiSharedSkill, "SKILL.md")); err != nil {
|
||||
t.Fatalf("replacement shared skill missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(customPath, "SKILL.md")); err != nil {
|
||||
t.Fatalf("unrelated custom skill changed: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "已清理已退役 Skill 残留") {
|
||||
t.Fatalf("legacy cleanup was not reported: %s", out.String())
|
||||
}
|
||||
|
||||
t.Run("failed replacement preserves legacy", func(t *testing.T) {
|
||||
missingSource := t.TempDir()
|
||||
failureHome := filepath.Join(t.TempDir(), "skills")
|
||||
failureLegacy := filepath.Join(failureHome, legacyMultiSharedSkill)
|
||||
if err := os.MkdirAll(failureLegacy, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(failureLegacy, "SKILL.md"), []byte("legacy\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var failureOut, failureErr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(
|
||||
missingSource,
|
||||
[]string{multiSharedSkill},
|
||||
[]string{failureHome},
|
||||
&failureOut,
|
||||
&failureErr,
|
||||
)
|
||||
if err != nil || installed != 0 || skipped != 1 {
|
||||
t.Fatalf("failed replacement = %d/%d, err=%v", installed, skipped, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(failureLegacy, "SKILL.md")); err != nil {
|
||||
t.Fatalf("failed replacement removed legacy shared skill: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupLegacySharedCleanupFailures(t *testing.T) {
|
||||
fail := errors.New("legacy cleanup failure")
|
||||
|
||||
t.Run("missing legacy is a no-op", func(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
cleanupLegacyMultiSharedSkill(t.TempDir(), &out, &errOut)
|
||||
if out.Len() != 0 || errOut.Len() != 0 {
|
||||
t.Fatalf("missing legacy emitted output: stdout=%q stderr=%q", out.String(), errOut.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stat failure is reported", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, fail })
|
||||
var out, errOut bytes.Buffer
|
||||
cleanupLegacyMultiSharedSkill("dest", &out, &errOut)
|
||||
if out.Len() != 0 || !strings.Contains(errOut.String(), "无法检查已退役 Skill 残留") {
|
||||
t.Fatalf("stat failure output: stdout=%q stderr=%q", out.String(), errOut.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("remove failure is reported", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
|
||||
return skillSetupFileInfo{name: filepath.Base(path), mode: os.ModeDir}, nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return fail })
|
||||
var out, errOut bytes.Buffer
|
||||
cleanupLegacyMultiSharedSkill("dest", &out, &errOut)
|
||||
if out.Len() != 0 || !strings.Contains(errOut.String(), "已退役 Skill 清理失败") {
|
||||
t.Fatalf("remove failure output: stdout=%q stderr=%q", out.String(), errOut.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T) {
|
||||
oldRunForm, oldInteractive := skillSetupRunForm, skillSetupInteractive
|
||||
oldReadDir, oldStat := skillSetupReadDir, skillSetupStat
|
||||
oldExecutable, oldGetwd, oldHome := skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir
|
||||
oldRemove, oldMkdir := skillSetupRemoveAll, skillSetupMkdirAll
|
||||
oldCopyDir, oldWalk, oldRel := skillSetupCopyDir, skillSetupWalk, skillSetupRel
|
||||
oldMkdirTemp, oldRename := skillSetupMkdirTemp, skillSetupRename
|
||||
oldReadlink, oldOpen, oldOpenFile, oldCopy := skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy
|
||||
t.Cleanup(func() {
|
||||
skillSetupRunForm, skillSetupInteractive = oldRunForm, oldInteractive
|
||||
@@ -133,6 +235,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir = oldExecutable, oldGetwd, oldHome
|
||||
skillSetupRemoveAll, skillSetupMkdirAll = oldRemove, oldMkdir
|
||||
skillSetupCopyDir, skillSetupWalk, skillSetupRel = oldCopyDir, oldWalk, oldRel
|
||||
skillSetupMkdirTemp, skillSetupRename = oldMkdirTemp, oldRename
|
||||
skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy = oldReadlink, oldOpen, oldOpenFile, oldCopy
|
||||
})
|
||||
fail := errors.New("failure")
|
||||
@@ -310,3 +413,236 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
}
|
||||
_ = fs.ValidPath("path")
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupEventMigrationFailureBranches(t *testing.T) {
|
||||
fail := errors.New("injected failure")
|
||||
validSkill := func(name string) []byte {
|
||||
return []byte("---\nname: " + name + "\ndescription: valid migration skill\n---\n\n# Skill\n")
|
||||
}
|
||||
|
||||
t.Run("folded discovery rejects directory reference", func(t *testing.T) {
|
||||
dest := t.TempDir()
|
||||
miscRoot := filepath.Join(dest, multiMiscSkill)
|
||||
if err := os.MkdirAll(miscRoot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(miscRoot, "SKILL.md"), []byte("dws event\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
|
||||
if strings.HasSuffix(path, filepath.Join("references", "event.md")) {
|
||||
return skillSetupFileInfo{name: "event.md", mode: os.ModeDir}, nil
|
||||
}
|
||||
return os.Stat(path)
|
||||
})
|
||||
if got := findFoldedEventMiscTargets([]string{dest}); len(got) != 0 {
|
||||
t.Fatalf("directory event reference accepted: %#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("migration root validation failures", func(t *testing.T) {
|
||||
if err := validateMigrationSkillRoot(filepath.Join(t.TempDir(), "missing"), multiEventSkill, nil); err == nil {
|
||||
t.Fatal("missing SKILL.md succeeded")
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "SKILL.md"), validSkill(multiEventSkill), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "references", "directory.md"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateMigrationSkillRoot(root, multiEventSkill, []string{filepath.Join("references", "directory.md")}); err == nil || !strings.Contains(err.Error(), "is a directory") {
|
||||
t.Fatalf("directory required file = %v", err)
|
||||
}
|
||||
|
||||
missing := filepath.Join("references", "missing.md")
|
||||
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
|
||||
if path == filepath.Join(root, missing) {
|
||||
return skillSetupFileInfo{name: "missing.md"}, nil
|
||||
}
|
||||
return os.Stat(path)
|
||||
})
|
||||
if err := validateMigrationSkillRoot(root, multiEventSkill, []string{missing}); err == nil || !strings.Contains(err.Error(), "无法读取") {
|
||||
t.Fatalf("unreadable required file = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("frontmatter validation branches", func(t *testing.T) {
|
||||
validWithIgnoredLine := []byte("---\nignored line\nname: dingtalk-event\ndescription: valid\n---\n\nbody\n")
|
||||
if name, err := parseMigrationSkillFrontmatter(validWithIgnoredLine); err != nil || name != multiEventSkill {
|
||||
t.Fatalf("ignored frontmatter line = %q, %v", name, err)
|
||||
}
|
||||
for name, body := range map[string][]byte{
|
||||
"duplicate name": []byte("---\nname: one\nname: two\ndescription: valid\n---\nbody\n"),
|
||||
"unclosed": []byte("---\nname: one\ndescription: valid\nbody\n"),
|
||||
"missing name": []byte("---\ndescription: valid\n---\nbody\n"),
|
||||
"missing desc": []byte("---\nname: one\n---\nbody\n"),
|
||||
"empty body": []byte("---\nname: one\ndescription: valid\n---\n \n"),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := parseMigrationSkillFrontmatter(body); err == nil {
|
||||
t.Fatal("invalid frontmatter succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("clean misc validation branches", func(t *testing.T) {
|
||||
if err := validateCleanEventMiscRoot(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("missing misc root succeeded")
|
||||
}
|
||||
|
||||
routed := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(routed, "SKILL.md"), append(validSkill(multiMiscSkill), []byte("dws event\n")...), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateCleanEventMiscRoot(routed); err == nil || !strings.Contains(err.Error(), "仍包含个人 Event 路由") {
|
||||
t.Fatalf("routed misc = %v", err)
|
||||
}
|
||||
|
||||
clean := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(clean, "SKILL.md"), validSkill(multiMiscSkill), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateCleanEventMiscRoot(clean); err != nil {
|
||||
t.Fatalf("missing references should be clean: %v", err)
|
||||
}
|
||||
|
||||
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, fail })
|
||||
if err := validateCleanEventMiscRoot(clean); !errors.Is(err, fail) {
|
||||
t.Fatalf("read-dir failure = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ordinary and prerequisite install errors", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
|
||||
return 0, 0, fail
|
||||
})
|
||||
migration := filepath.Join(t.TempDir(), "migration")
|
||||
ordinary := filepath.Join(t.TempDir(), "ordinary")
|
||||
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, io.Discard, io.Discard); !errors.Is(err, fail) {
|
||||
t.Fatalf("ordinary install failure = %v", err)
|
||||
}
|
||||
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill, multiMiscSkill, multiSharedSkill}, []string{migration}, []string{migration}, io.Discard, io.Discard); !errors.Is(err, fail) {
|
||||
t.Fatalf("prerequisite install failure = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preparation cleanup and staged misc validation", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
|
||||
dest := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return fail })
|
||||
cleanupFail := errors.New("cleanup failure")
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
|
||||
if _, err := prepareEventMiscMigration(src, dest); err == nil || !errors.Is(err, fail) || !errors.Is(err, cleanupFail) {
|
||||
t.Fatalf("joined preparation cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid staged misc root", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
|
||||
if err := os.WriteFile(filepath.Join(src, multiMiscSkill, "SKILL.md"), validSkill(multiEventSkill), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := prepareEventMiscMigration(src, t.TempDir()); err == nil || !strings.Contains(err.Error(), "staging 验证失败") {
|
||||
t.Fatalf("invalid staged misc = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("later staging failure joins cleanup error", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
|
||||
first := filepath.Join(t.TempDir(), "a")
|
||||
second := filepath.Join(t.TempDir(), "b")
|
||||
if err := os.MkdirAll(first, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(second, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originalMkdirTemp := skillSetupMkdirTemp
|
||||
calls := 0
|
||||
testseam.Swap(t, &skillSetupMkdirTemp, func(dir, pattern string) (string, error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return "", fail
|
||||
}
|
||||
return originalMkdirTemp(dir, pattern)
|
||||
})
|
||||
cleanupFail := errors.New("stage cleanup failure")
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
|
||||
if _, err := migrateEventMiscAtomically(src, []string{second, first}, io.Discard, io.Discard); err == nil || !errors.Is(err, fail) || !errors.Is(err, cleanupFail) {
|
||||
t.Fatalf("later preparation failure = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("successful migration reports cleanup warning", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
cleanupFail := errors.New("final cleanup failure")
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
|
||||
var stderr bytes.Buffer
|
||||
installed, err := migrateEventMiscAtomically(src, []string{home}, io.Discard, &stderr)
|
||||
if err != nil || installed != 2 || !strings.Contains(stderr.String(), cleanupFail.Error()) {
|
||||
t.Fatalf("successful migration cleanup warning: installed=%d err=%v stderr=%s", installed, err, stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("commit rollback and cleanup failures are joined", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
|
||||
root := t.TempDir()
|
||||
first := filepath.Join(root, "a", "skills")
|
||||
second := filepath.Join(root, "b", "skills")
|
||||
for _, home := range []string{first, second} {
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
}
|
||||
commitFail := errors.New("second commit failure")
|
||||
rollbackFail := errors.New("first rollback failure")
|
||||
originalRename := skillSetupRename
|
||||
testseam.Swap(t, &skillSetupRename, func(oldPath, newPath string) error {
|
||||
if filepath.Base(oldPath) == "new-misc" && newPath == filepath.Join(second, multiMiscSkill) {
|
||||
return commitFail
|
||||
}
|
||||
if filepath.Base(oldPath) == "old-misc" && newPath == filepath.Join(first, multiMiscSkill) {
|
||||
return rollbackFail
|
||||
}
|
||||
return originalRename(oldPath, newPath)
|
||||
})
|
||||
cleanupFail := errors.New("post-rollback cleanup failure")
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
|
||||
if _, err := migrateEventMiscAtomically(src, []string{second, first}, io.Discard, io.Discard); err == nil || !errors.Is(err, commitFail) || !errors.Is(err, rollbackFail) || !errors.Is(err, cleanupFail) {
|
||||
t.Fatalf("joined commit/rollback/cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("commit preflight and rollback aggregation", func(t *testing.T) {
|
||||
migration := &eventMiscMigration{dest: "dest", eventPath: "event", miscPath: "misc"}
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, fail })
|
||||
if err := commitEventMiscMigration(migration); !errors.Is(err, fail) {
|
||||
t.Fatalf("event stat failure = %v", err)
|
||||
}
|
||||
|
||||
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
|
||||
if path == migration.eventPath {
|
||||
return skillSetupFileInfo{name: "event", mode: os.ModeDir}, nil
|
||||
}
|
||||
return nil, fail
|
||||
})
|
||||
if err := commitEventMiscMigration(migration); !errors.Is(err, fail) {
|
||||
t.Fatalf("misc stat failure = %v", err)
|
||||
}
|
||||
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
|
||||
if err := commitEventMiscMigration(migration); err == nil || !strings.Contains(err.Error(), "已不存在") {
|
||||
t.Fatalf("missing misc = %v", err)
|
||||
}
|
||||
|
||||
migration.newMiscEnabled = true
|
||||
testseam.Swap(t, &skillSetupRename, func(string, string) error { return fail })
|
||||
if err := rollbackEventMiscMigrations([]*eventMiscMigration{migration}); !errors.Is(err, fail) {
|
||||
t.Fatalf("rollback aggregation = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -184,12 +187,20 @@ func writeMultiSkillSource(t *testing.T, names []string) string {
|
||||
if err := os.MkdirAll(filepath.Join(sub, "references"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte("# "+n), 0o644); err != nil {
|
||||
skillBody := "---\nname: " + n + "\ndescription: test skill\n---\n\n# " + n + "\n"
|
||||
if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte(skillBody), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sub, "references", n+".md"), []byte("ref "+n), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n == multiEventSkill {
|
||||
for _, ref := range eventMigrationRequiredReferences {
|
||||
if err := os.WriteFile(filepath.Join(sub, "references", ref), []byte("ref "+ref+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
@@ -549,3 +560,787 @@ func TestResolveSkillSetupSourceMultiFinds(t *testing.T) {
|
||||
t.Fatalf("expected %s, got %s", multiDir, got)
|
||||
}
|
||||
}
|
||||
|
||||
func executeMultiSkillSetupTest(t *testing.T, src string, dests []string, args ...string) (string, string, error) {
|
||||
t.Helper()
|
||||
originalTargets := skillSetupResolveTargets
|
||||
skillSetupResolveTargets = func(string, string) ([]string, error) {
|
||||
return append([]string(nil), dests...), nil
|
||||
}
|
||||
t.Cleanup(func() { skillSetupResolveTargets = originalTargets })
|
||||
|
||||
cmd := newSkillSetupCommand()
|
||||
cmd.Flags().Bool("dry-run", false, "")
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
baseArgs := []string{"--mode", "multi", "--source", src}
|
||||
cmd.SetArgs(append(baseArgs, args...))
|
||||
err := cmd.Execute()
|
||||
return stdout.String(), stderr.String(), err
|
||||
}
|
||||
|
||||
func writeFoldedEventMisc(t *testing.T, agentHome string) {
|
||||
t.Helper()
|
||||
miscRoot := filepath.Join(agentHome, multiMiscSkill)
|
||||
if err := os.MkdirAll(filepath.Join(miscRoot, "references"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(miscRoot, "SKILL.md"), []byte("personal IM route: dws event consume\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(miscRoot, "references", "event.md"), []byte("folded event docs\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeOldStandaloneEvent(t *testing.T, agentHome string) {
|
||||
t.Helper()
|
||||
eventRoot := filepath.Join(agentHome, multiEventSkill)
|
||||
if err := os.MkdirAll(eventRoot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(eventRoot, "SKILL.md"), []byte("old standalone event\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertOldEventMiscPair(t *testing.T, agentHome string) {
|
||||
t.Helper()
|
||||
eventBody, err := os.ReadFile(filepath.Join(agentHome, multiEventSkill, "SKILL.md"))
|
||||
if err != nil || string(eventBody) != "old standalone event\n" {
|
||||
t.Fatalf("old standalone event was not restored: body=%q err=%v", eventBody, err)
|
||||
}
|
||||
miscBody, err := os.ReadFile(filepath.Join(agentHome, multiMiscSkill, "SKILL.md"))
|
||||
if err != nil || !strings.Contains(string(miscBody), "dws event") {
|
||||
t.Fatalf("folded misc was not restored: body=%q err=%v", miscBody, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentHome, multiMiscSkill, "references", "event.md")); err != nil {
|
||||
t.Fatalf("folded Event reference was not restored: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoEventMigrationStages(t *testing.T, agentHome string) {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(agentHome)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".dws-event-migration-") {
|
||||
t.Fatalf("unexpected leftover Event migration stage %s", filepath.Join(agentHome, entry.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{
|
||||
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
|
||||
})
|
||||
foldedHome := filepath.Join(t.TempDir(), "folded", "skills")
|
||||
freshHome := filepath.Join(t.TempDir(), "fresh", "skills")
|
||||
writeFoldedEventMisc(t, foldedHome)
|
||||
if err := os.MkdirAll(filepath.Join(foldedHome, multiEventSkill), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(foldedHome, multiEventSkill, "SKILL.md"), []byte("old standalone event\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(foldedHome, "dingtalk-chat"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(foldedHome, "dingtalk-chat", "SKILL.md"), []byte("keep sibling\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event")
|
||||
if err != nil {
|
||||
t.Fatalf("selective event setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout)
|
||||
}
|
||||
if !strings.Contains(stdout, "迁移伴侣") || !strings.Contains(stdout, foldedHome) {
|
||||
t.Fatalf("confirmation output should expose folded misc migration: %s", stdout)
|
||||
}
|
||||
if !strings.Contains(stdout, "重新加载 Skills") {
|
||||
t.Fatalf("completion should tell the user to reload skills: %s", stdout)
|
||||
}
|
||||
|
||||
for _, home := range []string{freshHome, foldedHome} {
|
||||
for _, name := range []string{multiSharedSkill, multiEventSkill} {
|
||||
if _, err := os.Stat(filepath.Join(home, name, "SKILL.md")); err != nil {
|
||||
t.Errorf("%s missing from %s: %v", name, home, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, "dingtalk-doc")); !os.IsNotExist(err) {
|
||||
t.Errorf("unselected doc appeared in %s: %v", home, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(freshHome, multiMiscSkill)); !os.IsNotExist(err) {
|
||||
t.Fatalf("fresh selective target must not receive misc, stat err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(foldedHome, multiMiscSkill, "references", "event.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("folded event reference survived clean misc replacement, stat err=%v", err)
|
||||
}
|
||||
eventBody, err := os.ReadFile(filepath.Join(foldedHome, multiEventSkill, "SKILL.md"))
|
||||
if err != nil || strings.Contains(string(eventBody), "old standalone") {
|
||||
t.Fatalf("old standalone event was not replaced: body=%q err=%v", eventBody, err)
|
||||
}
|
||||
siblingBody, err := os.ReadFile(filepath.Join(foldedHome, "dingtalk-chat", "SKILL.md"))
|
||||
if err != nil || string(siblingBody) != "keep sibling\n" {
|
||||
t.Fatalf("unrelated sibling changed: body=%q err=%v", siblingBody, err)
|
||||
}
|
||||
|
||||
// A second selective run sees the already-clean misc, does not plan another
|
||||
// migration, and leaves that unselected sibling in place.
|
||||
stdout, stderr, err = executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("idempotent event setup failed: %v\nstderr=%s", err, stderr)
|
||||
}
|
||||
if strings.Contains(stdout, "迁移伴侣") {
|
||||
t.Fatalf("clean second run should not re-detect folded misc: %s", stdout)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(foldedHome, multiMiscSkill, "SKILL.md")); err != nil {
|
||||
t.Fatalf("second selective run removed clean misc: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationDryRunAndExplicitExclude(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{
|
||||
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
|
||||
})
|
||||
|
||||
t.Run("dry run reports companion without writes", func(t *testing.T) {
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--dry-run", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("dry run failed: %v\nstderr=%s", err, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout, "DRY-RUN") || !strings.Contains(stdout, "迁移伴侣") {
|
||||
t.Fatalf("dry run did not expose migration: %s", stdout)
|
||||
}
|
||||
body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
|
||||
if readErr != nil || !strings.Contains(string(body), "dws event") {
|
||||
t.Fatalf("dry run changed folded misc: body=%q err=%v", body, readErr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("dry run installed event, stat err=%v", statErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("excluding required misc fails before writes", func(t *testing.T) {
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--exclude", "misc", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "不能显式 --exclude misc") {
|
||||
t.Fatalf("expected clear migration exclusion error, got %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("failed migration installed event, stat err=%v", statErr)
|
||||
}
|
||||
body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
|
||||
if readErr != nil || !strings.Contains(string(body), "dws event") {
|
||||
t.Fatalf("failed migration changed misc: body=%q err=%v", body, readErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationRequiresCleanMiscInSource(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "当前 multi 源缺少") {
|
||||
t.Fatalf("expected missing migration companion error, got %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("failed preflight installed event, stat err=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationAcceptsShippedMultiBundle(t *testing.T) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := filepath.Clean(filepath.Join(wd, "..", "..", "skills", "multi"))
|
||||
if err := validateEventMiscMigrationSource(src); err != nil {
|
||||
t.Fatalf("shipped multi bundle is not a valid Event migration source: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationRejectsInvalidSkillBundlesBeforeWrites(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(t *testing.T, src string)
|
||||
}{
|
||||
{
|
||||
name: "empty event root",
|
||||
mutate: func(t *testing.T, src string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(src, multiEventSkill, "SKILL.md"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wrong event name",
|
||||
mutate: func(t *testing.T, src string) {
|
||||
t.Helper()
|
||||
body := "---\nname: dingtalk-chat\ndescription: wrong skill\n---\n\n# Wrong\n"
|
||||
if err := os.WriteFile(filepath.Join(src, multiEventSkill, "SKILL.md"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing event reference",
|
||||
mutate: func(t *testing.T, src string) {
|
||||
t.Helper()
|
||||
if err := os.Remove(filepath.Join(src, multiEventSkill, "references", "event-oa.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty event reference",
|
||||
mutate: func(t *testing.T, src string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(src, multiEventSkill, "references", "event-im.md"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wrong misc name",
|
||||
mutate: func(t *testing.T, src string) {
|
||||
t.Helper()
|
||||
body := "---\nname: dingtalk-event\ndescription: wrong skill\n---\n\n# Wrong\n"
|
||||
if err := os.WriteFile(filepath.Join(src, multiMiscSkill, "SKILL.md"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
test.mutate(t, src)
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
|
||||
stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "迁移源无效") {
|
||||
t.Fatalf("invalid migration source was accepted: %v", err)
|
||||
}
|
||||
if strings.Contains(stdout, "Skill 安装完成") {
|
||||
t.Fatalf("invalid migration source reported success: %s", stdout)
|
||||
}
|
||||
assertOldEventMiscPair(t, home)
|
||||
if _, statErr := os.Stat(filepath.Join(home, multiSharedSkill)); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("invalid source wrote shared skill: %v", statErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupSelectiveEventPreservesFoldedMiscAfterPrimarySkip(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
|
||||
originalInstallMulti := skillSetupInstallMulti
|
||||
t.Cleanup(func() { skillSetupInstallMulti = originalInstallMulti })
|
||||
calls := 0
|
||||
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
|
||||
calls++
|
||||
if calls > 1 {
|
||||
t.Fatal("misc migration companion ran after a primary install skip")
|
||||
}
|
||||
return 1, 1, nil
|
||||
}
|
||||
|
||||
stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "已保留折叠版 Event/misc") {
|
||||
t.Fatalf("expected preserved-fallback error, got %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("install calls = %d, want 1", calls)
|
||||
}
|
||||
if strings.Contains(stdout, "Skill 安装完成") {
|
||||
t.Fatalf("partial migration reported success: %s", stdout)
|
||||
}
|
||||
body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
|
||||
if readErr != nil || !strings.Contains(string(body), "dws event") {
|
||||
t.Fatalf("primary skip changed folded misc: body=%q err=%v", body, readErr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(home, multiMiscSkill, "references", "event.md")); statErr != nil {
|
||||
t.Fatalf("primary skip removed folded Event reference: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupFreshTargetFailureDoesNotTouchFoldedPair(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
freshHome := filepath.Join(t.TempDir(), "fresh", "skills")
|
||||
foldedHome := filepath.Join(t.TempDir(), "folded", "skills")
|
||||
writeFoldedEventMisc(t, foldedHome)
|
||||
writeOldStandaloneEvent(t, foldedHome)
|
||||
|
||||
originalInstallMulti := skillSetupInstallMulti
|
||||
t.Cleanup(func() { skillSetupInstallMulti = originalInstallMulti })
|
||||
calls := 0
|
||||
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
|
||||
calls++
|
||||
if calls > 1 {
|
||||
t.Fatal("folded target prerequisites ran after fresh target failure")
|
||||
}
|
||||
return 1, 1, nil
|
||||
}
|
||||
|
||||
stdout, _, err := executeMultiSkillSetupTest(
|
||||
t,
|
||||
src,
|
||||
[]string{foldedHome, freshHome},
|
||||
"--skill", "event",
|
||||
"--yes",
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "已保留折叠版 Event/misc") {
|
||||
t.Fatalf("fresh target failure did not block migration: %v", err)
|
||||
}
|
||||
if strings.Contains(stdout, "Skill 安装完成") {
|
||||
t.Fatalf("partial mixed-target install reported success: %s", stdout)
|
||||
}
|
||||
assertOldEventMiscPair(t, foldedHome)
|
||||
assertNoEventMigrationStages(t, foldedHome)
|
||||
}
|
||||
|
||||
func TestSkillSetupUnrelatedSelectiveInstallLeavesFoldedPairUntouched(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{
|
||||
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
|
||||
})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
|
||||
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "doc", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("unrelated selective install failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
|
||||
}
|
||||
if strings.Contains(stdout, "Event Skill 迁移") || strings.Contains(stdout, "Event 原子迁移") {
|
||||
t.Fatalf("unrelated selective install planned Event migration: %s", stdout)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, "dingtalk-doc", "SKILL.md")); err != nil {
|
||||
t.Fatalf("selected doc was not installed: %v", err)
|
||||
}
|
||||
assertOldEventMiscPair(t, home)
|
||||
assertNoEventMigrationStages(t, home)
|
||||
}
|
||||
|
||||
func TestSkillSetupSelectiveEventAtomicStageFailurePreservesFoldedPair(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
|
||||
originalCopyDir := skillSetupCopyDir
|
||||
t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
|
||||
skillSetupCopyDir = func(src, dest string) error {
|
||||
if strings.HasSuffix(dest, "new-misc") {
|
||||
return errors.New("injected stage failure")
|
||||
}
|
||||
return originalCopyDir(src, dest)
|
||||
}
|
||||
|
||||
stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "预备 dingtalk-misc 失败") {
|
||||
t.Fatalf("expected atomic staging error, got %v", err)
|
||||
}
|
||||
if strings.Contains(stdout, "Skill 安装完成") {
|
||||
t.Fatalf("partial atomic migration reported success: %s", stdout)
|
||||
}
|
||||
body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
|
||||
if readErr != nil || !strings.Contains(string(body), "dws event") {
|
||||
t.Fatalf("stage failure changed folded misc: body=%q err=%v", body, readErr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("stage failure installed standalone event, stat err=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationPreparationFailuresPreserveFoldedPair(t *testing.T) {
|
||||
t.Run("same-filesystem staging creation", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
|
||||
originalMkdirTemp := skillSetupMkdirTemp
|
||||
t.Cleanup(func() { skillSetupMkdirTemp = originalMkdirTemp })
|
||||
skillSetupMkdirTemp = func(dir, pattern string) (string, error) {
|
||||
if dir != home {
|
||||
t.Fatalf("staging dir = %s, want target filesystem root %s", dir, home)
|
||||
}
|
||||
return "", errors.New("injected mkdir-temp failure")
|
||||
}
|
||||
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "injected mkdir-temp failure") {
|
||||
t.Fatalf("staging creation failure was not returned: %v", err)
|
||||
}
|
||||
assertOldEventMiscPair(t, home)
|
||||
assertNoEventMigrationStages(t, home)
|
||||
})
|
||||
|
||||
t.Run("event staging copy", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
|
||||
originalCopyDir := skillSetupCopyDir
|
||||
t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
|
||||
skillSetupCopyDir = func(src, dest string) error {
|
||||
if strings.HasSuffix(dest, "new-event") {
|
||||
return errors.New("injected event copy failure")
|
||||
}
|
||||
return originalCopyDir(src, dest)
|
||||
}
|
||||
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "injected event copy failure") {
|
||||
t.Fatalf("event staging failure was not returned: %v", err)
|
||||
}
|
||||
assertOldEventMiscPair(t, home)
|
||||
assertNoEventMigrationStages(t, home)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSkillSetupSelectiveEventRejectsCorruptStagedMisc(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
|
||||
originalCopyDir := skillSetupCopyDir
|
||||
t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
|
||||
skillSetupCopyDir = func(src, dest string) error {
|
||||
if err := originalCopyDir(src, dest); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(dest, "new-misc") {
|
||||
return os.WriteFile(filepath.Join(dest, "references", "event-partial.md"), []byte("corrupt\n"), 0o644)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "staging 验证失败") {
|
||||
t.Fatalf("corrupt staged misc was accepted: %v", err)
|
||||
}
|
||||
miscBody, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
|
||||
if readErr != nil || !strings.Contains(string(miscBody), "dws event") {
|
||||
t.Fatalf("staging validation failure changed folded misc: body=%q err=%v", miscBody, readErr)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(err) {
|
||||
t.Fatalf("staging validation failure installed event: %v", err)
|
||||
}
|
||||
assertNoEventMigrationStages(t, home)
|
||||
}
|
||||
|
||||
func TestSkillSetupSelectiveEventRejectsIncompleteStagedEvent(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
|
||||
originalCopyDir := skillSetupCopyDir
|
||||
t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
|
||||
skillSetupCopyDir = func(src, dest string) error {
|
||||
if err := originalCopyDir(src, dest); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(dest, "new-event") {
|
||||
return os.Remove(filepath.Join(dest, "references", "event-oa.md"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "staging 验证失败") {
|
||||
t.Fatalf("incomplete staged Event was accepted: %v", err)
|
||||
}
|
||||
assertOldEventMiscPair(t, home)
|
||||
assertNoEventMigrationStages(t, home)
|
||||
}
|
||||
|
||||
func TestSkillSetupFullEventMigrationIsAtomicAndPreservesSiblings(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{
|
||||
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
|
||||
})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
sibling := filepath.Join(home, "dingtalk-private-sibling")
|
||||
if err := os.MkdirAll(sibling, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sibling, "SKILL.md"), []byte("keep\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("full setup migration failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout, "Event 原子迁移") || !strings.Contains(stdout, "Skill 安装完成") {
|
||||
t.Fatalf("full setup did not report atomic migration success: %s", stdout)
|
||||
}
|
||||
for _, name := range []string{multiEventSkill, multiMiscSkill, multiSharedSkill, "dingtalk-doc"} {
|
||||
if _, err := os.Stat(filepath.Join(home, name, "SKILL.md")); err != nil {
|
||||
t.Fatalf("full setup missing %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, multiMiscSkill, "references", "event.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("full setup retained folded Event reference: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(sibling, "SKILL.md"))
|
||||
if err != nil || string(body) != "keep\n" {
|
||||
t.Fatalf("full setup changed unrelated sibling: body=%q err=%v", body, err)
|
||||
}
|
||||
assertNoEventMigrationStages(t, home)
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationWithoutSharedStillCleansMonoLeftover(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
monoLeftover := filepath.Join(home, "dws")
|
||||
if err := os.MkdirAll(monoLeftover, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(monoLeftover, "SKILL.md"), []byte("old mono\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("migration without shared failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
|
||||
}
|
||||
if _, err := os.Stat(monoLeftover); !os.IsNotExist(err) {
|
||||
t.Fatalf("migration without prerequisites retained mono leftover: %v", err)
|
||||
}
|
||||
for _, name := range []string{multiEventSkill, multiMiscSkill} {
|
||||
if _, err := os.Stat(filepath.Join(home, name, "SKILL.md")); err != nil {
|
||||
t.Fatalf("migration without shared missing %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
assertNoEventMigrationStages(t, home)
|
||||
}
|
||||
|
||||
func TestSkillSetupFoldedEventMigrationSelectionPreflight(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{
|
||||
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
|
||||
})
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "misc only", args: []string{"--skill", "misc", "--yes"}, want: "不能只覆盖 dingtalk-misc"},
|
||||
{name: "explicitly excludes event", args: []string{"--exclude", "event", "--yes"}, want: "不能显式 --exclude event"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, tt.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("preflight error = %v, want %q", err, tt.want)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, multiSharedSkill)); !os.IsNotExist(err) {
|
||||
t.Fatalf("preflight failure wrote shared skill: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(err) {
|
||||
t.Fatalf("preflight failure wrote event skill: %v", err)
|
||||
}
|
||||
miscBody, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
|
||||
if readErr != nil || !strings.Contains(string(miscBody), "dws event") {
|
||||
t.Fatalf("preflight failure changed folded misc: body=%q err=%v", miscBody, readErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationRejectsEveryFoldedReferenceVariant(t *testing.T) {
|
||||
for _, filename := range []string{"event.md", "event-im.md", "event-oa.md", "EVENT-legacy.MD"} {
|
||||
t.Run(filename, func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
if err := os.WriteFile(filepath.Join(src, multiMiscSkill, "references", filename), []byte("stale\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "仍存在折叠 Event 参考页") {
|
||||
t.Fatalf("source with %s was accepted: %v", filename, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, multiSharedSkill)); !os.IsNotExist(err) {
|
||||
t.Fatalf("invalid source wrote shared skill: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationRenameFailuresRollbackPair(t *testing.T) {
|
||||
for failAt := 1; failAt <= 4; failAt++ {
|
||||
t.Run(fmt.Sprintf("rename_%d", failAt), func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
|
||||
originalRename := skillSetupRename
|
||||
t.Cleanup(func() { skillSetupRename = originalRename })
|
||||
renameCalls := 0
|
||||
skillSetupRename = func(oldPath, newPath string) error {
|
||||
renameCalls++
|
||||
if renameCalls == failAt {
|
||||
return errors.New("injected rename failure")
|
||||
}
|
||||
return originalRename(oldPath, newPath)
|
||||
}
|
||||
|
||||
stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "injected rename failure") {
|
||||
t.Fatalf("rename failure %d was not returned: %v", failAt, err)
|
||||
}
|
||||
if strings.Contains(stdout, "Skill 安装完成") {
|
||||
t.Fatalf("rename failure %d reported success: %s", failAt, stdout)
|
||||
}
|
||||
assertOldEventMiscPair(t, home)
|
||||
assertNoEventMigrationStages(t, home)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationFailureRollsBackEarlierTargets(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
root := t.TempDir()
|
||||
firstHome := filepath.Join(root, "a", "skills")
|
||||
secondHome := filepath.Join(root, "b", "skills")
|
||||
for _, home := range []string{firstHome, secondHome} {
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
}
|
||||
|
||||
originalRename := skillSetupRename
|
||||
t.Cleanup(func() { skillSetupRename = originalRename })
|
||||
failed := false
|
||||
skillSetupRename = func(oldPath, newPath string) error {
|
||||
if !failed && oldPath == filepath.Join(secondHome, multiMiscSkill) {
|
||||
failed = true
|
||||
return errors.New("second target failure")
|
||||
}
|
||||
return originalRename(oldPath, newPath)
|
||||
}
|
||||
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{secondHome, firstHome}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "second target failure") {
|
||||
t.Fatalf("second target failure was not returned: %v", err)
|
||||
}
|
||||
for _, home := range []string{firstHome, secondHome} {
|
||||
assertOldEventMiscPair(t, home)
|
||||
assertNoEventMigrationStages(t, home)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationRollbackFailurePreservesRecoveryDirectory(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
|
||||
originalRename := skillSetupRename
|
||||
t.Cleanup(func() { skillSetupRename = originalRename })
|
||||
skillSetupRename = func(oldPath, newPath string) error {
|
||||
if strings.HasSuffix(oldPath, filepath.Join("new-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
|
||||
return errors.New("commit failure")
|
||||
}
|
||||
if strings.HasSuffix(oldPath, filepath.Join("old-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
|
||||
return errors.New("rollback restore failure")
|
||||
}
|
||||
return originalRename(oldPath, newPath)
|
||||
}
|
||||
|
||||
_, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "回滚不完整") || !strings.Contains(err.Error(), "恢复目录") {
|
||||
t.Fatalf("rollback failure did not expose recovery directory: %v", err)
|
||||
}
|
||||
entries, readErr := os.ReadDir(home)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
var recoveryRoot string
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".dws-event-migration-") {
|
||||
recoveryRoot = filepath.Join(home, entry.Name())
|
||||
break
|
||||
}
|
||||
}
|
||||
if recoveryRoot == "" {
|
||||
t.Fatal("rollback failure deleted the only recovery directory")
|
||||
}
|
||||
if !strings.Contains(err.Error(), recoveryRoot) || !strings.Contains(stderr, recoveryRoot) {
|
||||
t.Fatalf("recovery directory was not reported: err=%v stderr=%s", err, stderr)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(recoveryRoot, "old-misc", "SKILL.md")); err != nil {
|
||||
t.Fatalf("old folded misc backup is missing from recovery directory: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(recoveryRoot, "old-event", "SKILL.md")); err != nil {
|
||||
t.Fatalf("old standalone Event backup is missing from recovery directory: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, multiEventSkill, "SKILL.md")); err != nil {
|
||||
t.Fatalf("rollback failure removed the live standalone Event entry: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationRollbackFailureKeepsNewEventWithoutOldStandalone(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
|
||||
originalRename := skillSetupRename
|
||||
t.Cleanup(func() { skillSetupRename = originalRename })
|
||||
skillSetupRename = func(oldPath, newPath string) error {
|
||||
if strings.HasSuffix(oldPath, filepath.Join("new-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
|
||||
return errors.New("commit failure")
|
||||
}
|
||||
if strings.HasSuffix(oldPath, filepath.Join("old-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
|
||||
return errors.New("rollback restore failure")
|
||||
}
|
||||
return originalRename(oldPath, newPath)
|
||||
}
|
||||
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "回滚不完整") {
|
||||
t.Fatalf("rollback failure was not returned: %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill, "SKILL.md")); statErr != nil {
|
||||
t.Fatalf("rollback failure removed the only live Event entry: %v", statErr)
|
||||
}
|
||||
entries, readErr := os.ReadDir(home)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !strings.HasPrefix(entry.Name(), ".dws-event-migration-") {
|
||||
continue
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(home, entry.Name(), "old-misc", "SKILL.md")); statErr != nil {
|
||||
t.Fatalf("rollback failure lost the folded misc recovery copy: %v", statErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("rollback failure did not preserve a recovery directory")
|
||||
}
|
||||
|
||||
@@ -568,9 +568,9 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat +flag-list",
|
||||
Aliases: map[string]string{
|
||||
"limit": "size",
|
||||
"limit": "page-size",
|
||||
},
|
||||
Blocked: []string{"count", "max", "max-results", "max-size", "page", "page-size", "per-page"},
|
||||
Blocked: []string{"count", "max", "max-results", "max-size", "page", "per-page"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat +group-members",
|
||||
@@ -2003,7 +2003,6 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"next-page-token": "cursor",
|
||||
"next-token": "cursor",
|
||||
"node-id": "node",
|
||||
"page-size": "limit",
|
||||
"page-token": "cursor",
|
||||
"per-page": "limit",
|
||||
"size": "limit",
|
||||
@@ -2459,25 +2458,15 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "doc +version-list",
|
||||
Aliases: map[string]string{
|
||||
"doc": "node",
|
||||
"doc-id": "node",
|
||||
"document-id": "node",
|
||||
"file-id": "node",
|
||||
"max-result": "limit",
|
||||
"max-results": "limit",
|
||||
"next-cursor": "cursor",
|
||||
"next-page-token": "cursor",
|
||||
"next-token": "cursor",
|
||||
"node-id": "node",
|
||||
"page-size": "limit",
|
||||
"page-token": "cursor",
|
||||
"per-page": "limit",
|
||||
"size": "limit",
|
||||
"take": "limit",
|
||||
"top": "limit",
|
||||
"url": "node",
|
||||
"doc": "node",
|
||||
"doc-id": "node",
|
||||
"document-id": "node",
|
||||
"file-id": "node",
|
||||
"node-id": "node",
|
||||
"url": "node",
|
||||
},
|
||||
Blocked: []string{"block-id", "comment-id", "comment-key", "count", "folder", "folder-id", "id", "job-id", "offset", "page", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"},
|
||||
Blocked: []string{"block-id", "comment-id", "comment-key", "folder", "folder-id", "id", "job-id", "parent-id", "revision", "task-id", "template-id", "version", "workspace", "workspace-id"},
|
||||
Ambiguous: []string{"max-result", "max-results", "next-cursor", "next-page-token", "next-token", "per-page", "size", "take", "top"},
|
||||
},
|
||||
{
|
||||
CLIPath: "doc +version-revert",
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
},
|
||||
|
||||
"command_overrides": {
|
||||
"doc +version-list": {"ambiguous": ["size", "max-results", "max-result", "take", "top", "per-page", "next-cursor", "next-token", "next-page-token"], "note": "--limit/--cursor and the shipped visible compatibility flags --page-size/--page-token remain native. Other pagination spellings cannot choose between two visible real flags and must stop before execution."},
|
||||
"chat group rename": {"bind": {"id": "open_conversation_id"}, "note": "This command's real --id carries one openConversationId; aliases reduce to --id without changing the value."},
|
||||
"chat group members": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat group members add": {"bind": {"id": "open_conversation_id"}, "block": ["user-id", "open-dingtalk-id"], "note": "The real --users is a list and may contain mixed userId/openDingTalkId values; singular inputs are not promoted automatically."},
|
||||
@@ -148,7 +149,7 @@
|
||||
"chat +chat-update": {"scoped_aliases": {"conversation-id": "group", "open-conversation-id": "group", "chat-id": "group", "title": "name", "new-title": "name"}, "block": ["id", "group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "scope_strict": true, "note": "--group accepts a name or CID, so only explicit CID spellings are mapped; generic --id is blocked."},
|
||||
"chat +conversation-set-top": {"scoped_aliases": {"open-conversation-id": "conversation-id", "chat-id": "conversation-id", "open-conversation-ids": "conversation-ids", "chat-ids": "conversation-ids"}, "block": ["group", "groups", "group-id", "group-ids", "top", "set-top"], "scope_strict": true, "note": "Singular/list cardinality stays explicit; top/set-top cannot be rewritten to the inverse --off switch."},
|
||||
"chat +feed-group-query-item": {"scoped_aliases": {"open-conversation-ids": "conversation-ids", "chat-ids": "conversation-ids"}, "block": ["group", "groups", "group-id", "group-ids", "conversation-id", "open-conversation-id"], "scope_strict": true, "note": "The real field is an openConversationId list; group names and singular IDs are not converted."},
|
||||
"chat +flag-list": {"scoped_aliases": {"limit": "size"}, "block": ["max", "max-results", "max-size", "count", "page", "page-size", "per-page"], "scope_strict": true, "note": "Only limit and size are reviewed as the same page bound; total-count and page-number spellings are not equivalent."},
|
||||
"chat +flag-list": {"scoped_aliases": {"limit": "page-size"}, "block": ["max", "max-results", "max-size", "count", "page", "per-page"], "scope_strict": true, "note": "Native --page-size is the canonical page bound, native --size is its command-owned compatibility alias, and --limit is reviewed as value-preservingly equivalent to --page-size; total-count and page-number spellings are not equivalent."},
|
||||
"chat +messages-batch-recall-by-bot": {"block": ["msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "note": "--keys carries processQueryKey values returned by bot sending; it is not an openMessageId field."},
|
||||
"chat +messages-combine-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "Source and destination conversation roles remain explicit; role-free CID spellings cannot choose a side."},
|
||||
"chat +messages-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id", "src-open-message-id": "msg-id", "source-message-id": "msg-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "The message role is uniquely the source message, but source/destination conversation roles cannot be inferred from a generic CID."},
|
||||
@@ -361,7 +362,7 @@
|
||||
{"command": "chat +chat-update", "emitted": "id", "expect": "did-you-mean:blocked", "via": "guard:generic-id-value-domain"},
|
||||
{"command": "chat +chat-update", "emitted": "title", "expect": "name", "via": "override:scoped-group-title"},
|
||||
{"command": "chat +chat-update", "emitted": "new-title", "expect": "name", "via": "override:scoped-group-title"},
|
||||
{"command": "chat +flag-list", "emitted": "limit", "expect": "size", "via": "override:scoped-page-bound"},
|
||||
{"command": "chat +flag-list", "emitted": "limit", "expect": "page-size", "via": "override:scoped-page-bound"},
|
||||
{"command": "chat +flag-list", "emitted": "max", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
{"command": "chat +flag-list", "emitted": "max-results", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
{"command": "chat +flag-list", "emitted": "max-size", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
|
||||
@@ -256,6 +256,10 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"chat.list_conversation_message_v2 --open-dingtalk-id": "selects the alternate list_individual_chat_message branch",
|
||||
"chat.list_conversation_message_v2 --user": "selects the alternate list_individual_chat_message branch",
|
||||
"chat.list_message_favorites --cursor": "Reviewed unpinned adapter: chat.list_message_favorites has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.list_message_favorites --max-items": "Reviewed helper-only pagination control: --max-items caps aggregated CLI output for chat.list_message_favorites and is not sent to the remote adapter.",
|
||||
"chat.list_message_favorites --page-all": "Reviewed helper-only pagination control: --page-all enables CLI-side looping for chat.list_message_favorites and is not sent to the remote adapter.",
|
||||
"chat.list_message_favorites --page-delay": "Reviewed helper-only pagination control: --page-delay controls local delay between chat.list_message_favorites pages and is not sent to the remote adapter.",
|
||||
"chat.list_message_favorites --page-limit": "Reviewed helper-only pagination control: --page-limit caps CLI-side page fetches for chat.list_message_favorites and is not sent to the remote adapter.",
|
||||
"chat.list_message_favorites --size": "Reviewed unpinned adapter: chat.list_message_favorites has no singular pinned interface_ref; --size is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.query_msg_read_status --user": "conditional wrapper: parseCSVValues + appendChatIDArgs routes each supplied identifier to targetUserIds or targetOpenDingTalkIds according to its runtime ID shape; there is no single RPC property for this flag",
|
||||
"chat.query_msg_read_status --users": "conditional wrapper/alias of --user: parseCSVValues + appendChatIDArgs routes each supplied identifier to targetUserIds or targetOpenDingTalkIds according to its runtime ID shape; there is no single RPC property for this flag",
|
||||
@@ -592,10 +596,26 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"sheet.range_batch_clear --node": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.range_batch_clear --ranges": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --ranges is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.range_batch_clear --type": "Reviewed unpinned adapter: sheet.range_batch_clear has no singular pinned interface_ref; --type is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.range_batch_set_style --batch": "Composite wrapper reads this local JSON file and performs zero or more update_range calls; the filesystem path is not a direct RPC property.",
|
||||
"sheet.range_batch_set_style --continue-on-error": "Composite wrapper consumes this flag in its local multi-call error loop and never sends it to update_range.",
|
||||
"sheet.range_batch_set_style --batch": "Composite wrapper reads this local JSON file and assembles it into a single sheet/batch_update operations array; the filesystem path is not a direct RPC property.",
|
||||
"sheet.range_batch_set_style --continue-on-error": "Reviewed unpinned adapter: sheet.range_batch_set_style has no singular pinned interface_ref; --continue-on-error is forwarded to sheet/batch_update as continueOnError and does not publish a direct interface property.",
|
||||
"sheet.range_read --range": "Reviewed unpinned adapter: sheet.range_read has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.range_read --sheet-id": "Reviewed unpinned adapter: sheet.range_read has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.range_set_style --bg-color": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.backgroundColor across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --bg-colors-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.backgroundColor across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --border-styles-json": "Composite stamp wrapper: set-style attaches this object as cells[][].borderStyles on every cell of the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-color": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontColor across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-colors-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.fontColor across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-family": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontFamily across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-line": "Composite stamp wrapper: set-style expands this single-choice flag into the cells[][].cellStyles.textUnderline and textLineThrough booleans of the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-size": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontSize across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-sizes-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.fontSize across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-style": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontStyle across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-weight": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.fontWeight across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --font-weights-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.fontWeight across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --h-align": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.horizontalAlignment across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --h-aligns-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.horizontalAlignment across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --v-align": "Composite stamp wrapper: set-style expands this flag into cells[][].cellStyles.verticalAlignment across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.range_set_style --v-aligns-json": "Composite stamp wrapper: set-style expands this per-cell matrix into cells[][].cellStyles.verticalAlignment across the set_cell_range matrix; it has no top-level RPC property.",
|
||||
"sheet.reply_sheet_comment --comment-key": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --comment-key is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.reply_sheet_comment --content": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --content is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.reply_sheet_comment --emoji": "Reviewed unpinned adapter: sheet.reply_sheet_comment has no singular pinned interface_ref; --emoji is a CLI wrapper input and does not publish a direct interface property.",
|
||||
|
||||
@@ -558,6 +558,18 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
|
||||
for _, alias := range flag.Aliases {
|
||||
RegisterFlag(cmd, flag.Kind, alias, "", flag.Usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
if registered := cmd.Flags().Lookup(alias); registered != nil {
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOf,
|
||||
flag.Name,
|
||||
)
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOrigin,
|
||||
runtimeannotate.FlagAliasOriginCorecmdV1,
|
||||
)
|
||||
}
|
||||
}
|
||||
if flag.MarkRequired {
|
||||
_ = cmd.MarkFlagRequired(flag.Name)
|
||||
|
||||
@@ -90,11 +90,17 @@ func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
// Aliases are registered with the main kind and hidden.
|
||||
for _, alias := range []string{"i-alias", "sl-alias"} {
|
||||
for alias, canonical := range map[string]string{"i-alias": "i", "sl-alias": "sl"} {
|
||||
f := cmd.Flags().Lookup(alias)
|
||||
if f == nil || !f.Hidden {
|
||||
t.Fatalf("alias %q = %#v, want registered+hidden", alias, f)
|
||||
}
|
||||
if got := f.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != canonical {
|
||||
t.Fatalf("alias %q annotation = %#v, want alias_of %q", alias, got, canonical)
|
||||
}
|
||||
if got := f.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
|
||||
t.Fatalf("alias %q origin = %#v, want corecmd FlagSpec marker", alias, got)
|
||||
}
|
||||
}
|
||||
if cmd.Flags().Lookup("i-alias").Value.Type() != "int" {
|
||||
t.Fatal("int alias must be registered as int")
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package runtimeannotate
|
||||
|
||||
// CLI flag alias evidence is kept in this narrow file so the base-owned
|
||||
// Interface Snapshot helper can add the protocol constants to an older stable
|
||||
// worktree without replacing that revision's complete runtimeannotate package.
|
||||
// Only corecmd.FlagSpec.Aliases writes the exact origin; neither field is a
|
||||
// Schema synonym or final payload-equivalence proof.
|
||||
const (
|
||||
AnnotationFlagAliasOf = "dws.compat.alias_of"
|
||||
AnnotationFlagAliasOrigin = "dws.compat.alias_origin"
|
||||
FlagAliasOriginCorecmdV1 = "corecmd.flag_spec_aliases.v1"
|
||||
)
|
||||
@@ -41,7 +41,7 @@ func TestCrossPlatformCoverageDiagnosticsAndErrorRenderingEdges(t *testing.T) {
|
||||
t.Fatalf("PrintJSON friendly diagnostics = %q, %v", out.String(), err)
|
||||
}
|
||||
out.Reset()
|
||||
if err := PrintHumanAt(&out, err, VerbosityVerbose); err != nil || !strings.Contains(out.String(), "开启地址") {
|
||||
if err := PrintHumanAt(&out, err, VerbosityVerbose); err != nil || !strings.Contains(out.String(), "处理入口") {
|
||||
t.Fatalf("PrintHuman friendly diagnostics = %q, %v", out.String(), err)
|
||||
}
|
||||
out.Reset()
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -460,7 +461,7 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
|
||||
lines = append(lines, tui.Cyan("Hint: "+friendlyHint))
|
||||
}
|
||||
if actionURL != "" {
|
||||
lines = append(lines, tui.White("Action: 开启地址: "+actionURL))
|
||||
lines = append(lines, tui.White("Action: 处理入口: "+actionURL))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,7 +541,7 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
|
||||
|
||||
func serverGuidance(diag ServerDiagnostics) (string, string) {
|
||||
friendlyHint := strings.TrimSpace(diag.FriendlyHint)
|
||||
actionURL := strings.TrimSpace(diag.ActionURL)
|
||||
actionURL := safeServerActionURL(diag.ActionURL)
|
||||
if friendlyHint == "" || actionURL == "" {
|
||||
switch diag.ServerErrorCode {
|
||||
case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED":
|
||||
@@ -555,6 +556,19 @@ func serverGuidance(diag ServerDiagnostics) (string, string) {
|
||||
return friendlyHint, actionURL
|
||||
}
|
||||
|
||||
func safeServerActionURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || !strings.EqualFold(parsed.Scheme, "https") ||
|
||||
parsed.Hostname() == "" || parsed.User != nil {
|
||||
return ""
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func category(err error) string {
|
||||
var typed *Error
|
||||
if stderrors.As(err, &typed) {
|
||||
|
||||
@@ -336,11 +336,40 @@ func TestCrossPlatformCoveragePrintHumanIncludesServerGuidance(t *testing.T) {
|
||||
if !strings.Contains(got, "Hint: 请联系管理员开通消息搜索权益") {
|
||||
t.Fatalf("expected server guidance in output, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Action: 开启地址: https://example.test/enable-search") {
|
||||
if !strings.Contains(got, "Action: 处理入口: https://example.test/enable-search") {
|
||||
t.Fatalf("expected server action URL in output, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageServerGuidanceSuppressesUnsafeActionURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, actionURL := range []string{
|
||||
"http://example.test/help",
|
||||
"javascript:alert(1)",
|
||||
"https://user:secret@example.test/help",
|
||||
"not a url",
|
||||
} {
|
||||
var human strings.Builder
|
||||
err := NewAPI("server error", WithServerDiag(ServerDiagnostics{
|
||||
FriendlyHint: "保留 Trace ID 后排查",
|
||||
ActionURL: actionURL,
|
||||
}))
|
||||
if printErr := PrintHuman(&human, err); printErr != nil {
|
||||
t.Fatal(printErr)
|
||||
}
|
||||
if strings.Contains(human.String(), actionURL) || strings.Contains(human.String(), "处理入口") {
|
||||
t.Fatalf("unsafe action URL %q leaked to human output: %q", actionURL, human.String())
|
||||
}
|
||||
var jsonOutput strings.Builder
|
||||
if printErr := PrintJSON(&jsonOutput, err); printErr != nil {
|
||||
t.Fatal(printErr)
|
||||
}
|
||||
if strings.Contains(jsonOutput.String(), `"action_url"`) {
|
||||
t.Fatalf("unsafe action URL %q leaked to JSON output: %q", actionURL, jsonOutput.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePrintJSONIncludesRPCCodeAndData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -128,6 +128,12 @@ func TestClientCreateRuleBasedSubscriptionsUsesDocumentedRuleParam(t *testing.T)
|
||||
{"receive_user/openDingtalkId", EventFromUser, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
|
||||
{"receive_o2o_all", EventAllSingleChat, RuleOptions{}, map[string]any{}},
|
||||
{"receive_group_all", EventAllGroupChat, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_task_created", EventOAApprovalTaskCreated, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_task_finished", EventOAApprovalTaskFinished, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_task_redirected", EventOAApprovalTaskRedirected, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_instance_started", EventOAApprovalInstanceStarted, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_instance_terminated", EventOAApprovalInstanceTerminated, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_instance_finished", EventOAApprovalInstanceFinished, RuleOptions{}, map[string]any{}},
|
||||
{"read_group", EventReadGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
|
||||
{"recall_group", EventRecallGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
|
||||
{"reaction_group", EventReactionGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
|
||||
|
||||
@@ -121,6 +121,94 @@ type GroupLifecycleEventOutput struct {
|
||||
Payload map[string]any `json:"payload" description:"群生命周期事件业务数据,字段以服务端实际推送为准" additional_properties:"true"`
|
||||
}
|
||||
|
||||
type OAApprovalTaskCreatedOutput struct {
|
||||
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
|
||||
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
|
||||
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
|
||||
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
|
||||
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
|
||||
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
|
||||
TaskID string `json:"task_id" description:"审批任务 ID"`
|
||||
Title string `json:"title" description:"审批标题"`
|
||||
Status string `json:"status" description:"审批任务状态"`
|
||||
CreateTime int64 `json:"create_time" description:"审批任务创建时间" format:"timestamp_ms"`
|
||||
EventTime int64 `json:"event_time" description:"审批任务事件业务时间" format:"timestamp_ms"`
|
||||
}
|
||||
|
||||
type OAApprovalTaskFinishedOutput struct {
|
||||
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
|
||||
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
|
||||
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
|
||||
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
|
||||
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
|
||||
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
|
||||
TaskID string `json:"task_id" description:"审批任务 ID"`
|
||||
Title string `json:"title" description:"审批标题"`
|
||||
Status string `json:"status" description:"审批任务状态"`
|
||||
Result string `json:"result" description:"审批任务处理结果,值以服务端实际推送为准"`
|
||||
CreateTime int64 `json:"create_time" description:"审批任务创建时间" format:"timestamp_ms"`
|
||||
FinishTime int64 `json:"finish_time" description:"审批任务完成时间" format:"timestamp_ms"`
|
||||
EventTime int64 `json:"event_time" description:"审批任务事件业务时间" format:"timestamp_ms"`
|
||||
}
|
||||
|
||||
type OAApprovalTaskRedirectedOutput struct {
|
||||
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
|
||||
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
|
||||
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
|
||||
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
|
||||
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
|
||||
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
|
||||
TaskID string `json:"task_id" description:"原审批任务 ID"`
|
||||
Title string `json:"title" description:"审批标题"`
|
||||
Status string `json:"status" description:"原审批任务状态"`
|
||||
Result string `json:"result" description:"审批任务转交结果,值以服务端实际推送为准"`
|
||||
CreateTime int64 `json:"create_time" description:"原审批任务创建时间" format:"timestamp_ms"`
|
||||
FinishTime int64 `json:"finish_time" description:"原审批任务转交完成时间" format:"timestamp_ms"`
|
||||
EventTime int64 `json:"event_time" description:"审批任务转交事件业务时间" format:"timestamp_ms"`
|
||||
}
|
||||
|
||||
type OAApprovalInstanceStartedOutput struct {
|
||||
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
|
||||
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
|
||||
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
|
||||
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
|
||||
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
|
||||
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
|
||||
Title string `json:"title" description:"审批标题"`
|
||||
Status string `json:"status" description:"审批实例状态"`
|
||||
CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"`
|
||||
EventTime int64 `json:"event_time" description:"审批实例事件业务时间" format:"timestamp_ms"`
|
||||
}
|
||||
|
||||
type OAApprovalInstanceTerminatedOutput struct {
|
||||
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
|
||||
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
|
||||
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
|
||||
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
|
||||
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
|
||||
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
|
||||
Title string `json:"title" description:"审批标题"`
|
||||
Status string `json:"status" description:"审批实例状态"`
|
||||
CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"`
|
||||
FinishTime int64 `json:"finish_time" description:"审批实例终止时间" format:"timestamp_ms"`
|
||||
EventTime int64 `json:"event_time" description:"审批实例终止事件业务时间" format:"timestamp_ms"`
|
||||
}
|
||||
|
||||
type OAApprovalInstanceFinishedOutput struct {
|
||||
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
|
||||
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
|
||||
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
|
||||
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
|
||||
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
|
||||
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
|
||||
Title string `json:"title" description:"审批标题"`
|
||||
Status string `json:"status" description:"审批实例状态"`
|
||||
Result string `json:"result" description:"审批实例处理结果,值以服务端实际推送为准"`
|
||||
CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"`
|
||||
FinishTime int64 `json:"finish_time" description:"审批实例完成时间" format:"timestamp_ms"`
|
||||
EventTime int64 `json:"event_time" description:"审批实例事件业务时间" format:"timestamp_ms"`
|
||||
}
|
||||
|
||||
type GroupMemberEventOutput struct {
|
||||
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
|
||||
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
|
||||
@@ -230,6 +318,22 @@ type personalGroupMemberRecord struct {
|
||||
OpenDingTalkID string `json:"openDingTalkId"`
|
||||
}
|
||||
|
||||
type personalOAApprovalPayload struct {
|
||||
EventTime int64 `json:"event_time"`
|
||||
Body personalOAApprovalBody `json:"body"`
|
||||
}
|
||||
|
||||
type personalOAApprovalBody struct {
|
||||
ProcessInstanceID string `json:"processInstanceId"`
|
||||
ProcessCode string `json:"processCode"`
|
||||
TaskID string `json:"taskId"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Result string `json:"result"`
|
||||
CreateTime int64 `json:"createTime"`
|
||||
FinishTime int64 `json:"finishTime"`
|
||||
}
|
||||
|
||||
func (b *personalReactionBody) UnmarshalJSON(data []byte) error {
|
||||
// encoding/json otherwise falls back to case-insensitive field matching.
|
||||
// Read this protocol field from a map so only operOpenDingtalkId is accepted.
|
||||
@@ -343,7 +447,7 @@ func ProjectOutput(ev transport.Event) (any, error) {
|
||||
case isGroupMemberEvent(eventType):
|
||||
return projectGroupMemberEvent(ev, base, data.Payload)
|
||||
case isGroupLifecycleEvent(eventType):
|
||||
payload, err := decodeGroupLifecyclePayload(data.Payload)
|
||||
payload, err := decodeConservativePayload(data.Payload)
|
||||
if err != nil {
|
||||
return ev, fmt.Errorf("decode personal group lifecycle payload: %w", err)
|
||||
}
|
||||
@@ -354,6 +458,8 @@ func ProjectOutput(ev transport.Event) (any, error) {
|
||||
SubscribeID: base.SubscribeID,
|
||||
Payload: payload,
|
||||
}, nil
|
||||
case isOAEvent(eventType):
|
||||
return projectOAApprovalEvent(ev, base, data.Payload)
|
||||
default:
|
||||
return ev, fmt.Errorf("unsupported personal event type %q", eventType)
|
||||
}
|
||||
@@ -370,7 +476,7 @@ func projectMessageEventContext(message personalMessageContext) MessageEventCont
|
||||
}
|
||||
}
|
||||
|
||||
func decodeGroupLifecyclePayload(raw json.RawMessage) (map[string]any, error) {
|
||||
func decodeConservativePayload(raw json.RawMessage) (map[string]any, error) {
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
||||
return nil, fmt.Errorf("payload is missing")
|
||||
@@ -489,6 +595,112 @@ func projectGroupMemberEvent(ev transport.Event, base baseEventOutput, raw json.
|
||||
}, nil
|
||||
}
|
||||
|
||||
func projectOAApprovalEvent(ev transport.Event, base baseEventOutput, raw json.RawMessage) (any, error) {
|
||||
var payload personalOAApprovalPayload
|
||||
if err := decodeRequiredPayload(raw, &payload); err != nil {
|
||||
return ev, fmt.Errorf("decode personal OA payload: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(payload.Body.ProcessInstanceID) == "" {
|
||||
return ev, fmt.Errorf("decode personal OA payload: processInstanceId is required")
|
||||
}
|
||||
if isOAApprovalTaskEvent(base.Type) && strings.TrimSpace(payload.Body.TaskID) == "" {
|
||||
return ev, fmt.Errorf("decode personal OA payload: taskId is required for %s", base.Type)
|
||||
}
|
||||
|
||||
switch base.Type {
|
||||
case EventOAApprovalTaskCreated:
|
||||
return OAApprovalTaskCreatedOutput{
|
||||
Type: base.Type,
|
||||
EventID: base.EventID,
|
||||
Timestamp: base.Timestamp,
|
||||
SubscribeID: base.SubscribeID,
|
||||
ProcessInstanceID: payload.Body.ProcessInstanceID,
|
||||
ProcessCode: payload.Body.ProcessCode,
|
||||
TaskID: payload.Body.TaskID,
|
||||
Title: payload.Body.Title,
|
||||
Status: payload.Body.Status,
|
||||
CreateTime: payload.Body.CreateTime,
|
||||
EventTime: payload.EventTime,
|
||||
}, nil
|
||||
case EventOAApprovalTaskFinished:
|
||||
return OAApprovalTaskFinishedOutput{
|
||||
Type: base.Type,
|
||||
EventID: base.EventID,
|
||||
Timestamp: base.Timestamp,
|
||||
SubscribeID: base.SubscribeID,
|
||||
ProcessInstanceID: payload.Body.ProcessInstanceID,
|
||||
ProcessCode: payload.Body.ProcessCode,
|
||||
TaskID: payload.Body.TaskID,
|
||||
Title: payload.Body.Title,
|
||||
Status: payload.Body.Status,
|
||||
Result: payload.Body.Result,
|
||||
CreateTime: payload.Body.CreateTime,
|
||||
FinishTime: payload.Body.FinishTime,
|
||||
EventTime: payload.EventTime,
|
||||
}, nil
|
||||
case EventOAApprovalTaskRedirected:
|
||||
return OAApprovalTaskRedirectedOutput{
|
||||
Type: base.Type,
|
||||
EventID: base.EventID,
|
||||
Timestamp: base.Timestamp,
|
||||
SubscribeID: base.SubscribeID,
|
||||
ProcessInstanceID: payload.Body.ProcessInstanceID,
|
||||
ProcessCode: payload.Body.ProcessCode,
|
||||
TaskID: payload.Body.TaskID,
|
||||
Title: payload.Body.Title,
|
||||
Status: payload.Body.Status,
|
||||
Result: payload.Body.Result,
|
||||
CreateTime: payload.Body.CreateTime,
|
||||
FinishTime: payload.Body.FinishTime,
|
||||
EventTime: payload.EventTime,
|
||||
}, nil
|
||||
case EventOAApprovalInstanceStarted:
|
||||
return OAApprovalInstanceStartedOutput{
|
||||
Type: base.Type,
|
||||
EventID: base.EventID,
|
||||
Timestamp: base.Timestamp,
|
||||
SubscribeID: base.SubscribeID,
|
||||
ProcessInstanceID: payload.Body.ProcessInstanceID,
|
||||
ProcessCode: payload.Body.ProcessCode,
|
||||
Title: payload.Body.Title,
|
||||
Status: payload.Body.Status,
|
||||
CreateTime: payload.Body.CreateTime,
|
||||
EventTime: payload.EventTime,
|
||||
}, nil
|
||||
case EventOAApprovalInstanceTerminated:
|
||||
return OAApprovalInstanceTerminatedOutput{
|
||||
Type: base.Type,
|
||||
EventID: base.EventID,
|
||||
Timestamp: base.Timestamp,
|
||||
SubscribeID: base.SubscribeID,
|
||||
ProcessInstanceID: payload.Body.ProcessInstanceID,
|
||||
ProcessCode: payload.Body.ProcessCode,
|
||||
Title: payload.Body.Title,
|
||||
Status: payload.Body.Status,
|
||||
CreateTime: payload.Body.CreateTime,
|
||||
FinishTime: payload.Body.FinishTime,
|
||||
EventTime: payload.EventTime,
|
||||
}, nil
|
||||
case EventOAApprovalInstanceFinished:
|
||||
return OAApprovalInstanceFinishedOutput{
|
||||
Type: base.Type,
|
||||
EventID: base.EventID,
|
||||
Timestamp: base.Timestamp,
|
||||
SubscribeID: base.SubscribeID,
|
||||
ProcessInstanceID: payload.Body.ProcessInstanceID,
|
||||
ProcessCode: payload.Body.ProcessCode,
|
||||
Title: payload.Body.Title,
|
||||
Status: payload.Body.Status,
|
||||
Result: payload.Body.Result,
|
||||
CreateTime: payload.Body.CreateTime,
|
||||
FinishTime: payload.Body.FinishTime,
|
||||
EventTime: payload.EventTime,
|
||||
}, nil
|
||||
default:
|
||||
return ev, fmt.Errorf("unsupported personal OA event type %q", base.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeRequiredPayload(raw json.RawMessage, target any) error {
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
||||
@@ -651,6 +863,18 @@ func outputTypeForEvent(eventKey string) reflect.Type {
|
||||
return reflect.TypeOf(GroupMemberEventOutput{})
|
||||
case isGroupLifecycleEvent(eventKey):
|
||||
return reflect.TypeOf(GroupLifecycleEventOutput{})
|
||||
case eventKey == EventOAApprovalTaskCreated:
|
||||
return reflect.TypeOf(OAApprovalTaskCreatedOutput{})
|
||||
case eventKey == EventOAApprovalTaskFinished:
|
||||
return reflect.TypeOf(OAApprovalTaskFinishedOutput{})
|
||||
case eventKey == EventOAApprovalTaskRedirected:
|
||||
return reflect.TypeOf(OAApprovalTaskRedirectedOutput{})
|
||||
case eventKey == EventOAApprovalInstanceStarted:
|
||||
return reflect.TypeOf(OAApprovalInstanceStartedOutput{})
|
||||
case eventKey == EventOAApprovalInstanceTerminated:
|
||||
return reflect.TypeOf(OAApprovalInstanceTerminatedOutput{})
|
||||
case eventKey == EventOAApprovalInstanceFinished:
|
||||
return reflect.TypeOf(OAApprovalInstanceFinishedOutput{})
|
||||
default:
|
||||
return reflect.TypeOf(baseEventOutput{})
|
||||
}
|
||||
@@ -677,6 +901,21 @@ func isGroupLifecycleEvent(eventKey string) bool {
|
||||
eventKey == EventGroupDisbanded
|
||||
}
|
||||
|
||||
func isOAEvent(eventKey string) bool {
|
||||
return eventKey == EventOAApprovalTaskCreated ||
|
||||
eventKey == EventOAApprovalTaskFinished ||
|
||||
eventKey == EventOAApprovalTaskRedirected ||
|
||||
eventKey == EventOAApprovalInstanceStarted ||
|
||||
eventKey == EventOAApprovalInstanceTerminated ||
|
||||
eventKey == EventOAApprovalInstanceFinished
|
||||
}
|
||||
|
||||
func isOAApprovalTaskEvent(eventKey string) bool {
|
||||
return eventKey == EventOAApprovalTaskCreated ||
|
||||
eventKey == EventOAApprovalTaskFinished ||
|
||||
eventKey == EventOAApprovalTaskRedirected
|
||||
}
|
||||
|
||||
func schemaType(t reflect.Type) string {
|
||||
switch t.Kind() {
|
||||
case reflect.String:
|
||||
|
||||
@@ -150,6 +150,59 @@ func personalGroupMemberData(eventKey string) string {
|
||||
}`, eventKey)
|
||||
}
|
||||
|
||||
func personalOAData(eventKey string) string {
|
||||
body := map[string]any{
|
||||
"processInstanceId": "process-instance-1",
|
||||
"createTime": int64(1785229100000),
|
||||
"processCode": "PROC-TEST-1",
|
||||
"title": "测试审批",
|
||||
}
|
||||
switch eventKey {
|
||||
case EventOAApprovalTaskCreated:
|
||||
body["taskId"] = "approval-task-1"
|
||||
body["status"] = "RUNNING"
|
||||
case EventOAApprovalTaskFinished:
|
||||
body["taskId"] = "approval-task-1"
|
||||
body["status"] = "FINISHED"
|
||||
body["result"] = "agree"
|
||||
body["finishTime"] = int64(1785229199000)
|
||||
case EventOAApprovalTaskRedirected:
|
||||
body["taskId"] = "approval-task-1"
|
||||
body["status"] = "FINISHED"
|
||||
body["result"] = "redirect"
|
||||
body["finishTime"] = int64(1785229199000)
|
||||
case EventOAApprovalInstanceStarted:
|
||||
body["status"] = "RUNNING"
|
||||
case EventOAApprovalInstanceTerminated:
|
||||
body["status"] = "TERMINATED"
|
||||
body["finishTime"] = int64(1785229199000)
|
||||
case EventOAApprovalInstanceFinished:
|
||||
body["status"] = "FINISHED"
|
||||
body["result"] = "agree"
|
||||
body["finishTime"] = int64(1785229199000)
|
||||
}
|
||||
data := map[string]any{
|
||||
"eventId": "oa-event",
|
||||
"eventKey": eventKey,
|
||||
"occurredAtMs": int64(1785229200123),
|
||||
"subId": "oa-data-sub",
|
||||
"payload": map[string]any{
|
||||
"uid": 100001,
|
||||
"CORPID": "internal-corp",
|
||||
"clientId": "internal-client",
|
||||
"filterSubId": "internal-filter",
|
||||
"bizid": "internal-biz",
|
||||
"orgId": 100002,
|
||||
"sourceId": "open",
|
||||
"body": body,
|
||||
"event_time": int64(1785229199000),
|
||||
"futureField": map[string]any{"nested": true},
|
||||
},
|
||||
}
|
||||
encoded, _ := json.Marshal(data)
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectOutputMessageEvents(t *testing.T) {
|
||||
for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser, EventAllSingleChat, EventAllGroupChat} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
@@ -396,6 +449,170 @@ func TestCrossPlatformCoverageProjectOutputGroupLifecycleEvents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectOutputOAEvents(t *testing.T) {
|
||||
tests := []struct {
|
||||
eventKey string
|
||||
want any
|
||||
}{
|
||||
{
|
||||
eventKey: EventOAApprovalTaskCreated,
|
||||
want: OAApprovalTaskCreatedOutput{
|
||||
Type: EventOAApprovalTaskCreated,
|
||||
EventID: "oa-event",
|
||||
Timestamp: 1785229200123,
|
||||
SubscribeID: "outer-sub",
|
||||
ProcessInstanceID: "process-instance-1",
|
||||
ProcessCode: "PROC-TEST-1",
|
||||
TaskID: "approval-task-1",
|
||||
Title: "测试审批",
|
||||
Status: "RUNNING",
|
||||
CreateTime: 1785229100000,
|
||||
EventTime: 1785229199000,
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalTaskFinished,
|
||||
want: OAApprovalTaskFinishedOutput{
|
||||
Type: EventOAApprovalTaskFinished,
|
||||
EventID: "oa-event",
|
||||
Timestamp: 1785229200123,
|
||||
SubscribeID: "outer-sub",
|
||||
ProcessInstanceID: "process-instance-1",
|
||||
ProcessCode: "PROC-TEST-1",
|
||||
TaskID: "approval-task-1",
|
||||
Title: "测试审批",
|
||||
Status: "FINISHED",
|
||||
Result: "agree",
|
||||
CreateTime: 1785229100000,
|
||||
FinishTime: 1785229199000,
|
||||
EventTime: 1785229199000,
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalTaskRedirected,
|
||||
want: OAApprovalTaskRedirectedOutput{
|
||||
Type: EventOAApprovalTaskRedirected,
|
||||
EventID: "oa-event",
|
||||
Timestamp: 1785229200123,
|
||||
SubscribeID: "outer-sub",
|
||||
ProcessInstanceID: "process-instance-1",
|
||||
ProcessCode: "PROC-TEST-1",
|
||||
TaskID: "approval-task-1",
|
||||
Title: "测试审批",
|
||||
Status: "FINISHED",
|
||||
Result: "redirect",
|
||||
CreateTime: 1785229100000,
|
||||
FinishTime: 1785229199000,
|
||||
EventTime: 1785229199000,
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceStarted,
|
||||
want: OAApprovalInstanceStartedOutput{
|
||||
Type: EventOAApprovalInstanceStarted,
|
||||
EventID: "oa-event",
|
||||
Timestamp: 1785229200123,
|
||||
SubscribeID: "outer-sub",
|
||||
ProcessInstanceID: "process-instance-1",
|
||||
ProcessCode: "PROC-TEST-1",
|
||||
Title: "测试审批",
|
||||
Status: "RUNNING",
|
||||
CreateTime: 1785229100000,
|
||||
EventTime: 1785229199000,
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceTerminated,
|
||||
want: OAApprovalInstanceTerminatedOutput{
|
||||
Type: EventOAApprovalInstanceTerminated,
|
||||
EventID: "oa-event",
|
||||
Timestamp: 1785229200123,
|
||||
SubscribeID: "outer-sub",
|
||||
ProcessInstanceID: "process-instance-1",
|
||||
ProcessCode: "PROC-TEST-1",
|
||||
Title: "测试审批",
|
||||
Status: "TERMINATED",
|
||||
CreateTime: 1785229100000,
|
||||
FinishTime: 1785229199000,
|
||||
EventTime: 1785229199000,
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceFinished,
|
||||
want: OAApprovalInstanceFinishedOutput{
|
||||
Type: EventOAApprovalInstanceFinished,
|
||||
EventID: "oa-event",
|
||||
Timestamp: 1785229200123,
|
||||
SubscribeID: "outer-sub",
|
||||
ProcessInstanceID: "process-instance-1",
|
||||
ProcessCode: "PROC-TEST-1",
|
||||
Title: "测试审批",
|
||||
Status: "FINISHED",
|
||||
Result: "agree",
|
||||
CreateTime: 1785229100000,
|
||||
FinishTime: 1785229199000,
|
||||
EventTime: 1785229199000,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.eventKey, func(t *testing.T) {
|
||||
projected, err := ProjectOutput(transport.Event{
|
||||
EventID: "outer-event",
|
||||
EventBornTime: 11,
|
||||
EventType: tt.eventKey,
|
||||
SubscribeID: "outer-sub",
|
||||
Data: personalOAData(tt.eventKey),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectOutput() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(projected, tt.want) {
|
||||
t.Fatalf("ProjectOutput() = %#v, want %#v", projected, tt.want)
|
||||
}
|
||||
assertNoInternalActionFields(t, projected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectOutputRejectsUnsupportedOAType(t *testing.T) {
|
||||
ev := transport.Event{EventID: "outer-event", EventType: "user_oa_approval_unknown"}
|
||||
projected, err := projectOAApprovalEvent(
|
||||
ev,
|
||||
baseEventOutput{Type: ev.EventType, EventID: ev.EventID},
|
||||
json.RawMessage(`{"body":{"processInstanceId":"process-instance-1"},"event_time":1}`),
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), `unsupported personal OA event type "user_oa_approval_unknown"`) {
|
||||
t.Fatalf("projectOAApprovalEvent() error = %v", err)
|
||||
}
|
||||
if got, ok := projected.(transport.Event); !ok || !reflect.DeepEqual(got, ev) {
|
||||
t.Fatalf("projectOAApprovalEvent() fallback = %#v, want %#v", projected, ev)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectOutputOADecodesDoublyWrappedJSONString(t *testing.T) {
|
||||
once, err := json.Marshal(personalOAData(EventOAApprovalTaskCreated))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
twice, err := json.Marshal(string(once))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
projected, err := ProjectOutput(transport.Event{Data: string(twice)})
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectOutput() error = %v", err)
|
||||
}
|
||||
got, ok := projected.(OAApprovalTaskCreatedOutput)
|
||||
if !ok {
|
||||
t.Fatalf("ProjectOutput() type = %T, want OAApprovalTaskCreatedOutput", projected)
|
||||
}
|
||||
if got.Type != EventOAApprovalTaskCreated || got.EventID != "oa-event" || got.SubscribeID != "oa-data-sub" {
|
||||
t.Fatalf("ProjectOutput() = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectOutputGroupMemberEvents(t *testing.T) {
|
||||
for _, eventKey := range []string{EventGroupMemberAdded, EventGroupMemberExited} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
@@ -549,6 +766,93 @@ func TestCrossPlatformCoverageProjectOutputRejectsInvalidGroupLifecyclePayloads(
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectOutputRejectsInvalidOAPayloads(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload string
|
||||
}{
|
||||
{name: "missing"},
|
||||
{name: "null", payload: `,"payload":null`},
|
||||
{name: "empty object", payload: `,"payload":{}`},
|
||||
{name: "array", payload: `,"payload":[]`},
|
||||
{name: "string", payload: `,"payload":"invalid"`},
|
||||
{name: "missing body", payload: `,"payload":{"event_time":1}`},
|
||||
{name: "null body", payload: `,"payload":{"body":null,"event_time":1}`},
|
||||
{name: "empty body", payload: `,"payload":{"body":{},"event_time":1}`},
|
||||
}
|
||||
for _, eventKey := range []string{
|
||||
EventOAApprovalTaskCreated,
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
} {
|
||||
for _, tt := range tests {
|
||||
t.Run(eventKey+"/"+tt.name, func(t *testing.T) {
|
||||
ev := transport.Event{
|
||||
EventID: "outer-event",
|
||||
EventType: eventKey,
|
||||
Data: fmt.Sprintf(`{"eventKey":%q%s}`, eventKey, tt.payload),
|
||||
}
|
||||
projected, err := ProjectOutput(ev)
|
||||
if err == nil {
|
||||
t.Fatal("ProjectOutput() error = nil, want OA payload validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "decode personal OA payload") {
|
||||
t.Fatalf("ProjectOutput() error = %v, want OA payload context", err)
|
||||
}
|
||||
got, ok := projected.(transport.Event)
|
||||
if !ok || !reflect.DeepEqual(got, ev) {
|
||||
t.Fatalf("ProjectOutput() fallback = %#v, want %#v", projected, ev)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectOutputRejectsOAWithoutStableIDs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
eventKey string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing process instance",
|
||||
eventKey: EventOAApprovalInstanceStarted,
|
||||
body: `{"status":"RUNNING"}`,
|
||||
want: "processInstanceId is required",
|
||||
},
|
||||
{
|
||||
name: "missing task",
|
||||
eventKey: EventOAApprovalTaskCreated,
|
||||
body: `{"processInstanceId":"process-instance-1","status":"RUNNING"}`,
|
||||
want: "taskId is required",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ev := transport.Event{
|
||||
EventID: "outer-event",
|
||||
EventType: tt.eventKey,
|
||||
Data: fmt.Sprintf(
|
||||
`{"eventKey":%q,"payload":{"body":%s,"event_time":1}}`,
|
||||
tt.eventKey,
|
||||
tt.body,
|
||||
),
|
||||
}
|
||||
projected, err := ProjectOutput(ev)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("ProjectOutput() error = %v, want %q", err, tt.want)
|
||||
}
|
||||
if got, ok := projected.(transport.Event); !ok || !reflect.DeepEqual(got, ev) {
|
||||
t.Fatalf("ProjectOutput() fallback = %#v, want %#v", projected, ev)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectOutputDecodesWrappedJSONString(t *testing.T) {
|
||||
wrapped, err := json.Marshal(personalMessageData(EventSingleChat))
|
||||
if err != nil {
|
||||
|
||||
@@ -23,22 +23,28 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
EventMention = "user_im_message_receive_at"
|
||||
EventSingleChat = "user_im_message_receive_o2o"
|
||||
EventInChat = "user_im_message_receive_group"
|
||||
EventFromUser = "user_im_message_receive_user"
|
||||
EventAllSingleChat = "user_im_message_receive_o2o_all"
|
||||
EventAllGroupChat = "user_im_message_receive_group_all"
|
||||
EventReadO2O = "user_im_message_read_o2o"
|
||||
EventReadGroup = "user_im_message_read_group"
|
||||
EventRecallO2O = "user_im_message_recall_o2o"
|
||||
EventRecallGroup = "user_im_message_recall_group"
|
||||
EventReactionO2O = "user_im_message_reaction_o2o"
|
||||
EventReactionGroup = "user_im_message_reaction_group"
|
||||
EventGroupUpdated = "user_im_group_updated"
|
||||
EventGroupMemberAdded = "user_im_group_member_added"
|
||||
EventGroupMemberExited = "user_im_group_member_exited"
|
||||
EventGroupDisbanded = "user_im_group_disbanded"
|
||||
EventMention = "user_im_message_receive_at"
|
||||
EventSingleChat = "user_im_message_receive_o2o"
|
||||
EventInChat = "user_im_message_receive_group"
|
||||
EventFromUser = "user_im_message_receive_user"
|
||||
EventAllSingleChat = "user_im_message_receive_o2o_all"
|
||||
EventAllGroupChat = "user_im_message_receive_group_all"
|
||||
EventReadO2O = "user_im_message_read_o2o"
|
||||
EventReadGroup = "user_im_message_read_group"
|
||||
EventRecallO2O = "user_im_message_recall_o2o"
|
||||
EventRecallGroup = "user_im_message_recall_group"
|
||||
EventReactionO2O = "user_im_message_reaction_o2o"
|
||||
EventReactionGroup = "user_im_message_reaction_group"
|
||||
EventGroupUpdated = "user_im_group_updated"
|
||||
EventGroupMemberAdded = "user_im_group_member_added"
|
||||
EventGroupMemberExited = "user_im_group_member_exited"
|
||||
EventGroupDisbanded = "user_im_group_disbanded"
|
||||
EventOAApprovalTaskCreated = "user_oa_approval_task_created"
|
||||
EventOAApprovalTaskFinished = "user_oa_approval_task_finished"
|
||||
EventOAApprovalTaskRedirected = "user_oa_approval_task_redirected"
|
||||
EventOAApprovalInstanceStarted = "user_oa_approval_instance_started"
|
||||
EventOAApprovalInstanceTerminated = "user_oa_approval_instance_terminated"
|
||||
EventOAApprovalInstanceFinished = "user_oa_approval_instance_finished"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -273,6 +279,72 @@ var definitions = []Definition{
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventOAApprovalTaskCreated,
|
||||
DisplayName: "审批任务创建",
|
||||
Description: "审批任务创建,发送给审批人",
|
||||
Category: "oa",
|
||||
RuleType: "all",
|
||||
Status: StatusEnabled,
|
||||
RequiredParams: nil,
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventOAApprovalTaskFinished,
|
||||
DisplayName: "审批任务完成",
|
||||
Description: "审批任务已完成",
|
||||
Category: "oa",
|
||||
RuleType: "all",
|
||||
Status: StatusEnabled,
|
||||
RequiredParams: nil,
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventOAApprovalTaskRedirected,
|
||||
DisplayName: "审批人任务转交",
|
||||
Description: "审批任务已转交",
|
||||
Category: "oa",
|
||||
RuleType: "all",
|
||||
Status: StatusEnabled,
|
||||
RequiredParams: nil,
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventOAApprovalInstanceStarted,
|
||||
DisplayName: "发起审批单",
|
||||
Description: "审批实例已发起",
|
||||
Category: "oa",
|
||||
RuleType: "all",
|
||||
Status: StatusEnabled,
|
||||
RequiredParams: nil,
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventOAApprovalInstanceTerminated,
|
||||
DisplayName: "审批单终止",
|
||||
Description: "审批实例已终止",
|
||||
Category: "oa",
|
||||
RuleType: "all",
|
||||
Status: StatusEnabled,
|
||||
RequiredParams: nil,
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventOAApprovalInstanceFinished,
|
||||
DisplayName: "审批单完成",
|
||||
Description: "审批实例完成,发送给审批单发起人",
|
||||
Category: "oa",
|
||||
RuleType: "all",
|
||||
Status: StatusEnabled,
|
||||
RequiredParams: nil,
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
}
|
||||
|
||||
func targetUIDConstraints() *ParameterConstraints {
|
||||
|
||||
@@ -46,12 +46,47 @@ func TestCatalogEnabledEvents(t *testing.T) {
|
||||
EventGroupMemberAdded,
|
||||
EventGroupMemberExited,
|
||||
EventGroupDisbanded,
|
||||
EventOAApprovalTaskCreated,
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
}
|
||||
if !reflect.DeepEqual(keys, want) {
|
||||
t.Fatalf("keys = %#v, want %#v", keys, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAEventCatalogDefinitions(t *testing.T) {
|
||||
items := Catalog("oa", true, false)
|
||||
wantKeys := []string{
|
||||
EventOAApprovalTaskCreated,
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
}
|
||||
if len(items) != len(wantKeys) {
|
||||
t.Fatalf("Catalog(oa) = %#v, want %d events", items, len(wantKeys))
|
||||
}
|
||||
for i, item := range items {
|
||||
if item.EventKey != wantKeys[i] {
|
||||
t.Fatalf("Catalog(oa)[%d].event_key = %q, want %q", i, item.EventKey, wantKeys[i])
|
||||
}
|
||||
if item.Category != "oa" || item.RuleType != "all" || item.Status != StatusEnabled || !item.Public {
|
||||
t.Fatalf("Catalog(oa)[%d] = %#v, want public enabled oa/all event", i, item)
|
||||
}
|
||||
if len(item.RequiredParams) != 0 || item.Constraints != nil {
|
||||
t.Fatalf("Catalog(oa)[%d] parameters = %#v/%#v, want none", i, item.RequiredParams, item.Constraints)
|
||||
}
|
||||
if item.Auth["identity"] != "user" {
|
||||
t.Fatalf("Catalog(oa)[%d].auth = %#v, want user identity", i, item.Auth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventFromUserIsPublic(t *testing.T) {
|
||||
if _, ok := Lookup(EventFromUser); !ok {
|
||||
t.Fatalf("Lookup(%q) failed", EventFromUser)
|
||||
@@ -119,6 +154,12 @@ func TestSchemaDocumentsDefaultToTransportEnvelope(t *testing.T) {
|
||||
EventGroupMemberAdded,
|
||||
EventGroupMemberExited,
|
||||
EventGroupDisbanded,
|
||||
EventOAApprovalTaskCreated,
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
def, ok := Lookup(eventKey)
|
||||
@@ -433,6 +474,96 @@ func TestGroupLifecycleSchemaDocumentsUseConservativePayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAEventSchemaDocumentsMatchOutputDTO(t *testing.T) {
|
||||
tests := []struct {
|
||||
eventKey string
|
||||
properties []string
|
||||
}{
|
||||
{
|
||||
eventKey: EventOAApprovalTaskCreated,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "task_id", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalTaskFinished,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "task_id", "title", "status", "result", "create_time",
|
||||
"finish_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalTaskRedirected,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "task_id", "title", "status", "result", "create_time",
|
||||
"finish_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceStarted,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceTerminated,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "title", "status", "create_time", "finish_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceFinished,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "title", "status", "result", "create_time", "finish_time",
|
||||
"event_time",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.eventKey, func(t *testing.T) {
|
||||
def, ok := Lookup(tt.eventKey)
|
||||
if !ok {
|
||||
t.Fatalf("Lookup(%q) failed", tt.eventKey)
|
||||
}
|
||||
doc := BuildSchemaDocumentForMode(def, true)
|
||||
if doc.JQRootPath != "." {
|
||||
t.Fatalf("jq_root_path = %q, want .", doc.JQRootPath)
|
||||
}
|
||||
props, ok := doc.Schema["properties"].(map[string]any)
|
||||
if !ok || len(props) != len(tt.properties) {
|
||||
t.Fatalf("schema.properties = %#v, want exactly %d fields", doc.Schema["properties"], len(tt.properties))
|
||||
}
|
||||
for _, name := range tt.properties {
|
||||
if _, ok := props[name].(map[string]any); !ok {
|
||||
t.Fatalf("schema.properties.%s = %#v, want object", name, props[name])
|
||||
}
|
||||
}
|
||||
eventType := props["type"].(map[string]any)
|
||||
if !reflect.DeepEqual(eventType["enum"], []string{tt.eventKey}) {
|
||||
t.Fatalf("schema.properties.type.enum = %#v, want %q", eventType["enum"], tt.eventKey)
|
||||
}
|
||||
if _, ok := props["payload"]; ok {
|
||||
t.Fatalf("schema.properties exposed generic payload: %#v", props)
|
||||
}
|
||||
for _, name := range []string{"timestamp", "create_time", "finish_time", "event_time"} {
|
||||
property, exists := props[name].(map[string]any)
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if property["type"] != "integer" || property["format"] != "timestamp_ms" {
|
||||
t.Fatalf("schema.properties.%s = %#v, want timestamp_ms integer", name, property)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMemberSchemaDocumentsMatchOutputDTO(t *testing.T) {
|
||||
wantProperties := []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "conversation_id",
|
||||
@@ -495,7 +626,16 @@ func TestBuildRuleParamMention(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildRuleParamAllEvents(t *testing.T) {
|
||||
for _, eventKey := range []string{EventAllSingleChat, EventAllGroupChat} {
|
||||
for _, eventKey := range []string{
|
||||
EventAllSingleChat,
|
||||
EventAllGroupChat,
|
||||
EventOAApprovalTaskCreated,
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
rule, param, err := BuildRuleParam(eventKey, RuleOptions{})
|
||||
if err != nil {
|
||||
@@ -701,6 +841,12 @@ func TestSupportsMessageFilter(t *testing.T) {
|
||||
EventReadO2O,
|
||||
EventReactionGroup,
|
||||
EventGroupUpdated,
|
||||
EventOAApprovalTaskCreated,
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
"unknown_event",
|
||||
} {
|
||||
if SupportsMessageFilter(eventKey) {
|
||||
|
||||
+357
-96
@@ -5,14 +5,18 @@ 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"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
@@ -122,114 +126,260 @@ func resolveWorkflowDSL(cmd *cobra.Command) (map[string]any, error) {
|
||||
return dsl, nil
|
||||
}
|
||||
|
||||
func validateWorkflowRunFlags(cmd *cobra.Command, _ []string) error {
|
||||
tableID, _ := cmd.Flags().GetString("table-id")
|
||||
tableID = strings.TrimSpace(tableID)
|
||||
recordIDs, _ := cmd.Flags().GetStringSlice("record-ids")
|
||||
cleaned := make([]string, 0, len(recordIDs))
|
||||
seen := make(map[string]struct{}, len(recordIDs))
|
||||
for _, recordID := range recordIDs {
|
||||
recordID = strings.TrimSpace(recordID)
|
||||
if recordID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[recordID]; ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--record-ids 不能包含重复值 %q", recordID))
|
||||
}
|
||||
seen[recordID] = struct{}{}
|
||||
cleaned = append(cleaned, recordID)
|
||||
}
|
||||
if cmd.Flags().Changed("table-id") && tableID == "" {
|
||||
return apperrors.NewValidation("--table-id 不能为空")
|
||||
}
|
||||
if cmd.Flags().Changed("record-ids") && len(cleaned) == 0 {
|
||||
return apperrors.NewValidation("--record-ids 必须包含 1 到 5 个非空记录 ID")
|
||||
}
|
||||
if len(cleaned) > 5 {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--record-ids 最多支持 5 个记录 ID,got %d", len(cleaned)))
|
||||
}
|
||||
if (tableID != "") != (len(cleaned) > 0) {
|
||||
return apperrors.NewValidation("--table-id 与 --record-ids 必须同时提供;定时触发工作流则两者都不传")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWorkflowHistoryFlags(cmd *cobra.Command, _ []string) error {
|
||||
if cmd.Flags().Changed("page") {
|
||||
page, _ := cmd.Flags().GetInt("page")
|
||||
if page < 0 {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--page 必须 >= 0,got %d", page))
|
||||
}
|
||||
}
|
||||
if cmd.Flags().Changed("size") {
|
||||
size, _ := cmd.Flags().GetInt("size")
|
||||
if size < 1 || size > 100 {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--size 必须在 [1, 100] 范围内,got %d", size))
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"after-time", "before-time"} {
|
||||
if !cmd.Flags().Changed(name) {
|
||||
continue
|
||||
}
|
||||
value, _ := cmd.Flags().GetInt(name)
|
||||
if value < 0 {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--%s 必须是 >= 0 的 Unix 毫秒时间戳,got %d", name, value))
|
||||
}
|
||||
}
|
||||
if cmd.Flags().Changed("after-time") && cmd.Flags().Changed("before-time") {
|
||||
afterTime, _ := cmd.Flags().GetInt("after-time")
|
||||
beforeTime, _ := cmd.Flags().GetInt("before-time")
|
||||
if afterTime >= beforeTime {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--after-time 必须小于 --before-time,got %d >= %d", afterTime, beforeTime))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 格式校验 ──────────────────────────────────────────────────────
|
||||
@@ -953,7 +1103,7 @@ func newAitableCommand() *cobra.Command {
|
||||
dws aitable form [list|delete|update] 表单管理
|
||||
dws aitable form field [list|update|hide] 表单字段管理
|
||||
dws aitable form share [get|update|notify] 表单分享管理
|
||||
dws aitable workflow [edit-example|create|update|enable|disable|get|list] 自动化工作流管理
|
||||
dws aitable workflow [edit-example|create|update|enable|disable|run|history|get|list] 自动化工作流管理
|
||||
dws aitable dashboard [get|create|update|delete|config-example] 仪表盘管理
|
||||
dws aitable chart [get|create|update|delete|widgets-example] 图表管理
|
||||
dws aitable export data 数据导出
|
||||
@@ -4793,7 +4943,7 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
|
||||
|
||||
workflowCmd := &cobra.Command{
|
||||
Use: "workflow",
|
||||
Short: "自动化工作流管理(创建 / 更新 / 启停 / 查看 / 列表)",
|
||||
Short: "自动化工作流管理(创建 / 更新 / 启停 / 执行 / 历史 / 查询)",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
@@ -5138,6 +5288,116 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
|
||||
},
|
||||
})
|
||||
|
||||
workflowRunCmd := NewLeafCommand(LeafSpec{
|
||||
Use: "run",
|
||||
Short: "执行指定自动化工作流",
|
||||
Long: `立即执行指定 Base 中的自动化工作流。此命令会启动真实的异步执行,并可能产生该工作流配置的消息发送、记录写入等副作用,因此执行前需要确认;CLI 不自动重试。
|
||||
|
||||
记录类触发器必须同时提供 --table-id 与 --record-ids;--table-id 必须与触发器绑定的数据表一致,--record-ids 接受 1 到 5 个不重复记录 ID。定时触发器不传这两个参数。
|
||||
返回每条记录的提交状态;提交成功项包含 executionId,可用 workflow history 返回项的 instanceId 匹配执行记录。`,
|
||||
Example: ` dws aitable workflow run --base-id BASE_ID --workflow-id WORKFLOW_ID --table-id TABLE_ID --record-ids RECORD_ID_1,RECORD_ID_2
|
||||
dws aitable workflow run --base-id BASE_ID --workflow-id WORKFLOW_ID`,
|
||||
Tool: "run_workflow",
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "user_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Validate: validateWorkflowRunFlags,
|
||||
Flags: []LeafFlag{
|
||||
{Name: "base-id", Usage: "目标 Base ID (必填)", Bind: "baseId", Trim: true, Required: true, Aliases: []string{"base"}},
|
||||
{Name: "workflow-id", Usage: "目标工作流 ID (必填)", Bind: "workflowId", Trim: true, Required: true},
|
||||
{Name: "table-id", Usage: "记录类触发器绑定的 Table ID;定时触发器不传", Bind: "tableId", Trim: true, OmitEmpty: true, RequiredWhen: "record-ids is provided or the workflow uses a record-based trigger"},
|
||||
{Name: "record-ids", Usage: "触发工作流的记录 ID,逗号分隔;记录类触发器必填,1 到 5 个且不可重复", Kind: LeafStringSlice, Bind: "recordIds", RequiredWhen: "table-id is provided or the workflow uses a record-based trigger"},
|
||||
},
|
||||
Constraints: []LeafConstraint{{
|
||||
Kind: corecmd.Custom,
|
||||
Flags: []string{"table-id", "record-ids"},
|
||||
Description: "记录类触发器必须同时提供 --table-id 与 --record-ids;定时触发器两者都不传",
|
||||
}},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "workflow_run",
|
||||
CanonicalPath: "aitable.workflow_run",
|
||||
CLIPath: "aitable workflow run",
|
||||
PrimaryCLIPath: "aitable workflow run",
|
||||
},
|
||||
Description: "立即执行 AI 表格自动化工作流,并返回异步执行提交结果。",
|
||||
Interface: aitableMCPInterface("run_workflow"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "立即执行已知 AI 表格自动化工作流,并获取 executionId。",
|
||||
UseWhen: []string{"用户明确要求立即执行已知工作流,已确认真实 base-id、workflow-id、触发类型及可能产生的业务副作用;记录类触发器还需确认绑定的 table-id 和 1 到 5 个真实 record-id"},
|
||||
AvoidWhen: []string{"仅开启后续自动触发用 workflow enable;查询工作流定义用 workflow get;查询既有执行结果用 workflow history;返回 executionId 后应以 history 的 instanceId 核对,不要在结果不确定时直接重复执行"},
|
||||
Examples: []string{
|
||||
"dws aitable workflow run --base-id <BASE_ID> --workflow-id <WORKFLOW_ID> --table-id <TABLE_ID> --record-ids <RECORD_ID>",
|
||||
"dws aitable workflow run --base-id <BASE_ID> --workflow-id <WORKFLOW_ID>",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true), InterfaceType: "string"},
|
||||
{Name: "workflow-id", Property: "workflowId", Required: boolPtr(true), InterfaceType: "string"},
|
||||
{Name: "table-id", Property: "tableId", InterfaceType: "string", RequiredWhen: "record-ids is provided or the workflow uses a record-based trigger"},
|
||||
{Name: "record-ids", Property: "recordIds", InterfaceType: "array", RequiredWhen: "table-id is provided or the workflow uses a record-based trigger"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
workflowHistoryCmd := NewLeafCommand(LeafSpec{
|
||||
Use: "history",
|
||||
Short: "查询工作流执行历史",
|
||||
Long: `分页查询指定 AI 表格工作流的执行历史。
|
||||
可按状态和 Unix 毫秒时间范围筛选;同时提供 --after-time 与 --before-time 时,前者必须小于后者。--page 从 0 开始,--size 默认 20、最大 100。
|
||||
返回 totalCount 与 list;run 返回的 executionId 可与历史项 instanceId 匹配。`,
|
||||
Example: ` dws aitable workflow history --base-id BASE_ID --workflow-id WORKFLOW_ID
|
||||
dws aitable workflow history --base-id BASE_ID --workflow-id WORKFLOW_ID --status failed --after-time 1786000000000 --before-time 1787000000000 --page 0 --size 50`,
|
||||
Tool: "get_flow_record_list",
|
||||
Safety: aitableSafetyRead(),
|
||||
Validate: validateWorkflowHistoryFlags,
|
||||
Flags: []LeafFlag{
|
||||
{Name: "base-id", Usage: "目标 Base ID (必填)", Bind: "baseId", Trim: true, Required: true, Aliases: []string{"base"}},
|
||||
{Name: "workflow-id", Usage: "目标工作流 ID (必填)", Bind: "flowId", Trim: true, Required: true},
|
||||
{Name: "status", Usage: "执行状态筛选;不传表示全部", Bind: "status", Trim: true, OmitEmpty: true, Enum: []string{"success", "failed", "running", "break", "untrigger"}},
|
||||
{Name: "after-time", Usage: "开始时间(Unix 毫秒)", Kind: LeafInt, Bind: "afterTime"},
|
||||
{Name: "before-time", Usage: "结束时间(Unix 毫秒)", Kind: LeafInt, Bind: "beforeTime"},
|
||||
{Name: "page", Usage: "页码,从 0 开始", Kind: LeafInt, Default: "0", Bind: "page"},
|
||||
{Name: "size", Usage: "每页条数 [1, 100]", Kind: LeafInt, Default: "20", Bind: "size"},
|
||||
},
|
||||
Constraints: []LeafConstraint{{
|
||||
Kind: corecmd.Custom,
|
||||
Flags: []string{"after-time", "before-time"},
|
||||
Description: "同时提供 --after-time 与 --before-time 时,--after-time 必须小于 --before-time",
|
||||
}},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "workflow_history",
|
||||
CanonicalPath: "aitable.workflow_history",
|
||||
CLIPath: "aitable workflow history",
|
||||
PrimaryCLIPath: "aitable workflow history",
|
||||
},
|
||||
Description: "分页查询 AI 表格自动化工作流执行历史。",
|
||||
Interface: aitableMCPInterface("get_flow_record_list"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按状态、时间和分页条件查询工作流执行历史。",
|
||||
UseWhen: []string{"需要核对工作流是否执行、执行结果或定位 run 返回的 executionId 时;executionId 与历史项 instanceId 相同,running 为非终态"},
|
||||
AvoidWhen: []string{"查询工作流定义用 workflow get;列出工作流用 workflow list;立即发起执行用 workflow run"},
|
||||
Examples: []string{
|
||||
"dws aitable workflow history --base-id <BASE_ID> --workflow-id <WORKFLOW_ID>",
|
||||
"dws aitable workflow history --base-id <BASE_ID> --workflow-id <WORKFLOW_ID> --status failed --page 0 --size 50",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true), InterfaceType: "string"},
|
||||
{Name: "workflow-id", Property: "flowId", Required: boolPtr(true), InterfaceType: "string"},
|
||||
{Name: "status", Property: "status", InterfaceType: "string", Enum: []string{"success", "failed", "running", "break", "untrigger"}},
|
||||
{Name: "after-time", Property: "afterTime", InterfaceType: "number"},
|
||||
{Name: "before-time", Property: "beforeTime", InterfaceType: "number"},
|
||||
{Name: "page", Property: "page", InterfaceType: "number"},
|
||||
{Name: "size", Property: "size", InterfaceType: "number"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// ── dashboard: 仪表盘管理 ────────────────────────────────────
|
||||
|
||||
dashboardCmd := &cobra.Command{Use: "dashboard", Short: "仪表盘管理", RunE: groupRunE}
|
||||
@@ -6966,8 +7226,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 获取 (必填)")
|
||||
@@ -7339,6 +7599,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
workflowCmd.AddCommand(
|
||||
workflowEditExampleCmd, workflowCreateCmd, workflowUpdateCmd,
|
||||
workflowEnableCmd, workflowDisableCmd,
|
||||
workflowRunCmd, workflowHistoryCmd,
|
||||
workflowGetCmd, workflowListCmd,
|
||||
)
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -40,26 +41,23 @@ func (*aitableWorkflowCaller) JQ() string { return "" }
|
||||
|
||||
func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*aitableWorkflowCaller, error) {
|
||||
t.Helper()
|
||||
previousDeps := deps
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
})
|
||||
testseam.Protect(t, &os.Args)
|
||||
|
||||
caller := &aitableWorkflowCaller{}
|
||||
InitDeps(caller)
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
os.Args = append([]string{"dws", "aitable", "workflow"}, args...)
|
||||
|
||||
cmd := newAitableCommand()
|
||||
cmd.PersistentFlags().String("format", "json", "output format")
|
||||
cmd.PersistentFlags().Bool("yes", false, "skip confirmation")
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(append([]string{"workflow"}, args...))
|
||||
if stdin != nil {
|
||||
cmd.SetIn(stdin)
|
||||
if stdin == nil {
|
||||
stdin = strings.NewReader("")
|
||||
}
|
||||
cmd.SetIn(stdin)
|
||||
return caller, cmd.Execute()
|
||||
}
|
||||
|
||||
@@ -197,3 +195,158 @@ func TestAitableWorkflowWriteRejectsInvalidInput(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableWorkflowRunMapsRecordTrigger(t *testing.T) {
|
||||
caller, err := runAitableWorkflowCommand(t, nil,
|
||||
"run",
|
||||
"--base-id", "base-run",
|
||||
"--workflow-id", "workflow-run",
|
||||
"--table-id", "table-run",
|
||||
"--record-ids", "record-1,record-2",
|
||||
"--yes",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("workflow run returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.productID != "aitable" || call.toolName != "run_workflow" {
|
||||
t.Fatalf("tool call = %s/%s, want aitable/run_workflow", call.productID, call.toolName)
|
||||
}
|
||||
wantArgs := map[string]any{
|
||||
"baseId": "base-run",
|
||||
"workflowId": "workflow-run",
|
||||
"tableId": "table-run",
|
||||
"recordIds": []string{"record-1", "record-2"},
|
||||
}
|
||||
if !reflect.DeepEqual(call.args, wantArgs) {
|
||||
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableWorkflowRunMapsScheduledTrigger(t *testing.T) {
|
||||
caller, err := runAitableWorkflowCommand(t, nil,
|
||||
"run", "--base", "base-scheduled", "--workflow-id", "workflow-scheduled", "--yes",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("scheduled workflow run returned error: %v", err)
|
||||
}
|
||||
wantArgs := map[string]any{
|
||||
"baseId": "base-scheduled",
|
||||
"workflowId": "workflow-scheduled",
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].args, wantArgs) {
|
||||
t.Fatalf("calls = %#v, want one scheduled invocation %#v", caller.calls, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableWorkflowRunRejectsUnsafeOrInvalidInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "confirmation", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow"}, want: "用户确认"},
|
||||
{name: "blank table", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", " ", "--yes"}, want: "--table-id 不能为空"},
|
||||
{name: "blank records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--record-ids", " , ", "--yes"}, want: "--record-ids 必须包含"},
|
||||
{name: "table without records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", "table", "--yes"}, want: "必须同时提供"},
|
||||
{name: "records without table", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--record-ids", "record", "--yes"}, want: "必须同时提供"},
|
||||
{name: "duplicate records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", "table", "--record-ids", "record,record", "--yes"}, want: "不能包含重复值"},
|
||||
{name: "too many records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", "table", "--record-ids", "r1,r2,r3,r4,r5,r6", "--yes"}, want: "最多支持 5 个"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller, err := runAitableWorkflowCommand(t, nil, tc.args...)
|
||||
if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tc.want)) {
|
||||
t.Fatalf("error = %v, want substring %q", err, tc.want)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid run reached MCP: %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableWorkflowHistoryMapsFilters(t *testing.T) {
|
||||
caller, err := runAitableWorkflowCommand(t, nil,
|
||||
"history",
|
||||
"--base-id", "base-history",
|
||||
"--workflow-id", "workflow-history",
|
||||
"--status", "failed",
|
||||
"--after-time", "1786000000000",
|
||||
"--before-time", "1787000000000",
|
||||
"--page", "2",
|
||||
"--size", "50",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("workflow history returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.productID != "aitable" || call.toolName != "get_flow_record_list" {
|
||||
t.Fatalf("tool call = %s/%s, want aitable/get_flow_record_list", call.productID, call.toolName)
|
||||
}
|
||||
wantArgs := map[string]any{
|
||||
"baseId": "base-history",
|
||||
"flowId": "workflow-history",
|
||||
"status": "failed",
|
||||
"afterTime": 1786000000000,
|
||||
"beforeTime": 1787000000000,
|
||||
"page": 2,
|
||||
"size": 50,
|
||||
}
|
||||
if !reflect.DeepEqual(call.args, wantArgs) {
|
||||
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableWorkflowHistoryMapsSingleTimeFilter(t *testing.T) {
|
||||
caller, err := runAitableWorkflowCommand(t, nil,
|
||||
"history",
|
||||
"--base-id", "base-history",
|
||||
"--workflow-id", "workflow-history",
|
||||
"--after-time", "1786000000000",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("workflow history returned error: %v", err)
|
||||
}
|
||||
wantArgs := map[string]any{
|
||||
"baseId": "base-history",
|
||||
"flowId": "workflow-history",
|
||||
"afterTime": 1786000000000,
|
||||
"size": 20,
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].args, wantArgs) {
|
||||
t.Fatalf("calls = %#v, want one history invocation %#v", caller.calls, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableWorkflowHistoryRejectsInvalidFilters(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "status", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--status", "unknown"}, want: "允许值"},
|
||||
{name: "negative page", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--page", "-1"}, want: "--page 必须 >= 0"},
|
||||
{name: "zero size", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--size", "0"}, want: "--size 必须在"},
|
||||
{name: "large size", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--size", "101"}, want: "--size 必须在"},
|
||||
{name: "negative after", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--after-time", "-1"}, want: "Unix 毫秒时间戳"},
|
||||
{name: "reversed range", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--after-time", "200", "--before-time", "100"}, want: "必须小于"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller, err := runAitableWorkflowCommand(t, nil, tc.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v, want substring %q", err, tc.want)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid history query reached MCP: %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+1767
-277
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
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// 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 (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
func runNativeCardUpdate(t *testing.T, caller *scriptedToolCaller, args ...string) error {
|
||||
t.Helper()
|
||||
installScriptedCaller(t, caller)
|
||||
root := newChatCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
if root.PersistentFlags().Lookup("dry-run") == nil {
|
||||
root.PersistentFlags().Bool("dry-run", false, "preview without executing")
|
||||
}
|
||||
if root.PersistentFlags().Lookup("yes") == nil {
|
||||
root.PersistentFlags().Bool("yes", false, "skip confirmation")
|
||||
}
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNativeMessageUpdateCardVerifiesWrite(t *testing.T) {
|
||||
t.Run("atomic command preserves no-extra-confirmation contract", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{"bizId":"biz-confirm","updated":true}}`}}}
|
||||
if err := runNativeCardUpdate(t, caller,
|
||||
"message", "update-card",
|
||||
"--biz-id", "biz-confirm",
|
||||
"--content", "原子更新",
|
||||
"--flow-status", "3",
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantArgs := map[string]any{
|
||||
"bizId": "biz-confirm",
|
||||
"msgContent": "原子更新",
|
||||
"flowStatus": 3,
|
||||
}
|
||||
if caller.calls != 1 || caller.server != "im" || caller.tool != "update_streaming_card" || !reflect.DeepEqual(caller.args, wantArgs) {
|
||||
t.Fatalf("atomic call = count:%d server:%q tool:%q args:%#v", caller.calls, caller.server, caller.tool, caller.args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit evidence succeeds", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{"bizId":"biz-1","updated":true}}`}}}
|
||||
err := runNativeCardUpdate(t, caller,
|
||||
"message", "update-card",
|
||||
"--biz-id", "biz-1",
|
||||
"--content", "完成",
|
||||
"--flow-status", "3",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 1 || caller.server != "im" || caller.tool != "update_streaming_card" {
|
||||
t.Fatalf("call = count:%d server:%q tool:%q", caller.calls, caller.server, caller.tool)
|
||||
}
|
||||
if caller.args["bizId"] != "biz-1" {
|
||||
t.Fatalf("args = %#v", caller.args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("generic success is unverified", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true,"errorCode":null}`}}}
|
||||
err := runNativeCardUpdate(t, caller,
|
||||
"message", "update-card",
|
||||
"--biz-id", "not-a-real-card",
|
||||
"--content", "完成",
|
||||
"--flow-status", "3",
|
||||
)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "streaming_card_update_unverified" {
|
||||
t.Fatalf("error = %#v, want streaming_card_update_unverified", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("lower write error is preserved", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("write unavailable")}}}
|
||||
err := runNativeCardUpdate(t, caller,
|
||||
"message", "update-card",
|
||||
"--biz-id", "biz-1",
|
||||
"--content", "完成",
|
||||
"--flow-status", "3",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("lower write error was ignored")
|
||||
}
|
||||
})
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
response string
|
||||
wantReason string
|
||||
}{
|
||||
{name: "empty response", response: "", wantReason: "streaming_card_update_unverified"},
|
||||
{name: "invalid response", response: "{", wantReason: "streaming_card_update_response_invalid"},
|
||||
{name: "not applied", response: `{"result":{"updated":false}}`, wantReason: "streaming_card_update_not_applied"},
|
||||
{name: "biz id drift", response: `{"result":{"bizId":"biz-other","updated":true}}`, wantReason: "streaming_card_update_biz_id_mismatch"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: test.response}}}
|
||||
err := runNativeCardUpdate(t, caller,
|
||||
"message", "update-card",
|
||||
"--biz-id", "biz-1",
|
||||
"--content", "完成",
|
||||
"--flow-status", "3",
|
||||
)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != test.wantReason {
|
||||
t.Fatalf("error = %#v, want reason %q", err, test.wantReason)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("invalid arguments make no call", func(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"message", "update-card", "--biz-id", "<bizId>", "--content", "完成", "--flow-status", "3"},
|
||||
{"message", "update-card", "--biz-id", "biz-1", "--content", "完成", "--flow-status", "6"},
|
||||
} {
|
||||
caller := &scriptedToolCaller{}
|
||||
if err := runNativeCardUpdate(t, caller, args...); err == nil {
|
||||
t.Fatalf("args %v unexpectedly succeeded", args)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("args %v made %d calls", args, caller.calls)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry run publishes unverified plan without write", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := runNativeCardUpdate(t, caller,
|
||||
"message", "update-card",
|
||||
"--biz-id", "biz-preview",
|
||||
"--content", "完成",
|
||||
"--flow-status", "3",
|
||||
"--dry-run",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("dry-run made %d calls", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func runChatCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...str
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
root.SetArgs(append(append([]string(nil), args...), "--yes"))
|
||||
return root.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
|
||||
@@ -190,23 +190,23 @@ func TestChatListFavoritesSuppliesOpenDefaults(t *testing.T) {
|
||||
func TestChatListFavoritesMapsExplicitPagination(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
err := executeChatFavoritesCommand(t, caller,
|
||||
"message", "list-favorites", "--cursor", "42", "--size", "50")
|
||||
"message", "list-favorites", "--cursor", "42", "--size", "30")
|
||||
if err != nil {
|
||||
t.Fatalf("list-favorites returned error: %v", err)
|
||||
}
|
||||
|
||||
want := map[string]any{"cursor": int64(42), "size": "50"}
|
||||
want := map[string]any{"cursor": int64(42), "size": "30"}
|
||||
if len(caller.calls) != 1 || caller.calls[0].productID != "im" || caller.calls[0].toolName != "list_message_favorites" || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("calls = %#v, want im/list_message_favorites %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatListFavoritesRejectsInvalidSize(t *testing.T) {
|
||||
for _, size := range []string{"-1", "0", "101"} {
|
||||
for _, size := range []string{"-1", "0", "31"} {
|
||||
t.Run(size, func(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
err := executeChatFavoritesCommand(t, caller, "message", "list-favorites", "--size", size)
|
||||
if err == nil || !strings.Contains(err.Error(), "--size must be between 1 and 100") {
|
||||
if err == nil || !strings.Contains(err.Error(), "--size must be between 1 and 30") {
|
||||
t.Fatalf("error = %v, want size validation error", err)
|
||||
}
|
||||
if got := apperrors.ExitCode(err); got != 3 {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
command string
|
||||
contains []string
|
||||
notContain string
|
||||
}{
|
||||
{
|
||||
name: "send returns task ID",
|
||||
command: "send",
|
||||
contains: []string{
|
||||
"openTaskId",
|
||||
"query-send-status --open-task-id <openTaskId>",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "query returns message and conversation IDs",
|
||||
command: "query-send-status",
|
||||
contains: []string{
|
||||
"openTaskId",
|
||||
"openMessageId",
|
||||
"openConversationId",
|
||||
"chat message edit",
|
||||
"chat message recall",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "edit includes post-send workflow",
|
||||
command: "edit",
|
||||
contains: []string{
|
||||
"send -> query-send-status -> edit",
|
||||
"query-send-status --open-task-id <上一步返回的openTaskId>",
|
||||
"edit --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
|
||||
},
|
||||
notContain: "chat message list",
|
||||
},
|
||||
{
|
||||
name: "recall includes post-send workflow",
|
||||
command: "recall",
|
||||
contains: []string{
|
||||
"send -> query-send-status -> recall",
|
||||
"query-send-status --open-task-id <上一步返回的openTaskId>",
|
||||
"recall --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
|
||||
},
|
||||
notContain: "chat message list",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
var output bytes.Buffer
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs([]string{"message", test.command, "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat message %s --help: %v\n%s", test.command, err, output.String())
|
||||
}
|
||||
|
||||
help := output.String()
|
||||
for _, want := range test.contains {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Errorf("chat message %s help missing %q:\n%s", test.command, want, help)
|
||||
}
|
||||
}
|
||||
if test.notContain != "" && strings.Contains(help, test.notContain) {
|
||||
t.Errorf("chat message %s help still contains %q:\n%s", test.command, test.notContain, help)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type chatMessagePaginationCaller struct {
|
||||
steps []scriptedToolStep
|
||||
calls []pagedCommandCall
|
||||
}
|
||||
|
||||
func (c *chatMessagePaginationCaller) CallTool(_ context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copied := map[string]any{}
|
||||
for k, v := range args {
|
||||
copied[k] = v
|
||||
}
|
||||
c.calls = append(c.calls, pagedCommandCall{server: serverID, tool: toolName, args: copied})
|
||||
if len(c.steps) == 0 {
|
||||
return textToolResult(`{"result":{"messages":[],"items":[],"hasMore":false,"nextCursor":"0"}}`), nil
|
||||
}
|
||||
step := c.steps[len(c.calls)-1]
|
||||
if step.err != nil {
|
||||
return nil, step.err
|
||||
}
|
||||
return textToolResult(step.text), nil
|
||||
}
|
||||
|
||||
func (*chatMessagePaginationCaller) Format() string { return "json" }
|
||||
func (*chatMessagePaginationCaller) DryRun() bool { return false }
|
||||
func (*chatMessagePaginationCaller) Fields() string { return "" }
|
||||
func (*chatMessagePaginationCaller) JQ() string { return "" }
|
||||
|
||||
func executeChatMessagePaginationCommand(t *testing.T, caller *chatMessagePaginationCaller, args ...string) (map[string]any, error) {
|
||||
t.Helper()
|
||||
oldDeps := deps
|
||||
oldSleep := helperSleep
|
||||
t.Cleanup(func() {
|
||||
deps = oldDeps
|
||||
helperSleep = oldSleep
|
||||
})
|
||||
InitDeps(caller)
|
||||
out := &bytes.Buffer{}
|
||||
deps.Out.w = out
|
||||
deps.Out.errW = io.Discard
|
||||
helperSleep = func(d time.Duration) {}
|
||||
|
||||
root := newChatCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
err := root.ExecuteContext(context.Background())
|
||||
if out.Len() == 0 {
|
||||
return nil, err
|
||||
}
|
||||
var parsed map[string]any
|
||||
if unmarshalErr := json.Unmarshal(out.Bytes(), &parsed); unmarshalErr != nil {
|
||||
t.Fatalf("stdout JSON = %q, err = %v", out.String(), unmarshalErr)
|
||||
}
|
||||
return parsed, err
|
||||
}
|
||||
|
||||
func TestChatMessagePaginationDefaultSinglePageUnchanged(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
server string
|
||||
tool string
|
||||
want map[string]any
|
||||
}{
|
||||
{
|
||||
name: "list-all",
|
||||
args: []string{"message", "list-all", "--start", "2026-08-01 00:00:00", "--end", "2026-08-02 00:00:00"},
|
||||
server: "",
|
||||
tool: "search_messages_by_time_range",
|
||||
want: map[string]any{"startTime": "2026-08-01 00:00:00", "endTime": "2026-08-02 00:00:00", "limit": 50, "cursor": "0"},
|
||||
},
|
||||
{
|
||||
name: "list-by-sender",
|
||||
args: []string{"message", "list-by-sender", "--sender-user-id", "u1", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
|
||||
server: "",
|
||||
tool: "search_messages_by_sender",
|
||||
want: map[string]any{"senderUserId": "u1", "startTime": float64(1785513600000), "endTime": float64(1785600000000), "limit": 50, "cursor": "0"},
|
||||
},
|
||||
{
|
||||
name: "list-mentions",
|
||||
args: []string{"message", "list-mentions", "--group", "cid1", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
|
||||
server: "",
|
||||
tool: "search_at_me_message",
|
||||
want: map[string]any{"openConversationId": "cid1", "startTime": float64(1785513600000), "endTime": float64(1785600000000), "limit": 50, "cursor": "0"},
|
||||
},
|
||||
{
|
||||
name: "list-focused",
|
||||
args: []string{"message", "list-focused"},
|
||||
server: "",
|
||||
tool: "list_special_focus_messages",
|
||||
want: map[string]any{"limit": 50},
|
||||
},
|
||||
{
|
||||
name: "search",
|
||||
args: []string{"message", "search", "--query", "发布", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
|
||||
server: "",
|
||||
tool: "search_messages_by_keyword",
|
||||
want: map[string]any{"keyword": "发布", "startTime": float64(1785513600000), "endTime": float64(1785600000000), "limit": 100, "cursor": "0"},
|
||||
},
|
||||
{
|
||||
name: "search-advanced",
|
||||
args: []string{"message", "search-advanced", "--query", "周报", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
|
||||
server: "im",
|
||||
tool: "search_messages",
|
||||
want: map[string]any{"keyword": "周报", "startTime": float64(1785513600000), "endTime": float64(1785600000000), "limit": 100, "cursor": "0"},
|
||||
},
|
||||
{
|
||||
name: "list-favorites",
|
||||
args: []string{"message", "list-favorites"},
|
||||
server: "im",
|
||||
tool: "list_message_favorites",
|
||||
want: map[string]any{"cursor": int64(0), "size": "20"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &chatMessagePaginationCaller{}
|
||||
args := append([]string{}, tt.args...)
|
||||
args = append(args, "--page-limit", "2", "--max-items", "1", "--page-delay", "0")
|
||||
_, err := executeChatMessagePaginationCommand(t, caller, args...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v, want one fallback call", caller.calls)
|
||||
}
|
||||
got := caller.calls[0]
|
||||
if got.server != tt.server || got.tool != tt.tool || !argsEqual(got.args, tt.want) {
|
||||
t.Fatalf("call = %#v, want server=%s tool=%s args=%#v", got, tt.server, tt.tool, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessagePaginationPageAllAggregatesSevenCommands(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
server string
|
||||
tool string
|
||||
itemPath string
|
||||
cursorOne any
|
||||
cursorTwo any
|
||||
pageOne string
|
||||
pageTwo string
|
||||
}{
|
||||
{
|
||||
name: "list-all", args: []string{"message", "list-all", "--start", "2026-08-01 00:00:00", "--end", "2026-08-02 00:00:00"},
|
||||
server: "chat", tool: "search_messages_by_time_range", itemPath: "conversationMessagesList", cursorOne: "0", cursorTwo: "c2",
|
||||
pageOne: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`,
|
||||
pageTwo: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`,
|
||||
},
|
||||
{
|
||||
name: "list-by-sender", args: []string{"message", "list-by-sender", "--sender-user-id", "u1", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
|
||||
server: "chat", tool: "search_messages_by_sender", itemPath: "conversationMessagesList", cursorOne: "0", cursorTwo: "c2",
|
||||
pageOne: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"洄川","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`,
|
||||
pageTwo: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"洄川","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`,
|
||||
},
|
||||
{
|
||||
name: "list-mentions", args: []string{"message", "list-mentions", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
|
||||
server: "chat", tool: "search_at_me_message", itemPath: "conversationMessagesList", cursorOne: "0", cursorTwo: "c2",
|
||||
pageOne: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`,
|
||||
pageTwo: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`,
|
||||
},
|
||||
{
|
||||
name: "list-focused", args: []string{"message", "list-focused"},
|
||||
server: "chat", tool: "list_special_focus_messages", itemPath: "messages", cursorOne: nil, cursorTwo: int64(2),
|
||||
pageOne: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":2}}`,
|
||||
pageTwo: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":0}}`,
|
||||
},
|
||||
{
|
||||
name: "search", args: []string{"message", "search", "--query", "发布", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
|
||||
server: "chat", tool: "search_messages_by_keyword", itemPath: "conversationMessagesList", cursorOne: "0", cursorTwo: "c2",
|
||||
pageOne: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`,
|
||||
pageTwo: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`,
|
||||
},
|
||||
{
|
||||
name: "search-advanced", args: []string{"message", "search-advanced", "--query", "周报"},
|
||||
server: "im", tool: "search_messages", itemPath: "conversationMessagesList", cursorOne: "0", cursorTwo: "c2",
|
||||
pageOne: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`,
|
||||
pageTwo: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`,
|
||||
},
|
||||
{
|
||||
name: "list-favorites", args: []string{"message", "list-favorites"},
|
||||
server: "im", tool: "list_message_favorites", itemPath: "items", cursorOne: int64(0), cursorTwo: int64(20),
|
||||
pageOne: `{"result":{"items":[{"id":"f1"}],"hasMore":true,"nextCursor":20}}`,
|
||||
pageTwo: `{"result":{"items":[{"id":"f2"}],"hasMore":false,"nextCursor":0}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &chatMessagePaginationCaller{steps: []scriptedToolStep{{text: tt.pageOne}, {text: tt.pageTwo}}}
|
||||
args := append([]string{}, tt.args...)
|
||||
args = append(args, "--page-all", "--page-delay", "0")
|
||||
got, err := executeChatMessagePaginationCommand(t, caller, args...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items := got["result"].(map[string]any)[tt.itemPath].([]any)
|
||||
if tt.itemPath == "conversationMessagesList" {
|
||||
messages := items[0].(map[string]any)["messages"].([]any)
|
||||
if len(items) != 1 || len(messages) != 2 {
|
||||
t.Fatalf("conversation items = %#v", items)
|
||||
}
|
||||
} else if len(items) != 2 {
|
||||
t.Fatalf("items = %#v", items)
|
||||
}
|
||||
if len(caller.calls) != 2 {
|
||||
t.Fatalf("calls = %#v, want two pages", caller.calls)
|
||||
}
|
||||
if caller.calls[0].server != tt.server || caller.calls[0].tool != tt.tool {
|
||||
t.Fatalf("first call = %#v", caller.calls[0])
|
||||
}
|
||||
if !reflect.DeepEqual(caller.calls[0].args["cursor"], tt.cursorOne) {
|
||||
t.Fatalf("first cursor = %#v, want %#v", caller.calls[0].args["cursor"], tt.cursorOne)
|
||||
}
|
||||
if !reflect.DeepEqual(caller.calls[1].args["cursor"], tt.cursorTwo) {
|
||||
t.Fatalf("second cursor = %#v, want %#v", caller.calls[1].args["cursor"], tt.cursorTwo)
|
||||
}
|
||||
paging := got["paging"].(map[string]any)
|
||||
if paging["pages"].(float64) != 2 || paging["total"].(float64) != 2 || paging["truncated"] != false {
|
||||
t.Fatalf("paging = %#v", paging)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func argsEqual(got, want map[string]any) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
for key, wantValue := range want {
|
||||
gotValue, ok := got[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch w := wantValue.(type) {
|
||||
case float64:
|
||||
g, ok := gotValue.(int64)
|
||||
if !ok || float64(g) != w {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
if !reflect.DeepEqual(gotValue, wantValue) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -16,6 +16,7 @@ package helpers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
@@ -23,8 +24,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type chatMessageSearchCall struct {
|
||||
@@ -34,12 +37,41 @@ type chatMessageSearchCall struct {
|
||||
}
|
||||
|
||||
type chatMessageSearchCaller struct {
|
||||
calls []chatMessageSearchCall
|
||||
calls []chatMessageSearchCall
|
||||
searchResponse string
|
||||
searchResponses []string
|
||||
searchCalls int
|
||||
searchError error
|
||||
failPreflight bool
|
||||
preflightError error
|
||||
}
|
||||
|
||||
func (c *chatMessageSearchCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, chatMessageSearchCall{productID: productID, toolName: toolName, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
|
||||
text := `{}`
|
||||
if toolName == "get_conversation_info" {
|
||||
if c.preflightError != nil {
|
||||
return nil, c.preflightError
|
||||
}
|
||||
if c.failPreflight {
|
||||
return nil, errors.New("conversation not found")
|
||||
}
|
||||
text = `{"result":{"openConversationId":"` + args["openConversationId"].(string) + `"}}`
|
||||
}
|
||||
if toolName == "search_messages_by_keyword" || toolName == "search_messages" {
|
||||
if c.searchError != nil {
|
||||
c.searchCalls++
|
||||
return nil, c.searchError
|
||||
}
|
||||
text = `{"result":{"messages":[],"hasMore":false}}`
|
||||
if c.searchCalls < len(c.searchResponses) {
|
||||
text = c.searchResponses[c.searchCalls]
|
||||
} else if c.searchResponse != "" {
|
||||
text = c.searchResponse
|
||||
}
|
||||
c.searchCalls++
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
func (*chatMessageSearchCaller) Format() string { return "json" }
|
||||
@@ -72,6 +104,7 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) {
|
||||
productID string
|
||||
toolName string
|
||||
wantToolArg map[string]any
|
||||
preflight []string
|
||||
}{
|
||||
{
|
||||
name: "keyword search",
|
||||
@@ -79,13 +112,13 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) {
|
||||
productID: "chat",
|
||||
toolName: "search_messages_by_keyword",
|
||||
wantToolArg: map[string]any{
|
||||
"keyword": "categoryName",
|
||||
"openConversationId": "cid-1",
|
||||
"startTime": startTime.UnixMilli(),
|
||||
"endTime": endTime.UnixMilli(),
|
||||
"limit": 100,
|
||||
"cursor": "0",
|
||||
"keyword": "categoryName",
|
||||
"startTime": startTime.UnixMilli(),
|
||||
"endTime": endTime.UnixMilli(),
|
||||
"limit": 100,
|
||||
"cursor": "0",
|
||||
},
|
||||
preflight: []string{"cid-1"},
|
||||
},
|
||||
{
|
||||
name: "advanced search",
|
||||
@@ -93,16 +126,16 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) {
|
||||
productID: "im",
|
||||
toolName: "search_messages",
|
||||
wantToolArg: map[string]any{
|
||||
"keyword": "categoryName",
|
||||
"openConversationIds": []string{"cid-1", "cid-2"},
|
||||
"messageType": "text",
|
||||
"onlyRobotMessages": true,
|
||||
"searchConvType": "group",
|
||||
"startTime": startTime.UnixMilli(),
|
||||
"endTime": endTime.UnixMilli(),
|
||||
"limit": 100,
|
||||
"cursor": "0",
|
||||
"keyword": "categoryName",
|
||||
"messageType": "text",
|
||||
"onlyRobotMessages": true,
|
||||
"searchConvType": "group",
|
||||
"startTime": startTime.UnixMilli(),
|
||||
"endTime": endTime.UnixMilli(),
|
||||
"limit": 100,
|
||||
"cursor": "0",
|
||||
},
|
||||
preflight: []string{"cid-1", "cid-2"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -116,14 +149,21 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetArgs(tt.args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat search returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
|
||||
if len(caller.calls) != len(tt.preflight)+1 {
|
||||
t.Fatalf("tool calls = %#v", caller.calls)
|
||||
}
|
||||
call := caller.calls[0]
|
||||
for index, conversationID := range tt.preflight {
|
||||
call := caller.calls[index]
|
||||
if call.productID != "chat" || call.toolName != "get_conversation_info" || call.args["openConversationId"] != conversationID {
|
||||
t.Fatalf("preflight[%d] = %#v", index, call)
|
||||
}
|
||||
}
|
||||
call := caller.calls[len(caller.calls)-1]
|
||||
if call.productID != tt.productID || call.toolName != tt.toolName {
|
||||
t.Fatalf("tool call = %s/%s, want %s/%s", call.productID, call.toolName, tt.productID, tt.toolName)
|
||||
}
|
||||
@@ -134,6 +174,507 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func executeNativeScopedSearch(t *testing.T, caller *chatMessageSearchCaller, args ...string) (map[string]any, error) {
|
||||
t.Helper()
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
cmd := newChatCommand()
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
var output strings.Builder
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(output.String()), &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func TestNativeScopedSearchFiltersGlobalResultsForBothEntries(t *testing.T) {
|
||||
start := "2026-07-09T00:00:00+08:00"
|
||||
end := "2026-07-11T00:00:00+08:00"
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
args []string
|
||||
tool string
|
||||
scopeParam string
|
||||
}{
|
||||
{
|
||||
name: "keyword search",
|
||||
args: []string{"message", "search", "--query", "周报", "--group", "cid-target", "--start", start, "--end", end},
|
||||
tool: "search_messages_by_keyword",
|
||||
scopeParam: "openConversationId",
|
||||
},
|
||||
{
|
||||
name: "advanced search",
|
||||
args: []string{"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target"},
|
||||
tool: "search_messages",
|
||||
scopeParam: "openConversationIds",
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{searchResponse: `{
|
||||
"result": {
|
||||
"conversationMessagesList": [
|
||||
{"openConversationId":"cid-target","title":"目标群","messages":[{"openMessageId":"m-target","content":"目标"}]},
|
||||
{"openConversationId":"cid-other","title":"其他群","messages":[{"openMessageId":"m-other","content":"越界"}]}
|
||||
],
|
||||
"hasMore": false
|
||||
}
|
||||
}`}
|
||||
payload, err := executeNativeScopedSearch(t, caller, tt.args...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, _ := payload["result"].(map[string]any)
|
||||
groups, _ := result["conversationMessagesList"].([]any)
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
group, _ := groups[0].(map[string]any)
|
||||
if group["openConversationId"] != "cid-target" {
|
||||
t.Fatalf("group = %#v", group)
|
||||
}
|
||||
scope, _ := payload["scope"].(map[string]any)
|
||||
if scope["targetsValidated"] != true || scope["resultsWithinScope"] != true || scope["filterMode"] != "client" {
|
||||
t.Fatalf("scope = %#v", scope)
|
||||
}
|
||||
searchCall := caller.calls[len(caller.calls)-1]
|
||||
if searchCall.toolName != tt.tool {
|
||||
t.Fatalf("search call = %#v", searchCall)
|
||||
}
|
||||
if _, exists := searchCall.args[tt.scopeParam]; exists {
|
||||
t.Fatalf("global fallback unexpectedly forwarded %s: %#v", tt.scopeParam, searchCall.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeScopedSearchInvalidCIDStopsBeforeSearch(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{failPreflight: true}
|
||||
_, err := executeNativeScopedSearch(t, caller,
|
||||
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-invalid")
|
||||
if err == nil {
|
||||
t.Fatal("invalid CID unexpectedly succeeded")
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_invalid" {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != "get_conversation_info" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeScopedSearchPreservesPreflightAuthError(t *testing.T) {
|
||||
want := &CLIError{Code: CodeAuthNotConfigured, Message: "当前未登录"}
|
||||
caller := &chatMessageSearchCaller{preflightError: want}
|
||||
_, err := executeNativeScopedSearch(t, caller,
|
||||
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
|
||||
if err == nil {
|
||||
t.Fatal("auth failure unexpectedly succeeded")
|
||||
}
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeAuthNotConfigured {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNativeScopedSearchPreservesAmbiguousMCPToolErrors(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
want *CLIError
|
||||
}{
|
||||
{
|
||||
name: "rate limited",
|
||||
want: &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: `{"success":false,"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down"}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "permission denied",
|
||||
want: &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: `{"success":false,"errorCode":"forbidden.noPermission","errorMsg":"permission denied"}`,
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{preflightError: test.want}
|
||||
_, err := executeNativeScopedSearch(t, caller,
|
||||
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
|
||||
if err != test.want {
|
||||
t.Fatalf("error = %#v, want original %#v", err, test.want)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != "get_conversation_info" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeScopedSearchScansUntilTargetConversationAppears(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{searchResponses: []string{
|
||||
`{"result":{"conversationMessagesList":[{"openConversationId":"cid-other","messages":[{"openMessageId":"m-other"}]}],"hasMore":true,"nextCursor":"c2"}}`,
|
||||
`{"result":{"conversationMessagesList":[{"openConversationId":"cid-target","messages":[{"openMessageId":"m-target"}]}],"hasMore":false}}`,
|
||||
}}
|
||||
payload, err := executeNativeScopedSearch(t, caller,
|
||||
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, _ := payload["result"].(map[string]any)
|
||||
if result["pagesFetched"] != float64(2) || result["complete"] != true {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
groups, _ := result["conversationMessagesList"].([]any)
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("groups = %#v", groups)
|
||||
}
|
||||
searchCalls := make([]chatMessageSearchCall, 0, 2)
|
||||
for _, call := range caller.calls {
|
||||
if call.toolName == "search_messages" {
|
||||
searchCalls = append(searchCalls, call)
|
||||
}
|
||||
}
|
||||
if len(searchCalls) != 2 || searchCalls[1].args["cursor"] != "c2" {
|
||||
t.Fatalf("search calls = %#v", searchCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNativeScopedSearchPageAllOptions(t *testing.T) {
|
||||
t.Run("page limit preserves continuation", func(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{searchResponse: `{
|
||||
"result": {
|
||||
"conversationMessagesList": [
|
||||
{"openConversationId":"cid-target","messages":[{"openMessageId":"m1"}]}
|
||||
],
|
||||
"hasMore": true,
|
||||
"nextCursor": "c2"
|
||||
}
|
||||
}`}
|
||||
payload, err := executeNativeScopedSearch(t, caller,
|
||||
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target",
|
||||
"--page-all", "--page-limit", "1", "--page-delay", "0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paging, _ := payload["paging"].(map[string]any)
|
||||
if paging["pages"] != float64(1) || paging["total"] != float64(1) || paging["truncated"] != true {
|
||||
t.Fatalf("paging = %#v", paging)
|
||||
}
|
||||
if caller.searchCalls != 1 {
|
||||
t.Fatalf("search calls = %d, want 1", caller.searchCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("max items truncates within filtered page", func(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{searchResponse: `{
|
||||
"result": {
|
||||
"conversationMessagesList": [
|
||||
{"openConversationId":"cid-target","messages":[
|
||||
{"openMessageId":"m1"},
|
||||
{"openMessageId":"m2"}
|
||||
]}
|
||||
],
|
||||
"hasMore": false
|
||||
}
|
||||
}`}
|
||||
payload, err := executeNativeScopedSearch(t, caller,
|
||||
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target",
|
||||
"--page-all", "--max-items", "1", "--page-delay", "0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, _ := payload["result"].(map[string]any)
|
||||
groups, _ := result["conversationMessagesList"].([]any)
|
||||
group, _ := groups[0].(map[string]any)
|
||||
messages, _ := group["messages"].([]any)
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("messages = %#v", messages)
|
||||
}
|
||||
paging, _ := payload["paging"].(map[string]any)
|
||||
if paging["total"] != float64(1) || paging["truncatedWithinPage"] != true || paging["resumeCursorReliable"] != false {
|
||||
t.Fatalf("paging = %#v", paging)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNativeScopedSearchMissingConversationIdentityFailsClosed(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{searchResponse: `{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":false}}`}
|
||||
_, err := executeNativeScopedSearch(t, caller,
|
||||
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
|
||||
if err == nil {
|
||||
t.Fatal("unverifiable scoped result unexpectedly succeeded")
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_unverified" {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeScopedSearchValidEmptyResultIsComplete(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
|
||||
payload, err := executeNativeScopedSearch(t, caller,
|
||||
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-empty")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, _ := payload["result"].(map[string]any)
|
||||
if result["complete"] != true || result["hasMore"] != false {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
scope, _ := payload["scope"].(map[string]any)
|
||||
if scope["targetsValidated"] != true || scope["sourceComplete"] != true {
|
||||
t.Fatalf("scope = %#v", scope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNativeScopedSearchFailureAndPaginationBranches(t *testing.T) {
|
||||
t.Run("empty scope uses the native search call", func(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{}
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
cmd := &cobra.Command{Use: "search"}
|
||||
err := runConversationScopedMessageSearch(
|
||||
cmd,
|
||||
"im",
|
||||
"search_messages",
|
||||
"openConversationIds",
|
||||
map[string]any{"keyword": "周报"},
|
||||
[]string{"", " "},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != "search_messages" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid page options fail before preflight", func(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{}
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
InitDeps(caller)
|
||||
|
||||
cmd := &cobra.Command{Use: "search"}
|
||||
AddPagedMCPFlags(cmd)
|
||||
if err := cmd.Flags().Set("page-all", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Flags().Set("page-limit", "0"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := runConversationScopedMessageSearch(
|
||||
cmd,
|
||||
"im",
|
||||
"search_messages",
|
||||
"openConversationIds",
|
||||
map[string]any{"keyword": "周报"},
|
||||
[]string{"cid-target"},
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "--page-limit must be between 1 and 500") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid paging made calls: %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cancelled context interrupts page delay", func(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{searchResponse: `{
|
||||
"result": {
|
||||
"messages": [{"openMessageId":"m1","openConversationId":"cid-target"}],
|
||||
"hasMore": true,
|
||||
"nextCursor": "c2"
|
||||
}
|
||||
}`}
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
cmd := &cobra.Command{Use: "search"}
|
||||
AddPagedMCPFlags(cmd)
|
||||
if err := cmd.Flags().Set("page-all", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Flags().Set("page-delay", "60000"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
cmd.SetContext(ctx)
|
||||
err := runConversationScopedMessageSearch(
|
||||
cmd,
|
||||
"im",
|
||||
"search_messages",
|
||||
"openConversationIds",
|
||||
map[string]any{"keyword": "周报", "limit": 100, "cursor": "0"},
|
||||
[]string{"cid-target"},
|
||||
)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("error = %v, want context canceled", err)
|
||||
}
|
||||
if caller.searchCalls != 1 {
|
||||
t.Fatalf("search calls = %d, want 1", caller.searchCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("lower search error", func(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{searchError: errors.New("search unavailable")}
|
||||
_, err := executeNativeScopedSearch(t, caller,
|
||||
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
|
||||
if err == nil {
|
||||
t.Fatal("lower search error was ignored")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stalled cursor fails closed", func(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{searchResponse: `{
|
||||
"result": {
|
||||
"messages": [{"openMessageId":"m1","openConversationId":"cid-target"}],
|
||||
"hasMore": true
|
||||
}
|
||||
}`}
|
||||
_, err := executeNativeScopedSearch(t, caller,
|
||||
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_cursor_stalled" {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("result limit preserves continuation", func(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{searchResponse: `{
|
||||
"result": {
|
||||
"messages": [{"openMessageId":"m1","openConversationId":"cid-target"}],
|
||||
"hasMore": true,
|
||||
"nextCursor": "c2"
|
||||
}
|
||||
}`}
|
||||
payload, err := executeNativeScopedSearch(t, caller,
|
||||
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target", "--limit", "1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, _ := payload["result"].(map[string]any)
|
||||
if result["complete"] != false || result["hasMore"] != true || result["nextCursor"] != "c2" {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate message ids are removed across pages", func(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{searchResponses: []string{
|
||||
`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-target"}],"hasMore":true,"nextCursor":"c2"}}`,
|
||||
`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-target"},{"openMessageId":"m2","openConversationId":"cid-target"}],"hasMore":false}}`,
|
||||
}}
|
||||
payload, err := executeNativeScopedSearch(t, caller,
|
||||
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, _ := payload["result"].(map[string]any)
|
||||
groups, _ := result["conversationMessagesList"].([]any)
|
||||
group, _ := groups[0].(map[string]any)
|
||||
messages, _ := group["messages"].([]any)
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("deduplicated messages = %#v", messages)
|
||||
}
|
||||
})
|
||||
|
||||
if got := uniqueNonEmptyStrings([]string{" cid ", "", "cid"}); !reflect.DeepEqual(got, []string{"cid"}) {
|
||||
t.Fatalf("uniqueNonEmptyStrings = %#v", got)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
value any
|
||||
want int
|
||||
}{
|
||||
{value: int64(7), want: 7},
|
||||
{value: json.Number("8"), want: 8},
|
||||
{value: float64(9), want: 9},
|
||||
{value: int64(0), want: 11},
|
||||
} {
|
||||
if got := positiveSearchLimit(test.value, 11); got != test.want {
|
||||
t.Errorf("positiveSearchLimit(%#v) = %d, want %d", test.value, got, test.want)
|
||||
}
|
||||
}
|
||||
if cleanSearchCursor(nil) != "" || cleanSearchCursor(" null ") != "" || cleanSearchCursor(" c2 ") != "c2" {
|
||||
t.Fatal("cleanSearchCursor did not normalize sentinel values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNativeScopedSearchDryRunShowsCompositePlanWithoutCallingTools(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{}
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
InitDeps(caller)
|
||||
cmd := &cobra.Command{Use: "search"}
|
||||
cmd.Flags().Bool("dry-run", true, "")
|
||||
AddPagedMCPFlags(cmd)
|
||||
for name, value := range map[string]string{
|
||||
"page-all": "true",
|
||||
"page-limit": "7",
|
||||
"max-items": "9",
|
||||
"page-delay": "11",
|
||||
} {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
var output strings.Builder
|
||||
cmd.SetOut(&output)
|
||||
err := runConversationScopedMessageSearch(
|
||||
cmd,
|
||||
"im",
|
||||
"search_messages",
|
||||
"openConversationIds",
|
||||
map[string]any{
|
||||
"keyword": "周报",
|
||||
"openConversationIds": []string{"cid-target"},
|
||||
"limit": 100,
|
||||
"cursor": "0",
|
||||
},
|
||||
[]string{"cid-target"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("dry-run made calls: %#v", caller.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(output.String()), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan, _ := payload["plan"].([]any)
|
||||
if payload["dry_run"] != true || payload["executed"] != false || len(plan) != 3 {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
searchStage, _ := plan[1].(map[string]any)
|
||||
arguments, _ := searchStage["arguments"].(map[string]any)
|
||||
if _, exists := arguments["openConversationIds"]; exists {
|
||||
t.Fatalf("dry-run global search still carries scope: %#v", searchStage)
|
||||
}
|
||||
if searchStage["pageAll"] != true ||
|
||||
searchStage["pageLimit"] != float64(7) ||
|
||||
searchStage["maxItems"] != float64(9) ||
|
||||
searchStage["pageDelay"] != float64(11) {
|
||||
t.Fatalf("dry-run paging = %#v", searchStage)
|
||||
}
|
||||
}
|
||||
|
||||
type chatChangedContractCaller struct {
|
||||
calls []chatMessageSearchCall
|
||||
resolveUsers bool
|
||||
@@ -160,9 +701,12 @@ func executeChatChangedContract(t *testing.T, caller *chatChangedContractCaller,
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
cmd := newChatCommand()
|
||||
if cmd.PersistentFlags().Lookup("yes") == nil {
|
||||
cmd.PersistentFlags().Bool("yes", false, "skip confirmation")
|
||||
}
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(args)
|
||||
cmd.SetArgs(append(append([]string(nil), args...), "--yes"))
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
|
||||
@@ -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,123 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
// NormalizeSearchConversationScopeError maps only errors that prove the
|
||||
// requested conversation does not exist or that openConversationId itself is
|
||||
// invalid. Unknown MCP tool failures must remain unchanged: the legacy
|
||||
// CodeMCPToolError bucket also carries permission, throttling, and transient
|
||||
// backend failures, none of which proves that the caller supplied a bad CID.
|
||||
func NormalizeSearchConversationScopeError(conversationID string, err error) error {
|
||||
if err == nil || !isDefinitiveInvalidSearchConversationError(err) {
|
||||
return err
|
||||
}
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("无法验证会话 CID %q;已停止搜索,避免过滤失效后返回其他会话消息", conversationID),
|
||||
apperrors.WithReason("search_conversation_scope_invalid"),
|
||||
apperrors.WithDetails(map[string]any{"conversationId": conversationID}),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithHint("确认 openConversationId 存在且当前账号可访问后重试"),
|
||||
apperrors.WithCause(err),
|
||||
)
|
||||
}
|
||||
|
||||
func isDefinitiveInvalidSearchConversationError(err error) bool {
|
||||
var cliErr *CLIError
|
||||
if errors.As(err, &cliErr) {
|
||||
switch cliErr.Code {
|
||||
case CodeResourceNotFound, CodeInvalidParam:
|
||||
return true
|
||||
}
|
||||
code, message := searchConversationErrorFacts(cliErr.Message)
|
||||
if isExplicitInvalidConversationCode(code) || isConversationParameterError(code, message) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Transport errors can survive below a legacy CLIError in the cause chain.
|
||||
// Inspect their structured diagnostics, but require conversation-specific
|
||||
// evidence before treating a generic PARAM_ERROR as an invalid CID.
|
||||
var appErr *apperrors.Error
|
||||
if errors.As(err, &appErr) {
|
||||
code := strings.TrimSpace(appErr.ServerDiag.ServerErrorCode)
|
||||
message := strings.Join([]string{
|
||||
appErr.Message,
|
||||
appErr.ServerDiag.TechnicalDetail,
|
||||
appErr.Reason,
|
||||
appErr.FailureStage,
|
||||
}, " ")
|
||||
return isExplicitInvalidConversationCode(code) || isConversationParameterError(code, message)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func searchConversationErrorFacts(raw string) (string, string) {
|
||||
var body map[string]any
|
||||
if json.Unmarshal([]byte(raw), &body) != nil {
|
||||
return "", raw
|
||||
}
|
||||
code := firstSearchConversationErrorString(body, "errorCode", "error_code", "code")
|
||||
message := firstSearchConversationErrorString(body, "errorMsg", "error_msg", "message", "error")
|
||||
return code, message
|
||||
}
|
||||
|
||||
func firstSearchConversationErrorString(body map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := body[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isExplicitInvalidConversationCode(code string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(code)) {
|
||||
case "INVALID_OPEN_CONVERSATION_ID", "OPEN_CONVERSATION_NOT_FOUND", "CONVERSATION_NOT_FOUND":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isConversationParameterError(code, message string) bool {
|
||||
normalizedCode := strings.ToUpper(strings.TrimSpace(code))
|
||||
if normalizedCode != "PARAM_ERROR" && normalizedCode != "PARAMETER_ERROR" && normalizedCode != "INVALID_ARGUMENT" {
|
||||
return false
|
||||
}
|
||||
normalizedMessage := strings.ToLower(strings.TrimSpace(message))
|
||||
mentionsConversationID := strings.Contains(normalizedMessage, "openconversationid") ||
|
||||
strings.Contains(normalizedMessage, "open conversation id") ||
|
||||
strings.Contains(normalizedMessage, "conversation id") ||
|
||||
strings.Contains(normalizedMessage, "cid")
|
||||
if !mentionsConversationID {
|
||||
return false
|
||||
}
|
||||
for _, marker := range []string{
|
||||
"invalid", "illegal", "malformed", "required", "missing", "not found", "不存在", "无效", "非法", "缺少", "必填",
|
||||
} {
|
||||
if strings.Contains(normalizedMessage, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// 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 (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageNormalizeSearchConversationScopeError(t *testing.T) {
|
||||
if got := NormalizeSearchConversationScopeError("cid", nil); got != nil {
|
||||
t.Fatalf("nil error normalized to %#v", got)
|
||||
}
|
||||
|
||||
invalidCases := []struct {
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{
|
||||
name: "classified resource not found",
|
||||
err: &CLIError{Code: CodeResourceNotFound, Message: "conversation not found"},
|
||||
},
|
||||
{
|
||||
name: "explicit conversation error code",
|
||||
err: &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: `{"success":false,"errorCode":"INVALID_OPEN_CONVERSATION_ID","errorMsg":"invalid conversation"}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "conversation specific parameter error",
|
||||
err: &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: `{"success":false,"errorCode":"PARAM_ERROR","errorMsg":"openConversationId is invalid"}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "transport diagnostic proves invalid conversation",
|
||||
err: apperrors.NewAPI(
|
||||
"conversation validation failed",
|
||||
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
|
||||
ServerErrorCode: "INVALID_OPEN_CONVERSATION_ID",
|
||||
}),
|
||||
),
|
||||
},
|
||||
}
|
||||
for _, test := range invalidCases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := NormalizeSearchConversationScopeError("cid-invalid", test.err)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(got, &typed) || typed.Reason != "search_conversation_scope_invalid" {
|
||||
t.Fatalf("normalized error = %#v", got)
|
||||
}
|
||||
if !typed.RetryableSet || typed.Retryable {
|
||||
t.Fatalf("retryable = (%t, set=%t), want false and set", typed.Retryable, typed.RetryableSet)
|
||||
}
|
||||
if typed.Details["conversationId"] != "cid-invalid" || !errors.Is(got, test.err) {
|
||||
t.Fatalf("normalized error lost details or cause: %#v", typed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
preservedCases := []struct {
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{
|
||||
name: "rate limit",
|
||||
err: &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: `{"success":false,"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down","retryable":true}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "permission denied",
|
||||
err: &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: `{"success":false,"errorCode":"forbidden.noPermission","errorMsg":"permission denied"}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "generic parameter error without CID evidence",
|
||||
err: &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: `{"success":false,"errorCode":"PARAM_ERROR","errorMsg":"未找到指定工具"}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "structured error without recognized facts",
|
||||
err: &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: `{"success":false,"retryable":true}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "parameter error mentions CID without invalid evidence",
|
||||
err: &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: `{"success":false,"errorCode":"PARAM_ERROR","errorMsg":"openConversationId could not be processed"}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range preservedCases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := NormalizeSearchConversationScopeError("cid-target", test.err); got != test.err {
|
||||
t.Fatalf("error = %#v, want original %#v", got, test.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ func TestCrossPlatformCoverageChatMessageSendLocation(t *testing.T) {
|
||||
caller = &guardedMutationCaller{}
|
||||
err = executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"message", "send", "--group", "cid1", "--msg-type", "location",
|
||||
"--latitude", "39.9", "--longitude", "116.4", "--location-name", "国贸", "--map-thumbnail-url", "@media1")
|
||||
"--latitude", "39.9", "--longitude", "116.4", "--location-name", "国贸", "--map-thumbnail-url", "@media1", "--yes")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -128,7 +128,7 @@ func TestCrossPlatformCoverageChatMessageSendProfile(t *testing.T) {
|
||||
|
||||
caller = &guardedMutationCaller{}
|
||||
err = executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"message", "send", "--group", "cid1", "--msg-type", "profile", "--contact-id", "od123")
|
||||
"message", "send", "--group", "cid1", "--msg-type", "profile", "--contact-id", "od123", "--yes")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -170,7 +170,6 @@ func TestCrossPlatformCoverageChartMailAndSheetPureHelpersCoverage(t *testing.T)
|
||||
_ = isEmptyDataValidation(value)
|
||||
_ = isEmptyHyperlink(value)
|
||||
}
|
||||
_ = fillIntMatrix(2, 3, 4)
|
||||
_ = maxColLenStr([][]string{{"a"}, {"b", "c"}})
|
||||
_ = maxColLen2D([][]int{{1}, {2, 3}})
|
||||
_ = checkMatrixShape(1, 2, 1, 2, "x")
|
||||
@@ -213,7 +212,7 @@ func TestCrossPlatformCoverageChartMailAndSheetPureHelpersCoverage(t *testing.T)
|
||||
{styleSpec{}, 1, 1},
|
||||
}
|
||||
for _, style := range styles {
|
||||
_ = applyStyleSpec(&style.spec, style.rows, style.cols, map[string]any{})
|
||||
_, _ = buildStyleCells(&style.spec, style.rows, style.cols)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
scalar, raw string
|
||||
@@ -227,7 +226,9 @@ func TestCrossPlatformCoverageChartMailAndSheetPureHelpersCoverage(t *testing.T)
|
||||
{"", `[[""]]`, hAlignEnum},
|
||||
{"", `[["left"]]`, hAlignEnum},
|
||||
} {
|
||||
_ = apply2DString(tc.scalar, tc.raw, 1, 1, "align", "alignments", tc.enum, map[string]any{})
|
||||
if get, err := strGrid(tc.scalar, tc.raw, "align", tc.enum, 1, 1); err == nil && get != nil {
|
||||
_, _ = get(0, 0)
|
||||
}
|
||||
}
|
||||
views := []map[string]any{{"id": "one"}, {"filterViewId": "two"}}
|
||||
_, _ = findFilterViewByID(views, "one")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -94,7 +94,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
"请求涉及企业内部应用的查询、创建、配置、成员权限、机器人、事件订阅或版本管理",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"开放平台接口文档搜索使用 devdoc;普通钉钉业务数据使用对应产品命令",
|
||||
"个人 IM/OA 实时事件监听使用 event;开放平台接口文档搜索使用 devdoc;普通钉钉业务数据使用对应产品命令",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -293,7 +293,7 @@ func newDevAppEventListCommand(runner executor.Runner) *cobra.Command {
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出或搜索应用可订阅的事件",
|
||||
UseWhen: []string{"需要查事件码、事件名称或当前事件列表时"},
|
||||
AvoidWhen: []string{"订阅或退订事件使用对应写命令"},
|
||||
AvoidWhen: []string{"订阅或退订应用回调事件使用对应写命令;监听当前用户个人 IM/OA 事件使用 event"},
|
||||
Examples: []string{`dws dev app event list --unified-app-id <unifiedAppId> --keyword "审批" --page-size 20`},
|
||||
},
|
||||
},
|
||||
@@ -329,7 +329,7 @@ func newDevAppEventSubscribeCommand(runner executor.Runner) *cobra.Command {
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "为应用订阅指定事件码",
|
||||
UseWhen: []string{"已确认事件码并需要新增事件订阅时"},
|
||||
AvoidWhen: []string{"查询事件码或已有订阅时先使用 dev app event list"},
|
||||
AvoidWhen: []string{"查询事件码或已有订阅时先使用 dev app event list;个人 IM/OA 事件长连接监听使用 event"},
|
||||
Examples: []string{"dws dev app event subscribe --unified-app-id <unifiedAppId> --event-codes bpms_task_change --dry-run"},
|
||||
},
|
||||
},
|
||||
@@ -365,7 +365,7 @@ func newDevAppEventUnsubscribeCommand(runner executor.Runner) *cobra.Command {
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "取消应用的指定事件订阅",
|
||||
UseWhen: []string{"需要停止接收一个或多个已订阅事件时"},
|
||||
AvoidWhen: []string{"只是查看事件订阅时使用 dev app event list"},
|
||||
AvoidWhen: []string{"只是查看应用事件订阅时使用 dev app event list;停止个人事件监听使用 event stop"},
|
||||
Examples: []string{"dws dev app event unsubscribe --unified-app-id <unifiedAppId> --event-codes bpms_task_change --dry-run"},
|
||||
},
|
||||
},
|
||||
|
||||
+139
-23
@@ -221,6 +221,43 @@ func runDocUpload(cmd *cobra.Command, _ []string) error {
|
||||
return callMCPTool("commit_uploaded_file", commitArgs)
|
||||
}
|
||||
|
||||
// docSpaceUploadCommitText 执行文档空间三步上传(凭证 → PUT → 入库)并
|
||||
// 返回 commit 响应原文,供 doc import 的白名单外回退链路组装结构化结果。
|
||||
// 与 runDocUpload 的区别:不打印输出、不携带 doc upload 的 --workspace
|
||||
// 兼容告警,调用方负责结果投影。
|
||||
func docSpaceUploadCommitText(ctx context.Context, filePath, fileName string, fileSize int64, folder, workspace string) (string, error) {
|
||||
step1Args := map[string]any{}
|
||||
if folder != "" {
|
||||
step1Args["folderId"] = folder
|
||||
}
|
||||
if workspace != "" {
|
||||
step1Args["workspaceId"] = workspace
|
||||
}
|
||||
text, err := callMCPToolReturnText(ctx, "get_file_upload_info", step1Args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resourceURL, uploadKey, ossHeaders, err := parseUploadInfo(text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := httpPutFile(ctx, resourceURL, ossHeaders, filePath, fileSize); err != nil {
|
||||
return "", err
|
||||
}
|
||||
commitArgs := map[string]any{
|
||||
"uploadKey": uploadKey,
|
||||
"name": fileName,
|
||||
"fileSize": float64(fileSize),
|
||||
}
|
||||
if folder != "" {
|
||||
commitArgs["folderId"] = folder
|
||||
}
|
||||
if workspace != "" {
|
||||
commitArgs["workspaceId"] = workspace
|
||||
}
|
||||
return callMCPToolReturnText(ctx, "commit_uploaded_file", commitArgs)
|
||||
}
|
||||
|
||||
// parseUploadInfo extracts resourceUrl, uploadKey and headers from the MCP tool response.
|
||||
func parseUploadInfo(text string) (resourceURL, uploadKey string, headers map[string]string, err error) {
|
||||
var data map[string]any
|
||||
@@ -495,16 +532,23 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
|
||||
fileSize := fileInfo.Size()
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
deps.Out.PrintKeyValue("操作", "上传附件并插入文档")
|
||||
deps.Out.PrintKeyValue("文档", nodeID)
|
||||
deps.Out.PrintKeyValue("文件", filePath)
|
||||
deps.Out.PrintKeyValue("名称", fileName)
|
||||
deps.Out.PrintKeyValue("类型", mimeType)
|
||||
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", fileSize))
|
||||
return nil
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"contractVersion": "doc.operation.v1",
|
||||
"dry_run": true,
|
||||
"preview_kind": "plan",
|
||||
"ok": true,
|
||||
"status": "success",
|
||||
"complete": true,
|
||||
"operation": "doc.media_insert",
|
||||
"data": map[string]any{
|
||||
"executed": false, "nodeId": nodeID, "file": filePath,
|
||||
"fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize,
|
||||
},
|
||||
"steps": []map[string]any{{"name": "validate_local_file", "status": "success"}},
|
||||
})
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Step 1: get upload credentials (uploadUrl + resourceId)
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
|
||||
@@ -531,7 +575,20 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
|
||||
"Content-Type": mimeType,
|
||||
}
|
||||
if err := httpPutFile(ctx, uploadURL, ossHeaders, filePath, fileSize); err != nil {
|
||||
return err
|
||||
return apperrors.NewAPI(
|
||||
"附件上传结果未知;尚未确认正文 block 已插入,请先检查文档媒体列表,禁止改用手写 HTTP",
|
||||
apperrors.WithOperation("doc.media_insert"),
|
||||
apperrors.WithReason("doc_media_upload_unknown"),
|
||||
apperrors.WithFailureStage("upload_oss"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithActions("运行 dws doc +media-list 检查当前文档", "确认没有对应媒体后才重新执行 +media-insert", "不要 curl 上传地址或安装本地依赖"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"contractVersion": "doc.operation.v1", "status": "unknown", "nodeId": nodeID,
|
||||
"resourceId": resourceID, "fileName": fileName, "stage": "upload_oss",
|
||||
}),
|
||||
apperrors.WithCause(err),
|
||||
)
|
||||
}
|
||||
|
||||
// Step 3: insert block into document
|
||||
@@ -588,15 +645,43 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
|
||||
if err := callMCPTool("insert_document_block", insertArgs); err != nil {
|
||||
return err
|
||||
return apperrors.NewAPI(
|
||||
"附件已上传,但正文 block 插入结果未知;请先检查媒体列表,不要重复上传或插入",
|
||||
apperrors.WithOperation("doc.media_insert"),
|
||||
apperrors.WithReason("doc_media_insert_partial"),
|
||||
apperrors.WithFailureStage("insert_block"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithActions("运行 dws doc +media-list 检查 resourceId/blockId", "不要直接重试 +media-insert,不要使用 resourceUrl 手写请求"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"contractVersion": "doc.operation.v1", "status": "partial_success", "nodeId": nodeID,
|
||||
"resourceId": resourceID, "resourceUrl": resourceURL, "fileName": fileName,
|
||||
"steps": []map[string]any{
|
||||
{"name": "resolve_upload", "status": "success"},
|
||||
{"name": "upload_oss", "status": "success"},
|
||||
{"name": "insert_block", "status": "unknown"},
|
||||
},
|
||||
}),
|
||||
apperrors.WithCause(err),
|
||||
)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(mimeType, "image/") {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("图片已插入文档: %s (resourceUrl=%s)", fileName, resourceURL))
|
||||
} else {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("附件已插入文档: %s (resourceId=%s)", fileName, resourceID))
|
||||
}
|
||||
return nil
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"contractVersion": "doc.operation.v1",
|
||||
"ok": true,
|
||||
"status": "success",
|
||||
"complete": true,
|
||||
"operation": "doc.media_insert",
|
||||
"data": map[string]any{
|
||||
"nodeId": nodeID, "resourceId": resourceID, "resourceUrl": resourceURL,
|
||||
"fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize, "inserted": true,
|
||||
},
|
||||
"steps": []map[string]any{
|
||||
{"name": "resolve_upload", "status": "success"},
|
||||
{"name": "upload_oss", "status": "success"},
|
||||
{"name": "insert_block", "status": "success"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// parseAttachmentUploadInfo extracts uploadUrl, resourceId and resourceUrl from the MCP tool response.
|
||||
@@ -3583,6 +3668,17 @@ CLI 内部自动完成全部流程:
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"preview_kind": "plan",
|
||||
"operation": "doc_export",
|
||||
"nodeId": node,
|
||||
"exportFormat": format,
|
||||
"savedPath": outputPath,
|
||||
})
|
||||
}
|
||||
deps.Out.PrintKeyValue("操作", "导出文档(提交+轮询+下载)")
|
||||
deps.Out.PrintKeyValue("文档", node)
|
||||
deps.Out.PrintKeyValue("输出", outputPath)
|
||||
@@ -3593,7 +3689,7 @@ CLI 内部自动完成全部流程:
|
||||
ctx := context.Background()
|
||||
|
||||
// ── Step 1: 提交导出任务 ──
|
||||
deps.Out.PrintInfo("[1/3] 提交导出任务...")
|
||||
printJSONSafeInfo("[1/3] 提交导出任务...")
|
||||
submitText, err := callMCPToolReturnText(ctx, "submit_export_job", submitArgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("提交导出任务失败: %w", err)
|
||||
@@ -3608,10 +3704,10 @@ CLI 内部自动完成全部流程:
|
||||
deps.Out.PrintRaw(submitText)
|
||||
return fmt.Errorf("提交导出任务成功但未返回 jobId")
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" 任务已提交,jobId: %s", jobID))
|
||||
printJSONSafeInfo(fmt.Sprintf(" 任务已提交,jobId: %s", jobID))
|
||||
|
||||
// ── Step 2: 渐进式退避轮询 ──
|
||||
deps.Out.PrintInfo("[2/3] 等待导出完成...")
|
||||
printJSONSafeInfo("[2/3] 等待导出完成...")
|
||||
downloadURL, err := pollDocExportJob(ctx, jobID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -3630,11 +3726,28 @@ CLI 内部自动完成全部流程:
|
||||
outputPath = filepath.Join(outputPath, filename)
|
||||
}
|
||||
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[3/3] 下载文件到 %s ...", outputPath))
|
||||
printJSONSafeInfo(fmt.Sprintf("[3/3] 下载文件到 %s ...", outputPath))
|
||||
if err := httpGetFile(ctx, downloadURL, nil, outputPath); err != nil {
|
||||
return fmt.Errorf("文件下载失败 (jobId=%s): %w", jobID, err)
|
||||
}
|
||||
|
||||
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
|
||||
info, err := os.Stat(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取导出产物信息失败 (jobId=%s): %w", jobID, err)
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"success": true,
|
||||
"nodeId": node,
|
||||
"exportFormat": format,
|
||||
"jobId": jobID,
|
||||
"taskId": jobID,
|
||||
"status": "SUCCESS",
|
||||
"savedPath": outputPath,
|
||||
"sizeBytes": info.Size(),
|
||||
})
|
||||
}
|
||||
|
||||
deps.Out.PrintInfo(fmt.Sprintf("导出完成: %s", outputPath))
|
||||
return nil
|
||||
},
|
||||
@@ -3764,6 +3877,9 @@ CLI 内部自动完成全部流程:
|
||||
xlsx, xls → 电子表格
|
||||
md, txt → 文字文档
|
||||
xmind, mark → 脑图
|
||||
其他格式(html/pdf/zip 等)→ 不做在线文档转换,自动改走文件上传链路,
|
||||
以原文件形式存入 --folder/--workspace 指定位置;如需在线文档请先转换
|
||||
为 md;上传到钉盘请用 dws drive upload
|
||||
|
||||
文件大小限制: 20MB
|
||||
|
||||
@@ -3791,8 +3907,8 @@ CLI 内部自动完成全部流程:
|
||||
},
|
||||
}
|
||||
importCmd.Flags().String("file", "", "本地文件路径 (必填)")
|
||||
importCmd.Flags().String("folder", "", "目标文件夹 ID 或 URL (可选,与 --workspace 至少传一个)")
|
||||
importCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL (可选,与 --folder 至少传一个)")
|
||||
importCmd.Flags().String("folder", "", "目标文件夹 ID 或 URL (可选;folder/workspace 都不传时导入到默认根目录)")
|
||||
importCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL (可选;folder/workspace 都不传时导入到默认根目录)")
|
||||
importCmd.Flags().StringP("name", "n", "", "导入后文档名称 (可选,默认取文件名)")
|
||||
importCmd.Flags().String("folder-id", "", "")
|
||||
_ = importCmd.Flags().MarkHidden("folder-id")
|
||||
@@ -4569,7 +4685,7 @@ func pollDocExportJob(ctx context.Context, jobID string) (downloadURL string, er
|
||||
|
||||
for attempt := 1; attempt <= maxPolls; attempt++ {
|
||||
interval := pollInterval(attempt)
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, maxPolls, interval))
|
||||
printJSONSafeInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, maxPolls, interval))
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"syscall"
|
||||
"unicode/utf8"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -17,9 +18,6 @@ const (
|
||||
// Server-side OSS delta resolution is now fixed, so large chunks are safe.
|
||||
initialChunkSize = 10000
|
||||
|
||||
// minChunkSize is the floor; below this we report an error instead of retrying.
|
||||
minChunkSize = 5000
|
||||
|
||||
// longContentWarningThreshold triggers a hint to use --content-file.
|
||||
longContentWarningThreshold = 2048
|
||||
)
|
||||
@@ -95,10 +93,11 @@ func docWritePipeline(cmd *cobra.Command, toolName string, toolArgs map[string]a
|
||||
// Single write path
|
||||
nodeID, lastResponse, writeErr = singleWrite(ctx, toolName, toolArgs)
|
||||
chunksWritten = 1
|
||||
if writeErr != nil && isTimeoutError(writeErr.Error()) && runeCount > minChunkSize {
|
||||
// Timeout on single write — fallback to chunked with halved size
|
||||
deps.Out.PrintInfo("[INFO] 单次写入超时,自动切换为分片写入...")
|
||||
nodeID, chunksWritten, lastResponse, writeErr = chunkedWrite(ctx, toolName, toolArgs, markdown, operation, initialChunkSize/2)
|
||||
if writeErr != nil && isTimeoutError(writeErr.Error()) {
|
||||
// The server may have committed the write before the client observed the
|
||||
// timeout. Replaying create/append here can duplicate a document or
|
||||
// content, so fail closed and require inspection before any retry.
|
||||
writeErr = docWriteUnknownStateError(operation, nodeID, "single_write", 0, 1, writeErr)
|
||||
}
|
||||
} else {
|
||||
// Chunked write path
|
||||
@@ -156,6 +155,9 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
|
||||
len(chunks), utf8.RuneCountInString(chunks[0])))
|
||||
resultText, err := callMCPToolReturnText(ctx, "create_document", createArgs)
|
||||
if err != nil {
|
||||
if isTimeoutError(err.Error()) {
|
||||
return "", 0, resultText, docWriteUnknownStateError(operation, "", "chunk_1", 0, len(chunks), err)
|
||||
}
|
||||
return "", 0, resultText, fmt.Errorf("创建文档失败: %w", err)
|
||||
}
|
||||
nodeID = extractNodeIDFromResult(resultText)
|
||||
@@ -181,6 +183,9 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
|
||||
len(chunks), utf8.RuneCountInString(chunks[0]), firstMode))
|
||||
resultText, err := callMCPToolReturnText(ctx, "update_document", updateArgs)
|
||||
if err != nil {
|
||||
if isTimeoutError(err.Error()) {
|
||||
return nodeID, 0, resultText, docWriteUnknownStateError(operation, nodeID, "chunk_1", 0, len(chunks), err)
|
||||
}
|
||||
return nodeID, 0, resultText, fmt.Errorf("第 1 片写入失败: %w", err)
|
||||
}
|
||||
lastResponse = resultText
|
||||
@@ -209,29 +214,9 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
|
||||
resultText, err := callMCPToolReturnText(ctx, "update_document", updateArgs)
|
||||
if err != nil {
|
||||
if isTimeoutError(err.Error()) {
|
||||
newSize := chunkSize / 2
|
||||
if newSize < minChunkSize {
|
||||
return nodeID, writtenCount, resultText, &CLIError{
|
||||
Code: CodeContentTruncated,
|
||||
Message: fmt.Sprintf("分片写入持续超时,已写入 %d 片。当前分片大小 %d 字符已低于最小阈值 %d",
|
||||
writtenCount, chunkSize, minChunkSize),
|
||||
Suggestion: fmt.Sprintf("后端写入超时无法恢复。已成功写入部分内容到 nodeId=%s,请使用 dws doc read --node %s 查看已写入部分",
|
||||
nodeID, nodeID),
|
||||
Operation: operation,
|
||||
}
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[INFO] 写入超时,分片大小减半为 %d 字符后重试...", newSize))
|
||||
chunkSize = newSize
|
||||
|
||||
var remaining strings.Builder
|
||||
remaining.WriteString(chunk)
|
||||
for j := i + 1; j < len(chunks); j++ {
|
||||
remaining.WriteString(chunks[j])
|
||||
}
|
||||
newChunks := splitMarkdownSafe(remaining.String(), chunkSize)
|
||||
chunks = append(chunks[:i], newChunks...)
|
||||
i-- // retry current index
|
||||
continue
|
||||
return nodeID, writtenCount, resultText, docWriteUnknownStateError(
|
||||
operation, nodeID, fmt.Sprintf("chunk_%d", i+1), writtenCount, len(chunks), err,
|
||||
)
|
||||
}
|
||||
return nodeID, writtenCount, resultText, fmt.Errorf("分片 %d 写入失败: %w", writtenCount+1, err)
|
||||
}
|
||||
@@ -243,6 +228,27 @@ func chunkedWrite(ctx context.Context, toolName string, toolArgs map[string]any,
|
||||
return nodeID, writtenCount, lastResponse, nil
|
||||
}
|
||||
|
||||
func docWriteUnknownStateError(operation, nodeID, stage string, written, total int, cause error) error {
|
||||
details := map[string]any{
|
||||
"status": "unknown",
|
||||
"nodeId": nodeID,
|
||||
"chunksWritten": written,
|
||||
"chunksTotal": total,
|
||||
"failedStage": stage,
|
||||
}
|
||||
return apperrors.NewAPI(
|
||||
"文档写入响应超时,服务端提交状态未知;为避免重复创建或重复追加,已停止自动重试",
|
||||
apperrors.WithOperation(operation),
|
||||
apperrors.WithReason("doc_write_commit_unknown"),
|
||||
apperrors.WithFailureStage(stage),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithActions("先读取目标文档确认实际写入状态", "仅在确认服务端未提交后重新执行"),
|
||||
apperrors.WithDetails(details),
|
||||
apperrors.WithCause(cause),
|
||||
)
|
||||
}
|
||||
|
||||
// isTimeoutError checks if an error message indicates a server-side timeout.
|
||||
func isTimeoutError(msg string) bool {
|
||||
lower := strings.ToLower(msg)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -40,10 +41,12 @@ func TestCrossPlatformCoverageDocWritePipelineStrategyRemainingCoverage(t *testi
|
||||
t.Fatalf("long literal single write: %v", err)
|
||||
}
|
||||
|
||||
fallback := strings.Repeat("x", minChunkSize+100)
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("timeout")}, {text: `{}`}, {text: `{}`}}})
|
||||
if err := docWritePipeline(docWriteCoverageCommand(), "update_document", map[string]any{"nodeId": "node", "mode": "overwrite", "markdown": fallback}, fallback, "update"); err != nil {
|
||||
t.Fatalf("single timeout fallback: %v", err)
|
||||
fallback := strings.Repeat("x", 5100)
|
||||
timeoutCaller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("timeout")}, {text: `{}`}}}
|
||||
installScriptedCaller(t, timeoutCaller)
|
||||
err := docWritePipeline(docWriteCoverageCommand(), "update_document", map[string]any{"nodeId": "node", "mode": "overwrite", "markdown": fallback}, fallback, "update")
|
||||
if err == nil || !strings.Contains(err.Error(), "提交状态未知") || timeoutCaller.calls != 1 {
|
||||
t.Fatalf("single timeout must stop without replay: err=%v calls=%d", err, timeoutCaller.calls)
|
||||
}
|
||||
|
||||
chunked := strings.Repeat("x", initialChunkSize+100)
|
||||
@@ -58,9 +61,43 @@ func TestCrossPlatformCoverageChunkedWriteAdaptiveRetryRemainingCoverage(t *test
|
||||
os.Args = []string{"dws", "doc"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
markdown := strings.Repeat("x", 24000)
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {err: errors.New("HSFTimeoutException")}, {text: `{}`}}})
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}, {err: errors.New("HSFTimeoutException")}, {text: `{}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
_, written, _, err := chunkedWrite(context.Background(), "update_document", map[string]any{"nodeId": "node"}, markdown, "update", 10000)
|
||||
if err != nil || written < 3 {
|
||||
t.Fatalf("adaptive retry written=%d err=%v", written, err)
|
||||
if err == nil || written != 1 || caller.calls != 2 || !strings.Contains(err.Error(), "提交状态未知") {
|
||||
t.Fatalf("timeout must stop without replay: written=%d calls=%d err=%v", written, caller.calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocWriteFirstChunkTimeoutIsUnknown(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "doc"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
markdown := strings.Repeat("x", 24000)
|
||||
tests := []struct {
|
||||
name string
|
||||
tool string
|
||||
args map[string]any
|
||||
wantNode string
|
||||
}{
|
||||
{name: "create", tool: "create_document", args: map[string]any{"name": "doc"}},
|
||||
{name: "update", tool: "update_document", args: map[string]any{"nodeId": "node", "mode": "overwrite"}, wantNode: "node"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("HSFTimeoutException")}, {text: `{}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
nodeID, written, _, err := chunkedWrite(context.Background(), tc.tool, tc.args, markdown, tc.name, 10000)
|
||||
var typed *apperrors.Error
|
||||
if err == nil || !errors.As(err, &typed) {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
if nodeID != tc.wantNode || written != 0 || caller.calls != 1 {
|
||||
t.Fatalf("node=%q written=%d calls=%d", nodeID, written, caller.calls)
|
||||
}
|
||||
if typed.Reason != "doc_write_commit_unknown" || typed.FailureStage != "chunk_1" || typed.ExecutionStarted == nil || !*typed.ExecutionStarted || !typed.RetryableSet || typed.Retryable {
|
||||
t.Fatalf("unknown commit metadata = %#v", typed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -619,10 +619,20 @@ func newDriveCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
dlOpts.logf = func(format string, a ...any) {
|
||||
deps.Out.PrintInfo(fmt.Sprintf(format, a...))
|
||||
printJSONSafeInfo(fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"preview_kind": "plan",
|
||||
"operation": "drive_download",
|
||||
"nodeId": fileID,
|
||||
"savedPath": outputPath,
|
||||
})
|
||||
}
|
||||
deps.Out.PrintKeyValue("操作", "下载钉盘文件")
|
||||
deps.Out.PrintKeyValue("文件ID", fileID)
|
||||
deps.Out.PrintKeyValue("输出", outputPath)
|
||||
@@ -632,7 +642,7 @@ func newDriveCommand() *cobra.Command {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Step 1: 获取下载 URL 和签名请求头
|
||||
deps.Out.PrintInfo("[1/2] 获取下载链接...")
|
||||
printJSONSafeInfo("[1/2] 获取下载链接...")
|
||||
text, err := callMCPToolReturnText(ctx, "download_file", argsMap)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -654,7 +664,7 @@ func newDriveCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
// Step 2: 分片下载(自动分派 + 401/403 凭证刷新重试)
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
|
||||
printJSONSafeInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
|
||||
dlOpts.knownSize = parseDownloadFileSize(text)
|
||||
dlOpts.nodeID = fileID
|
||||
dlOpts.version = parseDownloadFileVersion(text)
|
||||
@@ -684,6 +694,19 @@ func newDriveCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
|
||||
info, err := os.Stat(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取下载产物信息失败: %w", err)
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"success": true,
|
||||
"nodeId": fileID,
|
||||
"version": dlOpts.version,
|
||||
"savedPath": outputPath,
|
||||
"sizeBytes": info.Size(),
|
||||
})
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath))
|
||||
return nil
|
||||
},
|
||||
@@ -772,10 +795,21 @@ func newDriveCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
dlOpts.logf = func(format string, a ...any) {
|
||||
deps.Out.PrintInfo(fmt.Sprintf(format, a...))
|
||||
printJSONSafeInfo(fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"preview_kind": "plan",
|
||||
"operation": "drive_download_version",
|
||||
"nodeId": fileID,
|
||||
"version": versionNum,
|
||||
"savedPath": outputPath,
|
||||
})
|
||||
}
|
||||
deps.Out.PrintKeyValue("操作", "下载文件历史版本")
|
||||
deps.Out.PrintKeyValue("节点ID", fileID)
|
||||
deps.Out.PrintKeyValue("版本号", fmt.Sprintf("%d", versionNum))
|
||||
@@ -784,7 +818,7 @@ func newDriveCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
deps.Out.PrintInfo("[1/2] 获取历史版本下载链接...")
|
||||
printJSONSafeInfo("[1/2] 获取历史版本下载链接...")
|
||||
dlArgsMap := map[string]any{
|
||||
"nodeId": fileID,
|
||||
"version": versionNum,
|
||||
@@ -804,7 +838,7 @@ func newDriveCommand() *cobra.Command {
|
||||
}
|
||||
outputPath = filepath.Join(outputPath, filename)
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
|
||||
printJSONSafeInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
|
||||
dlOpts.knownSize = parseDownloadFileSize(text)
|
||||
dlOpts.nodeID = fileID
|
||||
dlOpts.version = versionNum
|
||||
@@ -833,6 +867,19 @@ func newDriveCommand() *cobra.Command {
|
||||
}
|
||||
return err
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
|
||||
info, err := os.Stat(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取下载产物信息失败: %w", err)
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"success": true,
|
||||
"nodeId": fileID,
|
||||
"version": versionNum,
|
||||
"savedPath": outputPath,
|
||||
"sizeBytes": info.Size(),
|
||||
})
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath))
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -58,6 +58,11 @@ type importFlowConfig struct {
|
||||
timeoutAsResult bool
|
||||
nextCommand string
|
||||
poll importPollPolicy
|
||||
// uploadFallback 开启后,所有不在 supportedFormats 白名单内的文件
|
||||
// (html/pdf/zip/无扩展名等)不再报错断链,统一移交文档空间文件上传
|
||||
// 链路原样入库;白名单即后端转换能力的封闭集合,无需第二份格式枚举。
|
||||
// 回退共享 prepareImportFile 的存在性 / 20MB / 空文件校验。
|
||||
uploadFallback bool
|
||||
}
|
||||
|
||||
type preparedImportFile struct {
|
||||
@@ -98,6 +103,10 @@ func docImportFlowConfig() importFlowConfig {
|
||||
workspaceFlags: []string{"workspace", "workspace-id"},
|
||||
nextCommand: "dws doc import get --task-id %s",
|
||||
poll: defaultImportPollPolicy(),
|
||||
// 白名单外的格式改走文档空间的文件上传链路
|
||||
// (与 drive upload --workspace 同一条 doc-space 上传原语),
|
||||
// 目标 flags(--folder/--workspace)与 import 同构,链路不中断。
|
||||
uploadFallback: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +174,11 @@ func prepareImportFile(cmd *cobra.Command, args []string, cfg importFlowConfig)
|
||||
}
|
||||
|
||||
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".")
|
||||
if !cfg.supportedFormats[extension] {
|
||||
// 非回退配置保持基线校验顺序:扩展名门禁先于导入目标校验
|
||||
// (sheet import 对无目标的非 Excel 文件必须先报 unsupported)。
|
||||
// uploadFallback 配置的白名单外文件继续走完共享校验,由
|
||||
// runImportCommand 分派到上传回退。
|
||||
if !cfg.supportedFormats[extension] && !cfg.uploadFallback {
|
||||
return preparedImportFile{}, fmt.Errorf("unsupported file format %q, supported: %s", extension, cfg.supportedFormatsText)
|
||||
}
|
||||
|
||||
@@ -197,11 +210,122 @@ func (cfg importFlowConfig) callTool(ctx context.Context, toolName string, args
|
||||
return callMCPToolReturnText(ctx, toolName, args)
|
||||
}
|
||||
|
||||
// runImportUploadFallback 承接白名单外格式:不再报错断链,改走文档空间
|
||||
// 文件上传链路原样入库。回退在 prepareImportFile 之后执行,共享存在性 /
|
||||
// 20MB / 空文件校验;不复用 runDocUpload,避免携带 doc upload 的
|
||||
// --workspace 兼容告警。移交事实通过 stderr 显式告知,机器可读结果统一
|
||||
// 携带 fallback=upload / converted=false 标记,防止 Agent 误判已完成
|
||||
// 在线文档转换。
|
||||
func runImportUploadFallback(cmd *cobra.Command, cfg importFlowConfig, file preparedImportFile) error {
|
||||
label := file.extension
|
||||
if label == "" {
|
||||
label = "无扩展名"
|
||||
}
|
||||
deps.Out.PrintWarning(fmt.Sprintf(
|
||||
"%s 文件不支持转换为在线文档(支持: %s),已自动改走文件上传链路,以原文件形式存入 --folder/--workspace 指定的目标位置;如需在线文档,请先将内容转换为 md 后重新执行 doc import;上传到钉盘请用 dws drive upload",
|
||||
label, cfg.supportedFormatsText))
|
||||
|
||||
// prepareImportFile 的 name 去掉了扩展名;上传保留原始文件名形态
|
||||
uploadName := file.name
|
||||
if filepath.Ext(uploadName) == "" && file.extension != "" {
|
||||
uploadName += "." + file.extension
|
||||
}
|
||||
jsonMode := deps.Caller.Format() == "json"
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
if jsonMode {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"preview_kind": "plan",
|
||||
"operation": "上传文件到钉钉文档",
|
||||
"requested_operation": cfg.operation,
|
||||
"fallback": "upload",
|
||||
"converted": false,
|
||||
"file": file.path,
|
||||
"name": uploadName,
|
||||
"format": file.extension,
|
||||
"size": file.size,
|
||||
})
|
||||
}
|
||||
deps.Out.PrintKeyValue("操作", "上传文件到钉钉文档(doc import 回退)")
|
||||
deps.Out.PrintKeyValue("文件", file.path)
|
||||
deps.Out.PrintKeyValue("名称", uploadName)
|
||||
deps.Out.PrintKeyValue("格式", file.extension)
|
||||
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", file.size))
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo("按原文件上传中(未转换为在线文档)...")
|
||||
}
|
||||
text, err := docSpaceUploadCommitText(ctx, file.path, uploadName, file.size, file.folder, file.workspace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// fail-closed:commit 响应必须可解析且带文件标识才算成功;
|
||||
// 空响应(legacy ack)、非 JSON 或缺少标识都不得包装为 success
|
||||
commit, dentryID, err := parseUploadCommitResult(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"success": true,
|
||||
"operation": "上传文件到钉钉文档",
|
||||
"requested_operation": cfg.operation,
|
||||
"fallback": "upload",
|
||||
"converted": false,
|
||||
"name": uploadName,
|
||||
"format": file.extension,
|
||||
"dentry_id": dentryID,
|
||||
"result": commit,
|
||||
})
|
||||
}
|
||||
|
||||
// uploadCommitIDKeys 是 commit_uploaded_file 响应中可作为文件标识的字段,
|
||||
// 按优先级排列;服务端可能返回平铺对象或包一层 result envelope。
|
||||
var uploadCommitIDKeys = []string{"dentryUuid", "dentryId", "nodeId", "fileId", "id"}
|
||||
|
||||
// parseUploadCommitResult 校验入库响应:拒绝空响应,要求 JSON 对象且
|
||||
// 含文件标识,返回解析后的对象与标识值。任何不满足都返回错误,
|
||||
// 由调用方向用户提示核对入库结果,而不是伪装成功。
|
||||
func parseUploadCommitResult(text string) (map[string]any, string, error) {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return nil, "", fmt.Errorf("上传入库未返回结果(commit_uploaded_file 响应为空),无法确认文件已入库;请用 dws doc list 核对目标位置")
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
|
||||
return nil, "", fmt.Errorf("上传入库响应无法解析为 JSON,无法确认文件已入库;原始响应: %s", trimmed)
|
||||
}
|
||||
payload := parsed
|
||||
if inner, ok := parsed["result"].(map[string]any); ok {
|
||||
payload = inner
|
||||
}
|
||||
for _, key := range uploadCommitIDKeys {
|
||||
if v, ok := payload[key].(string); ok && strings.TrimSpace(v) != "" {
|
||||
return parsed, v, nil
|
||||
}
|
||||
}
|
||||
return nil, "", fmt.Errorf("上传入库响应缺少文件标识(%s 均为空),无法确认文件已入库;原始响应: %s", strings.Join(uploadCommitIDKeys, "/"), trimmed)
|
||||
}
|
||||
|
||||
func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) error {
|
||||
file, err := prepareImportFile(cmd, args, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 非回退配置的白名单外文件已在 prepareImportFile 中按基线顺序拒绝
|
||||
if cfg.uploadFallback && !cfg.supportedFormats[file.extension] {
|
||||
return runImportUploadFallback(cmd, cfg, file)
|
||||
}
|
||||
jsonMode := deps.Caller.Format() == "json"
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
errUploadInfo = errors.New("upload info failed")
|
||||
errUploadPut = errors.New("put failed")
|
||||
)
|
||||
|
||||
func htmlFallbackCommand(t *testing.T, filePath string) *cobra.Command {
|
||||
t.Helper()
|
||||
// callMCPToolReturnText 从 os.Args 解析产品名(doc)
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "doc"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
cmd := &cobra.Command{Use: "import"}
|
||||
cmd.Flags().String("file", "", "")
|
||||
cmd.Flags().String("name", "", "")
|
||||
cmd.Flags().String("folder", "", "")
|
||||
cmd.Flags().String("workspace", "", "")
|
||||
cmd.Flags().String("folder-id", "", "")
|
||||
cmd.Flags().String("workspace-id", "", "")
|
||||
if filePath != "" {
|
||||
if err := cmd.Flags().Set("file", filePath); err != nil {
|
||||
t.Fatalf("set import file: %v", err)
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
|
||||
uploadSteps := []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
|
||||
{text: `{"dentryUuid":"node-1","name":"sales.html"}`},
|
||||
}
|
||||
|
||||
t.Run("html upload fallback emits marked json without legacy warnings", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", steps: uploadSteps}
|
||||
installScriptedCaller(t, caller)
|
||||
var stdout, warnings bytes.Buffer
|
||||
deps.Out.w = &stdout
|
||||
deps.Out.errW = &warnings
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
if err := cmd.Flags().Set("workspace", "ws-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
|
||||
}
|
||||
if caller.calls != 2 || caller.tool != "commit_uploaded_file" {
|
||||
t.Fatalf("fallback calls = %d last tool = %q, want 2 calls ending in commit_uploaded_file", caller.calls, caller.tool)
|
||||
}
|
||||
if got := caller.args["workspaceId"]; got != "ws-1" {
|
||||
t.Fatalf("commit workspaceId = %v, want ws-1", got)
|
||||
}
|
||||
if got := caller.args["name"]; got != "sales.html" {
|
||||
t.Fatalf("commit name = %v, want original file name with extension", got)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("fallback result must be one JSON document: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["success"] != true || payload["fallback"] != "upload" || payload["converted"] != false {
|
||||
t.Fatalf("fallback markers missing: %#v", payload)
|
||||
}
|
||||
if payload["dentry_id"] != "node-1" {
|
||||
t.Fatalf("dentry_id = %v, want node-1", payload["dentry_id"])
|
||||
}
|
||||
if payload["requested_operation"] != "导入本地文件为在线文档" {
|
||||
t.Fatalf("requested_operation = %v", payload["requested_operation"])
|
||||
}
|
||||
if !strings.Contains(warnings.String(), "文件上传链路") {
|
||||
t.Fatalf("fallback must announce the upload on stderr, got %q", warnings.String())
|
||||
}
|
||||
if strings.Contains(warnings.String(), "deprecated") {
|
||||
t.Fatalf("fallback must not emit the doc upload deprecation warning, got %q", warnings.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("json dry run stays a single json document", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", dry: true}
|
||||
installScriptedCaller(t, caller)
|
||||
var stdout bytes.Buffer
|
||||
deps.Out.w = &stdout
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("dry run must not call MCP, calls = %d", caller.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json dry-run output is not JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["dry_run"] != true || payload["executed"] != false || payload["fallback"] != "upload" || payload["converted"] != false {
|
||||
t.Fatalf("dry-run fallback payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("any non-importable format is redirected, not enumerated", func(t *testing.T) {
|
||||
for _, ext := range []string{"pdf", "zip", "png", "mp4"} {
|
||||
caller := &scriptedToolCaller{steps: uploadSteps}
|
||||
installScriptedCaller(t, caller)
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, ext))
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand(%s) error = %v, want upload fallback success", ext, err)
|
||||
}
|
||||
if caller.tool != "commit_uploaded_file" {
|
||||
t.Fatalf("%s last tool = %q, want commit_uploaded_file", ext, caller.tool)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uppercase htm extension via positional argument", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: uploadSteps}
|
||||
installScriptedCaller(t, caller)
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
path := writeImportFixture(t, "HTM")
|
||||
cmd := htmlFallbackCommand(t, "")
|
||||
if err := runImportCommand(cmd, []string{path}, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
|
||||
}
|
||||
if caller.tool != "commit_uploaded_file" {
|
||||
t.Fatalf("last tool = %q, want commit_uploaded_file", caller.tool)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hidden folder-id alias reaches the upload chain", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: uploadSteps}
|
||||
installScriptedCaller(t, caller)
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
if err := cmd.Flags().Set("folder-id", "folder-abc"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
|
||||
}
|
||||
if got := caller.args["folderId"]; got != "folder-abc" {
|
||||
t.Fatalf("commit folderId = %v, want folder-abc from --folder-id alias", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extensionless file is redirected with a readable label", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: uploadSteps}
|
||||
installScriptedCaller(t, caller)
|
||||
var warnings bytes.Buffer
|
||||
deps.Out.errW = &warnings
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
noExt := filepath.Join(t.TempDir(), "README")
|
||||
if err := os.WriteFile(noExt, []byte("plain"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := htmlFallbackCommand(t, noExt)
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
|
||||
}
|
||||
if !strings.Contains(warnings.String(), "无扩展名") {
|
||||
t.Fatalf("warning must label the extensionless file, got %q", warnings.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fallback enforces the shared 20MB limit", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
big := filepath.Join(t.TempDir(), "big.html")
|
||||
f, err := os.Create(big)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Truncate(importMaxFileSize + 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := htmlFallbackCommand(t, big)
|
||||
err = runImportCommand(cmd, nil, docImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds 20MB limit") {
|
||||
t.Fatalf("runImportCommand() error = %v, want 20MB limit rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fallback enforces the shared empty-file guard", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
empty := filepath.Join(t.TempDir(), "empty.html")
|
||||
if err := os.WriteFile(empty, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := htmlFallbackCommand(t, empty)
|
||||
err := runImportCommand(cmd, nil, docImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), "file is empty") {
|
||||
t.Fatalf("runImportCommand() error = %v, want empty-file rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("importable formats keep the conversion path", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", dry: true}
|
||||
installScriptedCaller(t, caller)
|
||||
var stdout bytes.Buffer
|
||||
deps.Out.w = &stdout
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "md"))
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v", err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("import dry-run output is not JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["operation"] != "导入本地文件为在线文档" {
|
||||
t.Fatalf("operation = %v, want conversion path", payload["operation"])
|
||||
}
|
||||
if _, ok := payload["fallback"]; ok {
|
||||
t.Fatalf("importable format must not carry fallback marker: %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing file falls through to the import required-flag error", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
cmd := htmlFallbackCommand(t, "")
|
||||
err := runImportCommand(cmd, nil, docImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), "--file is required") {
|
||||
t.Fatalf("runImportCommand() error = %v, want --file required", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("text dry run prints the fallback plan as key values", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{dry: true}
|
||||
installScriptedCaller(t, caller)
|
||||
var stdout bytes.Buffer
|
||||
deps.Out.w = &stdout
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("dry run must not call MCP, calls = %d", caller.calls)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "doc import 回退") || !strings.Contains(stdout.String(), "sales.html") {
|
||||
t.Fatalf("text dry-run must print the fallback plan, got %q", stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("upload chain errors propagate", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
steps []scriptedToolStep
|
||||
putErr error
|
||||
wantErr string
|
||||
}{
|
||||
{name: "upload info request fails", steps: []scriptedToolStep{{err: errUploadInfo}}, wantErr: "upload info failed"},
|
||||
{name: "upload credentials incomplete", steps: []scriptedToolStep{{text: `{"resourceUrl":""}`}}, wantErr: "incomplete upload credentials"},
|
||||
{name: "http put fails", steps: []scriptedToolStep{{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`}}, putErr: errUploadPut, wantErr: "put failed"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: tc.steps})
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return tc.putErr })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
err := runImportCommand(cmd, nil, docImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("runImportCommand() error = %v, want %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("commit responses that cannot prove success are rejected", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
commit string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "empty legacy ack", commit: " ", wantErr: "响应为空"},
|
||||
{name: "non-json text", commit: "commit-ok-plain-text", wantErr: "无法解析为 JSON"},
|
||||
{name: "missing file identity", commit: `{"ok":true}`, wantErr: "缺少文件标识"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
|
||||
{text: tc.commit},
|
||||
}})
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
err := runImportCommand(cmd, nil, docImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("runImportCommand() error = %v, want %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nested result envelope yields the dentry id", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
|
||||
{text: `{"result":{"dentryUuid":"nested-node-9"}}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller)
|
||||
var stdout bytes.Buffer
|
||||
deps.Out.w = &stdout
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
t.Cleanup(func() { SetHTTPPutFile(nil) })
|
||||
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
|
||||
t.Fatalf("runImportCommand() error = %v", err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("fallback result must stay JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["dentry_id"] != "nested-node-9" {
|
||||
t.Fatalf("dentry_id = %v, want nested-node-9", payload["dentry_id"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sheet import keeps rejecting html without fallback", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
// 无导入目标时也必须先报 unsupported(基线校验顺序:扩展名先于目标)
|
||||
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
err := runImportCommand(cmd, nil, sheetImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported file format") {
|
||||
t.Fatalf("sheet import (no target) error = %v, want unsupported file format", err)
|
||||
}
|
||||
|
||||
cmd = htmlFallbackCommand(t, writeImportFixture(t, "html"))
|
||||
if err := cmd.Flags().Set("workspace", "ws-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = runImportCommand(cmd, nil, sheetImportFlowConfig())
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported file format") {
|
||||
t.Fatalf("sheet import (with target) error = %v, want unsupported file format", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func executeJSONOutputContractCommand(t *testing.T, caller *scriptedToolCaller, build func() *cobra.Command, args ...string) (string, string, error) {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &deps)
|
||||
testseam.Protect(t, &os.Args)
|
||||
InitDeps(caller)
|
||||
var stdout, stderr bytes.Buffer
|
||||
deps.Out.w = &stdout
|
||||
deps.Out.errW = &stderr
|
||||
|
||||
root := build()
|
||||
installExampleGlobalFlags(root)
|
||||
os.Args = append([]string{"dws", root.Name()}, args...)
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err != nil {
|
||||
return stdout.String(), stderr.String(), err
|
||||
}
|
||||
return stdout.String(), stderr.String(), nil
|
||||
}
|
||||
|
||||
func assertJSONOutputPayload(t *testing.T, stdout string) map[string]any {
|
||||
t.Helper()
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
|
||||
t.Fatalf("stdout is not JSON: %v\n%s", err, stdout)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageJSONOutputContractForCompletedFileTransfers(t *testing.T) {
|
||||
testseam.Swap(t, &httpGetFile, func(_ context.Context, _ string, _ map[string]string, destination string) error {
|
||||
return os.WriteFile(destination, []byte("payload"), 0o600)
|
||||
})
|
||||
|
||||
t.Run("drive latest download", func(t *testing.T) {
|
||||
outputPath := filepath.Join(t.TempDir(), "latest.txt")
|
||||
stdout, stderr, err := executeJSONOutputContractCommand(t,
|
||||
&scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"downloadUrl":"https://example.test/latest.txt","fileSize":7,"version":9}`}}},
|
||||
newDriveCommand,
|
||||
"download", "--node", "node-latest", "--output", outputPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := assertJSONOutputPayload(t, stdout)
|
||||
if payload["nodeId"] != "node-latest" || payload["savedPath"] != outputPath || payload["sizeBytes"] != float64(7) || payload["version"] != float64(9) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if !strings.Contains(stderr, "下载完成") && !strings.Contains(stderr, "下载文件到") {
|
||||
t.Fatalf("expected progress on stderr, got %q", stderr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("drive historical download through compatibility flag", func(t *testing.T) {
|
||||
outputPath := filepath.Join(t.TempDir(), "versioned.txt")
|
||||
stdout, _, err := executeJSONOutputContractCommand(t,
|
||||
&scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"downloadUrl":"https://example.test/versioned.txt","fileSize":7}`}}},
|
||||
newDriveCommand,
|
||||
"download", "--node", "node-versioned", "--version", "4", "--output", outputPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := assertJSONOutputPayload(t, stdout)
|
||||
if payload["nodeId"] != "node-versioned" || payload["version"] != float64(4) || payload["sizeBytes"] != float64(7) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("doc export", func(t *testing.T) {
|
||||
testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time {
|
||||
ch := make(chan time.Time, 1)
|
||||
ch <- time.Now()
|
||||
return ch
|
||||
})
|
||||
outputPath := filepath.Join(t.TempDir(), "export.md")
|
||||
stdout, stderr, err := executeJSONOutputContractCommand(t,
|
||||
&scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: `{"jobId":"export-job-1"}`},
|
||||
{text: `{"status":"SUCCESS","downloadUrl":"https://example.test/export.md"}`},
|
||||
}},
|
||||
newDocCommand,
|
||||
"export", "--node", "doc-node", "--export-format", "markdown", "--output", outputPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := assertJSONOutputPayload(t, stdout)
|
||||
if payload["nodeId"] != "doc-node" || payload["exportFormat"] != "markdown" || payload["jobId"] != "export-job-1" || payload["taskId"] != "export-job-1" || payload["status"] != "SUCCESS" || payload["sizeBytes"] != float64(7) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if !strings.Contains(stderr, "提交导出任务") {
|
||||
t.Fatalf("expected export progress on stderr, got %q", stderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageJSONOutputContractDryRunIsMachineReadable(t *testing.T) {
|
||||
stdout, _, err := executeJSONOutputContractCommand(t,
|
||||
&scriptedToolCaller{format: "json", dry: true},
|
||||
newDriveCommand,
|
||||
"download", "--node", "node-dry-run", "--output", filepath.Join(t.TempDir(), "out.txt"), "--dry-run")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := assertJSONOutputPayload(t, stdout)
|
||||
if payload["dry_run"] != true || payload["executed"] != false || payload["nodeId"] != "node-dry-run" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
|
||||
stdout, _, err = executeJSONOutputContractCommand(t,
|
||||
&scriptedToolCaller{format: "json", dry: true},
|
||||
newDocCommand,
|
||||
"export", "--node", "doc-dry-run", "--export-format", "markdown", "--output", filepath.Join(t.TempDir(), "export.md"), "--dry-run")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload = assertJSONOutputPayload(t, stdout)
|
||||
if payload["dry_run"] != true || payload["executed"] != false || payload["nodeId"] != "doc-dry-run" || payload["operation"] != "doc_export" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageJSONOutputContractReportsMissingLocalArtifact(t *testing.T) {
|
||||
testseam.Swap(t, &httpGetFile, func(context.Context, string, map[string]string, string) error {
|
||||
return nil
|
||||
})
|
||||
testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time {
|
||||
ch := make(chan time.Time, 1)
|
||||
ch <- time.Now()
|
||||
return ch
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
build func() *cobra.Command
|
||||
args []string
|
||||
steps []scriptedToolStep
|
||||
}{
|
||||
{
|
||||
name: "latest drive download",
|
||||
build: newDriveCommand,
|
||||
args: []string{"download", "--node", "node-latest", "--output", filepath.Join(t.TempDir(), "latest.txt")},
|
||||
steps: []scriptedToolStep{{text: `{"downloadUrl":"https://example.test/latest.txt","fileSize":7,"version":9}`}},
|
||||
},
|
||||
{
|
||||
name: "versioned drive download",
|
||||
build: newDriveCommand,
|
||||
args: []string{"download", "--node", "node-versioned", "--version", "4", "--output", filepath.Join(t.TempDir(), "versioned.txt")},
|
||||
steps: []scriptedToolStep{{text: `{"downloadUrl":"https://example.test/versioned.txt","fileSize":7}`}},
|
||||
},
|
||||
{
|
||||
name: "doc export",
|
||||
build: newDocCommand,
|
||||
args: []string{"export", "--node", "doc-node", "--export-format", "markdown", "--output", filepath.Join(t.TempDir(), "export.md")},
|
||||
steps: []scriptedToolStep{
|
||||
{text: `{"jobId":"export-job-1"}`},
|
||||
{text: `{"status":"SUCCESS","downloadUrl":"https://example.test/export.md"}`},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, _, err := executeJSONOutputContractCommand(t, &scriptedToolCaller{format: "json", steps: tt.steps}, tt.build, tt.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), "读取") {
|
||||
t.Fatalf("expected missing local artifact error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -1346,7 +1346,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
Short: "批量删除个人热词",
|
||||
Long: `批量删除听记个人热词。
|
||||
支持一次删除多个热词(逗号分隔)。删除后对应热词不再参与后续语音识别优化。`,
|
||||
Example: ` dws minutes hot-word delete --words "钉钉"
|
||||
Example: ` dws minutes hot-word delete --words "天气"
|
||||
dws minutes hot-word delete --words "OKR,钉钉,Copilot"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "words"); err != nil {
|
||||
@@ -1384,7 +1384,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
"不确定现有热词时先用 hot-word list",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws minutes hot-word delete --words \"钉钉\"",
|
||||
"dws minutes hot-word delete --words \"天气\"",
|
||||
"dws minutes hot-word delete --words \"OKR,钉钉,Copilot\"",
|
||||
},
|
||||
},
|
||||
@@ -1883,11 +1883,12 @@ func newMinutesCommand() *cobra.Command {
|
||||
if err := validateRequiredFlagWithAliases(cmd, "id", "url", "task-uuid", "uuid"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "policy"); err != nil {
|
||||
return err
|
||||
// 手动校验必填参数(避免 validateRequiredFlags 对 Int 的误判)
|
||||
if !cmd.Flags().Changed("policy") {
|
||||
return fmt.Errorf("missing required flag --policy")
|
||||
}
|
||||
|
||||
policyID, err := strconv.ParseInt(mustGetFlag(cmd, "policy"), 10, 64)
|
||||
policyID, err := cmd.Flags().GetInt("policy")
|
||||
if err != nil || policyID < 2 || policyID > 4 {
|
||||
return fmt.Errorf("flag --policy must be an integer between 2 and 4 (2=可编辑, 3=可查看/下载, 4=仅查看)")
|
||||
}
|
||||
@@ -1942,7 +1943,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
_ = permissionApplyCmd.Flags().MarkHidden("task-uuid")
|
||||
permissionApplyCmd.Flags().String("uuid", "", "--id 的别名")
|
||||
_ = permissionApplyCmd.Flags().MarkHidden("uuid")
|
||||
permissionApplyCmd.Flags().String("policy", "", "权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填)")
|
||||
permissionApplyCmd.Flags().Int("policy", 0, "权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填)")
|
||||
|
||||
permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd, permissionApplyCmd)
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ func newOaCommand() *cobra.Command {
|
||||
"查看待审、已办、已发起或抄送审批,并执行同意、拒绝、撤销、转交等审批动作时",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"不要用于普通待办任务或工作日志;需要创建审批实例时先确认当前命令面是否支持",
|
||||
"不要用于普通待办任务或工作日志;需要实时监听未来的审批任务/实例事件时使用 event consume",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user