Compare commits

..
Author SHA1 Message Date
玉澜andCursor 50d7e7d98f docs: add declarative command framework migration guide
Give engineers a practical Tier1/Tier2/Tier3 checklist for migrating
helpers leaves and shortcuts onto corecmd declare → ContractFinal →
ResolveSchemaBuild, without resurrecting schema pins.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 11:27:19 +08:00
628 changed files with 17499 additions and 89046 deletions
-285
View File
@@ -1,285 +0,0 @@
'use strict';
// 评审归属是受保护分支上的声明式规则;未知路径不猜测,交给工作流负载均衡兜底。
const REVIEWER_POOL = ['wxianfeng', 'typefield', 'haofeng0705', 'hlzjsong'];
const PRODUCT_GROUPS = [
{
primary: 'wxianfeng',
backup: 'typefield',
products: ['chat', 'contact', 'ding', 'event', 'mail', 'live', 'conference', 'dev', 'devapp', 'mcp', 'aiapp'],
},
{
primary: 'typefield',
backup: 'wxianfeng',
products: ['doc', 'drive', 'wiki', 'markdown', 'docparse', 'aidesign', 'devdoc', 'blackboard', 'finance', 'law', 'credit'],
},
{
primary: 'haofeng0705',
backup: 'typefield',
products: ['minutes', 'sheet', 'aitable', 'calendar', 'todo', 'oa', 'attendance', 'report', 'agoal', 'aisearch', 'yida', 'hrbrain'],
},
];
const pathStartsWith = (prefixes) => (path) => prefixes.some((prefix) => path.startsWith(prefix));
const MODULES = [
{
id: 'security',
label: '登录、认证、权限、安全',
primary: 'hlzjsong',
backup: 'typefield',
requiresSecondary: true,
matches: pathStartsWith([
'internal/auth/',
'internal/keychain/',
'internal/audit/',
'internal/pat/',
'internal/security/',
'internal/safety/',
'pkg/edition/',
]),
},
{
id: 'delivery',
label: 'CI、测试、发布、安装',
primary: 'haofeng0705',
backup: 'wxianfeng',
requiresSecondary: true,
matches: (path) =>
path.startsWith('.github/') ||
path.startsWith('scripts/release/') ||
path.startsWith('scripts/policy/') ||
path.startsWith('scripts/dev/') ||
path.startsWith('scripts/install') ||
path.startsWith('Formula/') ||
path.startsWith('build/') ||
path.startsWith('internal/upgrade/') ||
path.startsWith('internal/app/upgrade') ||
path.startsWith('test/') ||
path.startsWith('verify/') ||
path.startsWith('.workflow/') ||
path === 'coverage.txt' ||
path === 'coverage-base.txt' ||
path === '.goreleaser.yaml' ||
path === 'package.json' ||
path === 'package-lock.json' ||
path === 'docs/releasing.md',
},
{
id: 'architecture',
label: 'DWS 架构、公共内核',
primary: 'wxianfeng',
backup: 'typefield',
requiresSecondary: true,
matches: pathStartsWith([
'cmd/',
'internal/apiclient/',
'internal/app/',
'internal/cli/',
'internal/cobracmd/',
'internal/corecmd/',
'internal/errors/',
'internal/executor/',
'internal/generator/',
'internal/i18n/',
'internal/interfacesnapshot/',
'internal/jsonutil/',
'internal/localio/',
'internal/logging/',
'internal/output/',
'internal/pipeline/',
'internal/plugin/',
'internal/profilectx/',
'internal/registry/',
'internal/syncdata/',
'internal/testseam/',
'internal/transport/',
'pkg/',
]),
},
{
id: 'compatibility',
label: '兼容性',
primary: 'wxianfeng',
backup: 'typefield',
requiresSecondary: true,
matches: (path) =>
/(?:^|[/_.-])compat(?:ibility)?(?=$|[/_.-])/.test(path) ||
path.includes('schema_compat'),
},
];
function productMatches(path, product) {
const aliases = product === 'blackboard' ? ['blackboard', 'whiteboard'] : [product];
return aliases.some((alias) => new RegExp(`(?:^|[/_.-])${alias}(?=$|[/_.-])`).test(path));
}
const PRODUCT_MODULES = PRODUCT_GROUPS.flatMap((group) =>
group.products.map((product) => ({
id: `product:${product}`,
label: `产品:${product}`,
primary: group.primary,
backup: group.backup,
requiresSecondary: false,
matches: (path) => productMatches(path, product),
})),
);
const ALL_MODULES = [MODULES[0], MODULES[1], ...PRODUCT_MODULES, MODULES[2], MODULES[3]];
function normalizedPaths(file) {
return [file?.filename, file?.previous_filename]
.filter((path) => typeof path === 'string' && path !== '')
.map((path) => path.toLowerCase());
}
function compareStats(left, right) {
return right.files - left.files || left.module.order - right.module.order || left.module.id.localeCompare(right.module.id);
}
function classifyFiles(files) {
const counts = new Map();
for (const file of files || []) {
const matchingModules = new Set();
for (const path of normalizedPaths(file)) {
const matches = ALL_MODULES.filter((module) => module.matches(path));
const securityOrDelivery = matches.filter(
(module) => module.id === 'security' || module.id === 'delivery',
);
const effectiveMatches = securityOrDelivery.length > 0
? [...securityOrDelivery, ...matches.filter((module) => module.id === 'compatibility')]
: matches;
for (const match of effectiveMatches) {
matchingModules.add(match.id);
}
if (
effectiveMatches.length === 0 &&
(path.startsWith('internal/helpers/') || path.startsWith('internal/shortcut/'))
) {
matchingModules.add('architecture');
}
}
for (const moduleID of matchingModules) {
counts.set(moduleID, (counts.get(moduleID) || 0) + 1);
}
}
return [...counts.entries()]
.map(([id, files]) => {
const index = ALL_MODULES.findIndex((module) => module.id === id);
return {module: {...ALL_MODULES[index], order: index}, files};
})
.sort(compareStats);
}
function chooseModuleReviewer(module, unavailable) {
return [module.primary, module.backup].find(
(reviewer) => REVIEWER_POOL.includes(reviewer) && !unavailable.has(reviewer),
);
}
function addReviewer(reviewers, reviewer) {
if (reviewer && !reviewers.includes(reviewer)) {
reviewers.push(reviewer);
}
}
function reviewerCandidates({preferredReviewers, fallbackReviewers, eligibleReviewers}) {
const eligible = new Set(eligibleReviewers.map((reviewer) => reviewer.toLowerCase()));
const candidates = [];
for (const reviewer of [...preferredReviewers, ...fallbackReviewers]) {
if (
eligible.has(reviewer.toLowerCase()) &&
!candidates.some((candidate) => candidate.toLowerCase() === reviewer.toLowerCase())
) {
candidates.push(reviewer);
}
}
return candidates;
}
async function requestReviewersWithFallback({
candidates,
requiredReviewers,
satisfiedReviewers = [],
requestReviewer,
onFailure = () => {},
}) {
const alreadySatisfied = new Set(
satisfiedReviewers.map((reviewer) => reviewer.toLowerCase()),
);
const satisfied = new Set();
const requested = [];
for (const reviewer of candidates) {
if (satisfied.size >= requiredReviewers) {
break;
}
const normalizedReviewer = reviewer.toLowerCase();
if (alreadySatisfied.has(normalizedReviewer)) {
satisfied.add(normalizedReviewer);
continue;
}
try {
const shouldContinue = await requestReviewer(reviewer);
if (shouldContinue === false) {
return {requested, satisfiedReviewers: [...satisfied], aborted: true};
}
requested.push(reviewer);
satisfied.add(normalizedReviewer);
} catch (error) {
onFailure(reviewer, error);
}
}
return {requested, satisfiedReviewers: [...satisfied], aborted: false};
}
function resolveReviewRouting({files, author, latestPusher, fallbackReviewers = REVIEWER_POOL}) {
const modules = classifyFiles(files);
const unavailable = new Set([author, latestPusher].filter(Boolean).map((login) => login.toLowerCase()));
const reviewers = [];
const primaryModule = modules[0];
if (!primaryModule) {
return {modules: [], reviewers, requiredReviewers: 1, reason: 'unknown_paths'};
}
addReviewer(reviewers, chooseModuleReviewer(primaryModule.module, unavailable));
const requiresSecondary =
modules.length > 1 || modules.some(({module}) => module.requiresSecondary);
const secondaryModule = modules.find(({module}) => module.id !== primaryModule.module.id) || primaryModule;
if (requiresSecondary) {
addReviewer(
reviewers,
chooseModuleReviewer(secondaryModule.module, new Set([...unavailable, ...reviewers])),
);
}
for (const reviewer of fallbackReviewers) {
if (reviewers.length >= (requiresSecondary ? 2 : 1)) {
break;
}
if (REVIEWER_POOL.includes(reviewer) && !unavailable.has(reviewer)) {
addReviewer(reviewers, reviewer);
}
}
return {
modules: modules.map(({module, files}) => ({id: module.id, label: module.label, files})),
reviewers,
requiredReviewers: requiresSecondary ? 2 : 1,
reason: requiresSecondary ? 'cross_or_sensitive' : 'single_module',
};
}
module.exports = {
REVIEWER_POOL,
classifyFiles,
requestReviewersWithFallback,
resolveReviewRouting,
reviewerCandidates,
};
-148
View File
@@ -1,148 +0,0 @@
'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;
});
+12 -36
View File
@@ -111,7 +111,6 @@ jobs:
filename.startsWith('internal/app/') ||
filename.startsWith('internal/cli/') ||
filename.startsWith('internal/cobracmd/') ||
filename.startsWith('internal/corecmd/') ||
filename.startsWith('internal/helpers/') ||
filename.startsWith('internal/i18n/') ||
filename.startsWith('internal/interfacesnapshot/') ||
@@ -148,12 +147,12 @@ 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/') ||
filename.startsWith('internal/app/upgrade') ||
filename.startsWith('internal/transport/') ||
filename.startsWith('internal/recovery/') ||
filename.startsWith('internal/syncdata/') ||
filename.includes('/testdata/') ||
filename.startsWith('testdata/') ||
@@ -434,10 +433,6 @@ 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
@@ -822,7 +817,6 @@ jobs:
run: ./scripts/policy/run-platform-coverage-gate.sh --base-ref "$COVERAGE_BASE_REF" --profile coverage-windows.txt
- name: Upload Windows coverage artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-windows
@@ -1181,7 +1175,7 @@ jobs:
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
COVERAGE_TARGET: "100"
COVERAGE_ENFORCE_OVERALL: "false"
COVERAGE_OVERALL_TOLERANCE: "0.1"
COVERAGE_OVERALL_TOLERANCE: "0"
run: |
policy_profile=coverage-policy.txt
if [ "$FULL_SUITE" != true ]; then
@@ -1371,47 +1365,29 @@ 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
. ./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
stable_ref="$(git tag --merged "$base_ref" --list 'v[0-9]*' --sort=-version:refname | awk 'index($0, "-") == 0 { print; exit }')"
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
printf '%s\n' \
"COMPATIBILITY_BASE_REF=$base_ref" \
"COMPATIBILITY_STABLE_REF=$stable_ref" \
"COMPATIBILITY_CANDIDATE_REF=$candidate_ref" >> "$GITHUB_ENV"
echo "COMPATIBILITY_BASE_REF=$base_ref" >> "$GITHUB_ENV"
echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV"
- name: Check historical commands, help, and complete CLI compatibility
- name: Check historical commands and help 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" \
STABLE_REF="$COMPATIBILITY_STABLE_REF" \
CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"
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 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" \
STABLE_REF="$COMPATIBILITY_STABLE_REF" \
CANDIDATE_REF="$COMPATIBILITY_CANDIDATE_REF"
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_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') }}
+44 -87
View File
@@ -21,11 +21,6 @@ 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:
@@ -34,13 +29,12 @@ jobs:
const repo = context.repo.repo;
const pullNumber = context.payload.pull_request.number;
const eventHeadSha = context.payload.pull_request.head.sha;
const {
REVIEWER_POOL,
requestReviewersWithFallback,
resolveReviewRouting,
reviewerCandidates,
} = require('./.github/reviewer-routing.js');
const reviewerPool = REVIEWER_POOL;
const reviewerPool = [
'sczheng189',
'shangguanxuan633-lab',
'audanye-sudo',
'wxianfeng',
];
async function getReadyEventPull(phase) {
const {data: currentPull} = await github.rest.pulls.get({
@@ -73,21 +67,6 @@ 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 &&
@@ -98,9 +77,13 @@ jobs:
return;
}
const existingRequestedReviewers = new Set(
(pullRequest.requested_reviewers || []).map(({login}) => login.toLowerCase()),
);
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;
}
let reviews;
try {
@@ -133,18 +116,22 @@ jobs:
latestDecisionByLogin.set(login, review);
}
}
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()),
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),
);
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 {
@@ -191,42 +178,20 @@ jobs:
tieOrder.get(left) - tieOrder.get(right),
);
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) => {
for (const reviewer of ranked) {
try {
const currentPull = await getReadyEventPull('review request');
if (!currentPull) {
return false;
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;
}
await github.rest.pulls.requestReviewers({
owner,
@@ -237,23 +202,15 @@ jobs:
core.info(
`Requested @${reviewer} for PR #${pullNumber} (open request load: ${loads.get(reviewer)}).`,
);
return true;
},
onFailure: (reviewer, error) => {
return;
} catch (error) {
core.warning(
`Could not request @${reviewer} for PR #${pullNumber}; trying the next candidate (${error.status || 'unknown status'}).`,
);
},
});
if (requestResult.aborted) {
return;
}
}
if (requestResult.satisfiedReviewers.length < routing.requiredReviewers) {
core.warning(
`Only ${requestResult.satisfiedReviewers.length} of ${routing.requiredReviewers} required reviewers could be satisfied for PR #${pullNumber}.`,
);
}
core.warning(`No reviewer request could be created for PR #${pullNumber}.`);
}
async function enableAutoMerge() {
+12 -15
View File
@@ -302,8 +302,9 @@ on the leaf:
```bash
dws auth status # token_valid should be true
dws schema <mcp-canonical> --jq '{canonical_path,interface_ref,parameters}' -f json
# or CLI path: dws schema --cli-path "drive copy" --jq '{canonical_path,interface_ref,parameters}' -f json
dws cache refresh # deprecated no-op: prints a retirement notice (discovery cache is gone; refreshes nothing)
dws schema <mcp-canonical> -f json
# or CLI path: dws schema --cli-path "drive copy" -f json
```
Resolve MCP identity via declared `interface_ref` when CLI canonical ≠ MCP path
@@ -320,10 +321,8 @@ Skill (evidence only)**.
Split work by product groups. Each agent must:
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live
`dws schema <leaf> --compact` for its tools. Mapping/interface/provenance
audits may query the full leaf only through a narrow `--jq` / `--fields`
projection; do not load an entire full leaf into Agent context.
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live `dws schema`
for its tools.
- Hand-write selection prose and leaf Contract / ProductDecl declarations;
forbid wholesale JSON merges from review dumps.
- Edit only its product’s leaf declarations (and `ProductDecl` when needed).
@@ -472,15 +471,13 @@ path; a generator unit test or JSON count alone is insufficient.
`parameters` object for commands without flags. Keep it suitable for the #602
compatibility baseline and fail rather than silently emitting a partial
export.
- `schema --all` is not normal command discovery. Use overview -> compact
product/group -> compact leaf for routine Agent work. `--compact` is the
reviewed positive-field allowlist for Agent context: new full/audit fields
must not appear there until explicitly reviewed. A compact full export is not
a complete compatibility baseline.
- `schema --all` is not normal command discovery. Use overview -> product/group
-> leaf for routine Agent work. `--compact` is supported for context-saving
projections, but a compact full export is not a complete compatibility
baseline.
- `dws <path> --help` defines whether Cobra exposes a path and which flags the
executable accepts. A compact leaf defines Agent selection, CLI parameters,
constraints, and safety/confirmation semantics. Full leaf fields such as
`property`, `interface_ref`, and provenance are audit facts. A conflict is
contract drift, not permission to guess.
executable accepts. A leaf Schema defines Agent selection, parameter mapping
and constraints, and safety/confirmation semantics. A conflict is contract
drift, not permission to guess.
- Schema and Help describe commands; neither returns DingTalk business data.
After discovery, execute the real read/search/list command to obtain data.
-231
View File
@@ -6,238 +6,8 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
### Added
- **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
### Added
- **Expanded open CLI workflows** (#887) — adds calendar event-instance
queries, Drive latest-file selection, Markdown diff, Mail calendar/export/
share-to-chat workflows, and Minutes hot-word, permission, and audio-memo
operations, with matching Schema and cross-platform coverage.
### Changed
- **Multi-skill framework alignment** (#887) — folds long-tail skills into
`dingtalk-misc`, renames the shared package to `dingtalk-shared`, removes
stale Preview guidance, and reorganizes shared recipes and routing for more
predictable Agent selection.
- **Bounded Agent Schema delivery** (#887) — keeps compact and wire projections
focused on executable contract facts, retires stale MCP metadata candidates,
and teaches Agents to prefer `dws schema --compact` for bounded context.
### Deprecated
- **Recovery and discovery-cache compatibility surfaces** (#887) — keeps
visible Deprecated `dws recovery` and `dws cache` compatibility stubs while
retiring their former recovery engine and dynamic discovery-cache behavior.
Recovery plan/execute/finalize now return an explicit “不再支持” notice, and
Skills no longer teach either retired workflow.
### Fixed
- **Mail share-to-chat confirmation** (#887) — requires explicit confirmation
before the first remote write, while preserving the confirmed sign-retry
flow and covering both direct-success and retry responses.
## [1.0.57] - 2026-08-06
This stable release promotes the fully delivered `v1.0.57-beta.4` baseline.
It includes the v1.0.57 beta-line command-contract, document, chat, OA, Wiki,
and compatibility improvements, plus the multi-skill framework alignment and
expanded calendar, Drive, Markdown, Mail, and Minutes workflows validated in
the final prerelease.
- **Promote v1.0.57-beta.4** — publishes the final validated prerelease
baseline as stable `v1.0.57` without adding post-beta product changes.
## [1.0.57-beta.3] - 2026-08-06
### Added
- **Reviewed document shortcuts** (#880) — adds public document shortcuts for
safe local downloads, content and history, review, media and style, and
document access/sharing workflows, while retaining reviewed compatibility
identities and confirmation safeguards for writes.
- **Mentions in chat replies** (#881) — `chat message reply` now supports
`--at-open-dingtalk-ids` and `--at-all`, forwarding reply mention fields and
adding any required mention placeholders without changing existing send
behavior.
## [1.0.57-beta.2] - 2026-08-05
### Fixed
- **Stable Chat command compatibility** (#876) — restores the hidden migration
entries for `chat send`, `chat history`, and their `im` aliases, preserving
the v1.0.56 command surface while directing callers to the supported
`chat message send/list` commands. Legacy flags now reach the same migration
hints instead of failing during flag parsing.
- **Drive download cancellation-test stability** (#876) — replaces a
timing-sensitive worker-cancellation coverage test with a deterministic seam,
reducing flaky CI without changing download behavior.
## [1.0.57-beta.1] - 2026-08-05
This beta starts the v1.0.57 line on top of v1.0.56. It packages the unified
command-contract and runtime Schema architecture, complete Multi IM Chat
coverage, document whiteboard and OA approval workflows, Wiki activity feeds,
and compatibility and CI reliability fixes.
### Added
- **Contact personal-status updates** (#872) — adds `contact user update-ownness`
(alias `set-ownness`) for updating a user's personal status text. The write
operation maps reviewed `userId` and `ownnessText` parameters to the service
contract and requires confirmation unless `--yes` is explicitly supplied.
- **Document whiteboard workflows** (#861) — adds `doc whiteboard insert`,
`whiteboard query/update`, and `doc media upload`. These commands support
confirmed document-embedded whiteboard creation and updates, structured
OpenNodes reads, and preparation of node-bound Vector/SVG resources.
- **Complete Multi IM Chat coverage** (#860) — hardens deterministic group and
stable-ID resolution, sending, querying, downloading, pagination, and JSON
export. The remaining reviewed Chat Shortcuts enter Schema coverage, with
destructive delete and clear operations aligned to confirmation gates.
- **OA approval form workflows** (#853) — adds OA form-schema lookup,
process forecast, and confirmed approval-instance creation, supporting both
simple flags and complete `--request` payloads.
- **Wiki activity-feed queries** (#862) — adds `wiki feed list` to retrieve
workspace document activity, with cursor paging and optional file exclusion.
### Changed
- **Unified command and Schema contract framework** (#830) — Leaf commands and
Shortcuts now use the shared typed `corecmd` base for flags, constraints,
confirmation, Help, and runtime Schema projection. Schema delivery assembles
from leaf Contract declarations at runtime; the retired hint overlays,
pinned MCP metadata, and committed Catalog artifacts are no longer delivery
authorities.
- **Faster macOS CI without reducing native coverage** (#857) — narrows the
macOS race suite to Keychain, codesign, and Darwin-only tests while adding a
reachability contract that prevents native-only tests from being silently
excluded.
### Fixed
- **Chat media-download JSON compatibility** (#854) — restores parseable
`success`, `downloadUrl`, and `output` fields for
`chat message download-media --format json` after a successful download,
without progress output corrupting JSON stdout.
### Added
- **Document-embedded whiteboard workflows** — adds `doc whiteboard insert` for confirmed creation and part-ID verification, `whiteboard query/update` for structured OpenNodes reads and confirmed writes, and `doc media upload` for preparing node-bound Vector/SVG resources. The public adapter uses an explicit helper-only whiteboard endpoint, validates update envelopes locally, decodes `resultJson`, and publishes the full command, Schema, Skill, and safety contract migrated from `dws-wukong@e2da8ab947c6`.
- **Robot image and file messages** — extends `chat message send-by-bot` with public image URL delivery through `--msg-type image --image-url` and local-file upload/send through `--msg-type file --file-path`, while preserving Markdown as the default message type.
### Changed
- **Chat reply mentions** — `dws chat message reply` can @ specified group members with `--at-open-dingtalk-ids` or @ everyone with `--at-all`, forwarding the existing `send_personal_message` mention fields and automatically adding missing current-user `<@id>` / `<@all>` placeholders.
- **Pinned MCP metadata retired** — deletes `internal/cli/schema_mcp_metadata.json` and removes its embed/loader/fallback role from Schema assembly. Catalog now assembles from Contract/ParamDecl/Interface + Cobra only; `make fetch-mcp-metadata` remains an optional diagnostic dump under `artifacts/` and refuses the retired pin path. Policy bans the pin from reappearing.
- **MCP service review retired** — deletes `schema_mcp_service_review.json` and removes its policy jq / outputguard / test disposition gate (`notify` → `out_of_surface`, snapshot hash pin). No replacement ledger.
- **Hints retired; ContractDecl is the leaf Schema source** (#830) — `schema_hints/`, Manual/Schema hint overlays, and `schema_agent_metadata/` delivery are removed. Selection, safety, parameters, and interface facts declare on ProductDecl / leaf `Contract` (`corecmd.ContractDecl` + `contract.ParamDecl` / `Safety`). Authoring renamed `SchemaDecl` → `ContractDecl`; nested fields reuse `contract.*` directly.
@@ -246,7 +16,6 @@ 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.
+3 -11
View File
@@ -68,17 +68,9 @@ 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 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.
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.
5. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may
change.
6. Run `./scripts/release/verify-package-managers.sh` when packaging or
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.58-beta.2"
version "1.0.56-beta.4"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-darwin-arm64.tar.gz"
sha256 "1b2b6953f7f1ae1ca6ecb0702424ac0e1a976a6a5ff91e8ffc3b5ae495d98c7c"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-arm64.tar.gz"
sha256 "f1f9b6394137edbd0b08d632aab34e92a0f3f81d80107a47de1bec9b384f0515"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-darwin-amd64.tar.gz"
sha256 "a1c1b3c58b48e04c0ae520062f9d6ab0dc961eddb635497bdb9b4345316e45f6"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-amd64.tar.gz"
sha256 "cd3c64d20723c420e2490405d0bf8eecfd7e2b8fc352f63f23de5847a1d38f55"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-linux-arm64.tar.gz"
sha256 "7f35e3c4734f17b125a8c32f3c95e05d1410f683cf6956be857ee9349f8e4d36"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-arm64.tar.gz"
sha256 "910918d88074534e680a2e320d3cb364ad092e96b9c422f9e75d11c9c0815dd8"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-linux-amd64.tar.gz"
sha256 "37beb9e39790563cf0584ac23376f713bf2eb2c50cff4222965e831ac9adbb0e"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-amd64.tar.gz"
sha256 "172fe0d84443be953d0c6f2c2433540e4b972fbe7776cff1417ec9c73723552b"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-skills.zip"
sha256 "7e10fead4192059c98d596c5b1886f77fd550526de5cd18c425cdad6fd64cd3a"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-skills.zip"
sha256 "a3457befe858cbf3fe85848428b630bfd3a5f626256ed6b49415267948915152"
end
def install
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCli < Formula
desc "Automate DingTalk workspace tasks from the terminal"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.57"
version "1.0.56"
license "Apache-2.0"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-arm64.tar.gz"
sha256 "c01c28dc13948a70fca905207073dc8dbd22f7ba7fc90e68b3316eb9a9c98e88"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-darwin-arm64.tar.gz"
sha256 "5c6003fe484aa36cc00820a574186652467b9d075f19c159cf807e57590256ba"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-amd64.tar.gz"
sha256 "d7baa218beefc851c6a933b456055195f8272984ce008d7e0122bdfc5dad94ea"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-darwin-amd64.tar.gz"
sha256 "969b005a10682c2a1a828fa112165b5b0cd8ceeed8d22110ef7f39402cc36804"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-arm64.tar.gz"
sha256 "0bbe9c233a3ff585077bae1ac5000937c32d967846d14cc44c46f98d49b95ae2"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-linux-arm64.tar.gz"
sha256 "530c5ea7ddc7de320d9c2471fbd33752a723d00c9665f49321c7580e8392c756"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-amd64.tar.gz"
sha256 "f113ce3654f21d1f9ecc7c196f815aeafbca54d377a347b244a15116c5cba698"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-linux-amd64.tar.gz"
sha256 "675fa42727ac9a549c6710b82e1980cd0f795363d71d5116a4e69771b7c5470e"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-skills.zip"
sha256 "0c9667209cf30761427a8f9348149cbbf1e397aa3c25587e99f205bc7525e101"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-skills.zip"
sha256 "3d57794e4660a089209ce3962571d16ca0d46141e973c9993257a301cce0e097"
end
def install
+10 -48
View File
@@ -10,7 +10,7 @@ SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
all: setup-hooks fmt lint build test rebuild
@@ -24,16 +24,15 @@ 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 [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 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 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 - 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 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 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"
@printf " make cli-smoke - Verify help for every public top-level command\n"
@printf " make mock-mcp-smoke - Verify HTTP and stdio MCP request/response transport\n"
@printf " make test-schema-agent-examples - Contract-check all Agent examples and dry-run the eligible subset\n"
@@ -88,8 +87,6 @@ policy: test-auth-legacy-compat
@mkdir -p "$(POLICY_GOTMPDIR)"
@$(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
@@ -103,22 +100,10 @@ edition-test:
$(GO) test -v -count=1 ./pkg/editiontest/...
interface-integrity:
@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"
@./scripts/policy/check-interface-baseline.sh
authoritative-interface-integrity:
@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"
@./scripts/policy/check-authoritative-interface-baselines.sh --base-ref "$(BASE_REF)"
coverage-gate:
@./scripts/policy/check-coverage-gate.sh --base-ref "$(BASE_REF)" --scope-buildable
@@ -133,12 +118,7 @@ reset-interface-baseline:
@./scripts/policy/check-interface-baseline.sh --reset
schema-compatibility:
@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"
@./scripts/policy/check-authoritative-schema-compatibility.sh --base-ref "$(BASE_REF)"
skill-command-integrity:
@./scripts/policy/check-skill-commands.sh
@@ -146,12 +126,6 @@ skill-command-integrity:
skill-context-budget:
@./scripts/policy/check-skill-context-budget.sh
multi-im-skill-chain-integrity:
@./scripts/policy/check-multi-im-skill-chain.sh
skill-mono-multi-content:
@./scripts/policy/check-mono-multi-skill-content.sh
cli-smoke:
@./scripts/policy/check-cli-smoke.sh
@@ -169,13 +143,9 @@ generate-schema:
@set -e; \
concepts_guard=$$(mktemp); \
concepts_schema_guard=$$(mktemp); \
command_fallbacks_guard=$$(mktemp); \
command_fallbacks_schema_guard=$$(mktemp); \
trap 'rm -f "$$concepts_guard" "$$concepts_schema_guard" "$$command_fallbacks_guard" "$$command_fallbacks_schema_guard"' EXIT HUP INT TERM; \
trap 'rm -rf "$$concepts_guard" "$$concepts_schema_guard"' EXIT HUP INT TERM; \
cp internal/cli/param_concepts.json "$$concepts_guard"; \
cp internal/cli/param_concepts.schema.json "$$concepts_schema_guard"; \
cp internal/cli/command_path_fallbacks.json "$$command_fallbacks_guard"; \
cp internal/cli/command_path_fallbacks.schema.json "$$command_fallbacks_schema_guard"; \
$(GO) generate ./internal/cli; \
rm -rf internal/cli/schema_agent_metadata internal/cli/schema_agent_metadata_audit.json; \
rm -f internal/cli/schema_meta_index.json; \
@@ -191,14 +161,6 @@ generate-schema:
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2; \
exit 1; \
}; \
cmp -s internal/cli/command_path_fallbacks.json "$$command_fallbacks_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.json' >&2; \
exit 1; \
}; \
cmp -s internal/cli/command_path_fallbacks.schema.json "$$command_fallbacks_schema_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.schema.json' >&2; \
exit 1; \
}; \
if [ -e internal/cli/schema_hints ]; then \
printf '%s\n' 'retired schema_hints/ must not reappear after generation' >&2; \
exit 1; \
+28 -35
View File
@@ -71,7 +71,9 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
| Mode | What gets installed | Best for |
|------|----------------------|----------|
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
| **multi** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
| **multi** 🧪 **EXPERIMENTAL** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** All product-scoped skills pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
How to pick:
@@ -369,7 +371,7 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
Use Cobra help and Schema for different parts of the command contract:
- `dws <path> --help` is the source of truth for whether a command exists and which flags the binary accepts.
- `dws schema "<path>" --compact` is the normative Agent view for command selection, CLI parameters and constraints, risk, and confirmation; use a full leaf with a narrow `--jq` projection for mapping or provenance audits.
- `dws schema "<path>"` is the Agent contract for command selection, parameter mappings and constraints, risk, and confirmation semantics.
- If Help and Schema disagree, treat it as contract drift: pass only flags accepted by Cobra and use the more conservative safety semantics.
- Schema describes commands; it does not read or search DingTalk business data. Execute the real product command after discovery.
@@ -378,21 +380,21 @@ Use Cobra help and Schema for different parts of the command contract:
dws aitable record query --help
# Discover within a product, then inspect the selected leaf contract
dws schema aitable --compact
dws schema "aitable record query" --compact
dws schema aitable
dws schema "aitable record query"
# Execute the real business query
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
```
`dws schema --all` exports the complete contract for tooling, CI, audits, and compatibility baselines. Agents should query progressively with `--compact`; its positive field allowlist prevents new full/audit fields from silently expanding Agent context.
`dws schema --all` exports the complete contract for tooling, CI, audits, and compatibility baselines. Agents should prefer product/group discovery followed by a leaf query to avoid loading the full Catalog into context.
### Agent Skills
The repo ships a complete Agent Skill system under `skills/`, organized into two layouts:
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`.
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
Leaf safety/parameters/selection prose for Schema generation come from ProductDecl / ContractFinal declarations in Go. The former `internal/cli/schema_hints/` HintFile tree is fully retired and must not reappear.
@@ -441,6 +443,7 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
| Intent guide | `skills/mono/references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) |
| Global reference | `skills/mono/references/global-reference.md` | Auth, output formats, global flags |
| Error codes | `skills/mono/references/error-codes.md` | Error codes + debugging workflows |
| Recovery guide | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling |
| Ready-made scripts | `skills/mono/scripts/*.py` | 13 batch operation scripts (see below) |
<details>
@@ -471,7 +474,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, group lifecycle events, and six OA approval task/instance 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, and group title/disband lifecycle 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,33 +484,28 @@ 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 +listen-im --kind at-me -f ndjson
dws event consume user_im_message_receive_at --flatten -f ndjson
# Listen for messages from a specified sender
dws event +listen-im --kind sender --user <userId> -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 by openDingtalkId (external contact, bot, or cross-organization identity)
dws event +listen-im --kind sender --open-dingtalk-id <openDingtalkId> -f ndjson
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> --flatten -f ndjson
# Listen for messages in a specified group
dws event +listen-im --kind group --chat-id <openConversationId> -f ndjson
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
# Listen for all one-to-one or all group messages
dws event +listen-im --kind all-direct -f ndjson
dws event +listen-im --kind all-group -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
# 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
@@ -515,19 +513,14 @@ 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 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
# Listen for multiple events for the same user in one process
dws event consume \
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
user_im_message_receive_o2o \
user_im_message_read_o2o \
user_im_message_recall_o2o \
--user <userId> \
--flatten \
-f ndjson
# Inspect local consumers and cancel a subscription
dws event status
@@ -631,7 +624,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
```bash
# Built-in jq expressions
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
dws schema "dev app create" --jq '.parameters'
dws schema "dev app create" --jq '.tool.required'
# Return only specific fields
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
@@ -643,9 +636,9 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
<summary><strong>Schema Introspection</strong> — Agent command discovery and execution contracts</summary>
```bash
dws schema aitable --compact # discover product commands
dws schema "aitable record query" --compact # view the selected Agent leaf contract
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # view required fields
dws schema aitable # discover product commands
dws schema "aitable record query" # view the selected leaf contract
dws schema "aitable record query" --jq '.tool.required' # view required fields
dws schema --all # full export for CI/audit/baselines
```
+28 -35
View File
@@ -71,7 +71,9 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
| 模式 | 安装内容 | 适合场景 |
|------|----------|----------|
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
| **multi** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
| **multi** 🧪 **试验版 / Preview** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。全部独立 skill 均通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
怎么选:
@@ -363,7 +365,7 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
命令帮助和 Schema 分别负责命令契约的不同部分:
- `dws <path> --help` 是命令是否存在、当前二进制接受哪些 flags 的事实源。
- `dws schema "<path>" --compact` 是 Agent 选命令、CLI 参数与约束、风险和确认语义的规范视图;映射或 provenance 审计使用 full leaf 配合 `--jq` 精确投影。
- `dws schema "<path>"` 是 Agent 选命令、参数映射与约束、风险和确认语义的契约。
- Help 与 Schema 冲突时视为契约漂移:执行只传 Cobra 接受的参数,安全语义取更保守值。
- Schema 只描述命令,不读取或搜索钉钉业务数据;发现命令后仍需执行真实产品命令。
@@ -372,21 +374,21 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
dws aitable record query --help
# 先在产品内发现命令,再查看选中 leaf 的契约
dws schema aitable --compact
dws schema "aitable record query" --compact
dws schema aitable
dws schema "aitable record query"
# 执行真实业务查询
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
```
`dws schema --all` 会完整导出命令契约,供工具、CI、审计和兼容性基线使用。Agent 应使用 `--compact` 渐进查询;该视图采用正向字段白名单,full 新增的审计字段不会自动进入 Agent 上下文。
`dws schema --all` 会完整导出命令契约,供工具、CI、审计和兼容性基线使用。Agent 应优先按产品/分组发现后查询 leaf,避免把整个 Catalog 加载进上下文。
### Agent Skills
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / ContractFinal 声明驱动。原 `internal/cli/schema_hints/` HintFile 目录已完全退役,不得重新引入。
@@ -435,6 +437,7 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
| 意图指南 | `skills/mono/references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
| 全局参考 | `skills/mono/references/global-reference.md` | 认证、输出格式、全局 flag |
| 错误码 | `skills/mono/references/error-codes.md` | 错误码 + 调试流程 |
| Recovery 指南 | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 |
| 现成脚本 | `skills/mono/scripts/*.py` | 13 个批量操作脚本(见下方) |
<details>
@@ -465,7 +468,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。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及六个 OA 审批任务/实例事件。
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应,以及群标题变更和群解散事件。
默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。
@@ -475,33 +478,28 @@ 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 +listen-im --kind at-me -f ndjson
dws event consume user_im_message_receive_at --flatten -f ndjson
# 监听指定发送人的消息
dws event +listen-im --kind sender --user <userId> -f ndjson
# 监听与指定用户的单聊消息
dws event consume user_im_message_receive_o2o --user <userId> --flatten -f ndjson
# 使用 openDingtalkId 监听外部联系人、机器人或跨组织身份
dws event +listen-im --kind sender --open-dingtalk-id <openDingtalkId> -f ndjson
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> --flatten -f ndjson
# 监听指定群的消息
dws event +listen-im --kind group --chat-id <openConversationId> -f ndjson
dws event consume user_im_message_receive_group --group <openConversationId> --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_message_receive_o2o_all --flatten -f ndjson
dws event consume user_im_message_receive_group_all --flatten -f ndjson
# 监听指定群标题变更、成员进退群或群解散
dws event consume user_im_group_updated --group <openConversationId> --flatten -f ndjson
@@ -509,19 +507,14 @@ 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_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
user_im_message_receive_o2o \
user_im_message_read_o2o \
user_im_message_recall_o2o \
--user <userId> \
--flatten \
-f ndjson
# 查看本地 consume,并取消指定订阅
dws event status
@@ -625,7 +618,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
```bash
# 内置 jq 表达式
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
dws schema "dev app create" --jq '.parameters'
dws schema "dev app create" --jq '.tool.required'
# 只返回指定字段
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
@@ -637,9 +630,9 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
<summary><strong>Schema 自省</strong> — Agent 命令发现与执行契约</summary>
```bash
dws schema aitable --compact # 发现产品命令
dws schema "aitable record query" --compact # 查看 Agent leaf 契约
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # 定向查看必填字段
dws schema aitable # 发现产品命令
dws schema "aitable record query" # 查看选中 leaf 契约
dws schema "aitable record query" --jq '.tool.required' # 查看必填字段
dws schema --all # CI/审计/基线的全量导出
```
+2 -91
View File
@@ -17,23 +17,18 @@
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))
}
@@ -117,11 +112,7 @@ func runGenerate(args []string, stdout, stderr io.Writer) error {
defer i18n.SetLang(previousLang)
i18n.SetLang("en")
root := newRootCommand()
snapshot := interfacesnapshot.Capture(root)
if err := validateHelpRendering(root, snapshot); err != nil {
return err
}
snapshot := interfacesnapshot.Capture(app.NewRootCommand())
if *output == "-" {
return interfacesnapshot.Write(stdout, snapshot)
}
@@ -147,16 +138,6 @@ 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
}
@@ -169,14 +150,6 @@ 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 {
@@ -197,25 +170,6 @@ 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("", " ")
@@ -225,49 +179,6 @@ 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 {
@@ -280,5 +191,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] [--approved-flag-migrations FILE --candidate-flag-migrations FILE]")
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE]")
}
-447
View File
@@ -15,16 +15,11 @@ 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) {
@@ -61,24 +56,6 @@ 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")
@@ -106,368 +83,6 @@ 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 {
@@ -505,68 +120,6 @@ 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 {
+1
View File
@@ -40,6 +40,7 @@
- `internal/output`: response formatting (json, table, raw, pretty)
- `internal/logging`: structured logging and argument sanitization
- `internal/tui`: terminal UI helpers
- `internal/recovery`: panic recovery and graceful degradation
- `pkg/configmeta`: environment variable registry and documentation
- `pkg/config`: configuration constants and paths
- `pkg/edition`: edition detection (oss vs enterprise)
+1 -1
View File
@@ -43,7 +43,7 @@ repository root while preserving repo-local guidance for automation.
- Error message or category issues: inspect `internal/errors`
- Audit log issues: inspect `internal/audit`
- Plugin loading or command surface: inspect `internal/plugin`
- Failure or degraded mode: inspect `internal/errors`
- Failure or degraded mode: inspect `internal/errors`, `internal/recovery`
## Policy Checks
+8 -39
View File
@@ -157,33 +157,15 @@ expected to repeat every CI job locally:
```sh
make build
make policy
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 interface-integrity
make authoritative-interface-integrity BASE_REF=<merge-base>
make schema-compatibility BASE_REF=<merge-base>
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
@@ -204,24 +186,11 @@ 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.
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。
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.
## Required GitHub repository settings
-176
View File
@@ -1,176 +0,0 @@
# 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。
@@ -0,0 +1,368 @@
# 声明式命令框架迁移指南
- **受众**:把 helpers 叶子 / Shortcut 从 Tier3 / 遗留 annotate 迁到声明式框架的工程师
- **规范权威**(本文不重复政策全文,只给可执行路径):
- [`rfc-command-framework-convergence.md`](rfc-command-framework-convergence.md) **§5.0**(声明定义、三档路径、Schema 字段权威)
- [`flag-help-schema-homology.md`](flag-help-schema-homology.md)(路径 A:Contract 嵌入 Schema)
- 仓库根 [`AGENTS.md`](../AGENTS.md)(Authoring tiers、Agent Schema contract、curation)
- **架构速览**:[`command-framework-architecture.md`](command-framework-architecture.md)
- **实现基线**:PR #830 起 `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` → `corecmd.New`;Schema 经 `RegisterSchemaSourceRoot` → `ResolveSchemaBuild` 运行时组装(**声明即 Catalog**)
---
## 1. Why / 终态
目标不是「再写一份 Catalog JSON」,而是 **一份声明同时驱动**:
1. Cobra 可执行表面(flags / required / constraints / help)
2. 运行时确认门(`confirmation=user_required` → `ConfirmSafety`)
3. Agent Schema(`ToolSpec` / `ResolveMeta`)
**单向数据流(摘要)**:
```text
leaf Safety + Contract / ParamDecl / ProductDecl
→ ContractFinal(corecmd.New 或 AttachContract / DeclareLeafMetadata)
→ CollectIdentitySpecs(活 Cobra 叶上的 Identity)
→ ResolveSchemaBuild → SchemaRegistry
→ dws schema / ResolveMeta(同一组装结果)
```
硬约束:
| 要做 | 不要做 |
|---|---|
| 在叶子旁声明 `Safety` / `Contract` / `ParamDecl` | 提交 `schema_catalog/`、`schema_meta_index.*`、`schema_agent_metadata/`、`schema_hints/`、`schema_mcp_metadata.json` |
| 用 `make generate-schema` 刷新 param aliases + 证明组装确定性 | 把 Catalog dump 当交付权威或手工改 wire |
| help/Schema 事实 **declare OR annotate** | 纯推断、或钩子闭包里「顺带」发明表面 |
| `Confirmation` 单独驱动运行时门 | 从 `effect`/`risk` 机械推导 `confirmation` |
`cmd_schema_catalog` 仅供 CI/local dump;生产路径没有 committed Catalog pin。
---
## 2. Authoring tiers(何时用哪一档)
同一 `ContractFinal` 语义;三档不是互相否定。详见 RFC §5.0.2a。
| 档 | 入口 | 声明什么 | 执行面 | 何时用 |
|---|---|---|---|---|
| **Tier1** | `corecmd.New` / `helpers.NewLeafCommand(LeafSpec)` | `Flags` / `Constraints` / `Safety` / `ConstParams` / `Contract` 全进 Spec | 框架注册 flag、投影参数、`ConfirmSafety`、派发 | **新命令默认**;执行面可迁入 LeafSpec/`Call` |
| **Tier2** | `helpers.DeclareLeafMetadata(cmd, LeafSpec)` | 仅 `Safety` + `Contract`(可选 `Validate`) | **不**注册 flag;确认挂 RunE 包装器 | helpers / Shortcut 迁移态:先补 Agent Schema,执行面暂时手写 `RunE`/`Execute` |
| **Tier3** | 裸 `*cobra.Command` | 无框架声明(或仅 annotate / 精确排除) | 调用方自管 | 应收窄;新增裸叶须迁元数据或进 `schema_command_exclusions.go` |
选用规则(今日):
1. 新命令 → **Tier1**。
2. 既有叶子要进 Schema、但 `RunE`/`Execute` 还不想重写 → **Tier2**(声明写在命令字面量旁)。
3. **Shortcut + `DeclareLeafMetadata` 合法**;多数 Shortcut 已走 `FromShortcut` → `corecmd.New`(Tier1 表面 + 自有 `Execute`)。
4. Tier2 → Tier1 与 Shortcut → mcpbind 是后续里程碑,**不是**本阶段硬门槛;不得用 Tier2 绕开「业务 flag 必须声明」的纪律(半接管字段会 panic)。
`DeclareLeafMetadata` 禁止传入:`Flags` / `Constraints` / `ConstParams` / `Call` / `RunE` / `PostMount` / `ConfirmFirst` / `Server`/`Tool`;**唯一允许的执行钩子是 `Validate`**。
---
## 3. What to declare(声明清单)
### 3.1 框架声明面(数据字段才算声明)
| 字段 | 作用 | Schema / 运行时 |
|---|---|---|
| `Flags`(Tier1) | kebab-case flag、Kind、Default、Required、`Bind`、Aliases… | parameters;`Bind` → property |
| `Constraints`(Tier1) | `at_least_one` / `exactly_one` / `mutually_exclusive` / custom | constraints 段 + 运行时校验 |
| `Safety`(`contract.SafetySpec`) | `effect` / `risk` / `confirmation` / `idempotency` **四字段齐全** | ToolSpec Safety;仅 `confirmation` 驱动确认门 |
| `ConstParams`(Tier1) | 固定 toolArgs,不上用户 flag 表 | 载荷;非 parameters |
| `Contract`(`corecmd.ContractDecl`) | Identity / Description / Selection / Parameters / Interface / DryRun | ContractFinal → Catalog |
| `ProductDecl` | 产品级 routing(`contract.RegisterProductDecl`) | 产品 overview selection |
钩子(`Validate` / `Call` / `Invoke` / `Orchestrate` / `RunE` / `Execute` / `PostMount`)**不算**声明——行为正确也不能替代 Schema 事实。
### 3.2 Contract 必填要点
- `Identity`:`ProductID` / `Name` / `CanonicalPath` / `CLIPath` / `PrimaryCLIPath`(与活 Cobra 路径一致;collector 以此为唯一 identity 源)
- `Description`:构造期必填(声明证据);Catalog **交付**可优先 Cobra Long(provenance `cobra_help`)
- `Selection`:决策向 `AgentSummary` / `UseWhen` / `AvoidWhen` / `Examples`(勿复述 Short;**禁止** `Reviewed` 字段)
- `Parameters`(`[]contract.ParamDecl`):CLI flag 名 → RPC `Property`;`interface_type` 等接口事实写这里,不写 MCP pin
- `Interface`:`mcp` / `composite` / `local` + `Ref` 或 `Reason`
### 3.3 ProductDecl
在产品根命令构造处注册一次,例如 `internal/helpers/drive.go` 的 `newDriveCommand`:
```go
contract.RegisterProductDecl(contract.ProductDecl{
ID: "drive",
Selection: contract.ProductSelectionDecl{
AgentSummary: "…",
UseWhen: []string{"…"},
AvoidWhen: []string{"…"},
},
})
```
---
## 4. 分步迁移清单
### 4.1 典型 helpers 叶子(裸 Cobra → Tier2)
1. **确认 CLI 路径**:`dws <path> --help`,记下 flag 名与 required 组。
2. **选定 Identity**:`product_id` / `name` / `canonical_path` / `cli_path` 与兄弟命令不冲突;aliases 若有则写进 Identity(与 collected registry 一致)。
3. **声明 Safety**:写操作填完整四字段;删除/破坏性用 `confirmation=user_required`(见 §5)。
4. **声明 Contract**:`Description` + `Selection` + `Interface`;flag→RPC 用 `Parameters: []ParamDecl{{Name, Property}}`。
5. **挂元数据**:在 flag 注册与 `RunE` 之后调用:
```go
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{ /* 四字段 */ },
Contract: LeafContract{ /* Identity / Selection / … */ },
// 本地副作用且 user_required:补 Validate,避免确认抢先或 fail-closed
})
```
6. **Property 缺口**:CLI 有、RPC 无的 selector → `schema_parameter_mapping_ledger.go` 的 `mapping_exclusions`(精确键 + 非空 reason),不要造 binding JSON。
7. **产品路由**:若产品尚无 `ProductDecl`,在产品根补注册。
8. **验证**(§7)后再开 PR。
参考:`internal/helpers/drive.go`(`DeclareLeafMetadata` + `RegisterProductDecl`)。
### 4.2 典型 Shortcut(补 Safety + Contract)
1. Shortcut 已由 `FromShortcut` → `corecmd.New` 接管 flag/约束/确认;迁移焦点是 **显式 `Safety` + `Contract`**,不要再依赖仅 `Risk` 推断。
2. 在 `shortcut.Shortcut{…}` 字面量上增加:
```go
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: corecmd.ContractDecl{
Identity: contract.ToolIdentitySpec{ /* … */ },
Description: "…",
Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "…"},
Selection: contract.SelectionSpec{
AgentSummary: "…",
UseWhen: []string{"…"},
AvoidWhen: []string{"…"},
Examples: []string{"dws chat +conversation-info --group <openConversationId>"},
},
},
```
3. `Flags` / `Constraints` / `Validate` / `Execute` 保持 Shortcut 词汇;adapter 映射到 `corecmd.Spec`。
4. Examples 用真实可执行路径与 flag;**不要**写 `--yes`。
5. 验证(§7)。
参考:`internal/shortcut/chat/chat_conversation.go`(`ConversationInfo`);adapter:`internal/shortcut/adapter.go`。
### 4.3 新命令 / 可重写执行面 → Tier1
优先 `NewLeafCommand(LeafSpec{ Flags, Safety, Contract, Call/… })`,例如 `internal/helpers/devapp.go`。业务 flag 只进 `Flags`,禁止在 `PostMount`/`Validate` 里 `Flags().String` 注册业务面。
---
## 5. Safety / confirmation
### 5.1 声明完整 SafetySpec
```go
Safety: contract.SafetySpec{
Effect: "destructive", // read | write | destructive
Risk: "high", // low | medium | high
Confirmation: "user_required", // not_required | user_required
Idempotency: "unknown", // idempotent | retryable | non_idempotent | unknown
},
```
非空则四字段必须齐全(构造期校验)。字段独立:`effect=destructive` **不会**自动变成 `user_required`。
### 5.2 迁离 AnnotateRuntimeRisk / AnnotateRuntimeGate
| 旧路径 | 新路径 |
|---|---|
| `AnnotateRuntimeRisk` / 字符串 `dws.schema.risk` | 声明 `Safety.Risk`(经 ContractFinal) |
| `AnnotateRuntimeGate` / `runtime_gate`(如 `devAppRequireWriteGuard`) | 声明 `Safety.Confirmation=user_required`;框架 `ConfirmSafety` |
| 仅 Shortcut `Risk=write` 隐式确认 | 显式 `Safety`(覆盖 Risk 展开) |
**禁止新增**生产 `AnnotateRuntimeRisk` / `AnnotateRuntimeGate` 调用点;存量 annotate 可保留至迁完(`HOM-S2`)。同源门禁:`confirmation=user_required` ↔ 运行时 gate(见 `check-runtime-confirmation-truth.sh`、`internal/cli/homology`)。
### 5.3 Tier2 确认时机
- 有 `Validate`:`Validate` → `ConfirmSafety` → 原 `RunE`
- 无 `Validate`:确认推迟到首次 `CallTool`;无 Caller 的本地副作用叶必须补 `Validate`,否则 fail-closed
执行前需确认时,用 `--yes` 跳过交互;**Schema examples 永不包含 `--yes`**。
---
## 6. Parameters / mapping
1. **主权威**:Tier1 用 `FlagSpec.Bind`(空则 Name);Tier2 / 混合路径用 `contract.ParamDecl{Name, Property}`。
2. **mapping ledger**(`internal/cli/schema_parameter_mapping_ledger.go`):只放 `mapping_exclusions` / removals——CLI flag **无**直接 RPC property 时的精确评审排除;非空 reason。
3. **不要**:提交 MCP pin(`schema_mcp_metadata.json` 已退役);不要指望 live MCP 创建 CLI flag;不要用 hints overlay 改 `type`/`required`/`default`。
4. **required 地板**:Cobra `MarkFlagRequired` 不得被低优先级源降为 optional。
5. 接口事实(`interface_ref` / `interface_type`)声明在 leaf `Contract.Interface` / `ParamDecl`;CLI path ≠ MCP path 时用 `Interface.Ref`(例:`drive delete` → `doc.delete_document`)。
---
## 7. Schema 验证
迁移后至少跑:
```bash
make generate-schema
./scripts/policy/check-schema-catalog.sh
./scripts/policy/check-runtime-confirmation-truth.sh # 若动了 confirmation / gate
# 聚焦:
go test ./internal/app -run 'TestSheetFinalSchemaConfirmationMatchesRuntimeGuards|TestFinalSchemaParametersMatchExecutableHelpFlags' -count=1
```
手工抽查:
```bash
dws schema --cli-path "drive mkdir" -f json
# 或 ResolveMeta 同源:help Safety 行与 Schema confirmation 一致
dws drive mkdir --help
```
Examples 规则(组装/门禁):
- 每 tool 最多两条;路径与 flag 必须是活 Cobra 可接受的
- **禁止** `--yes`;禁止 shell 注释
- 缺必填 / 约束失败 = 契约 bug,不是「跳过 example」的理由
可选:`make test-schema-agent-examples`(合同 + 显式 dry-run 能力子集)。
---
## 8. Pitfalls(常见坑)
1. **Schema source root = declarationOnly**
`app.NewSchemaSourceRootCommand` 以 `declarationOnly=true` 建树:**跳过** `helpers.InitDeps` / `injectStaticServers`,避免组装时清掉进程里的 ToolCaller / plugin endpoint。叶子构造与 RunE 包装必须对 `deps == nil` 安全(见 `printDocDeprecationWarning` 注释,`internal/helpers/doc.go`)。
2. **DryRun `RemoteReads`**
声明 `Contract.DryRun` / `DryRunSpec` 时,`RemoteReads: false` 表示预览计划不发起远程读;勿把「有 dry-run 能力」与「会打后端」混为一谈。无 reviewed dry-run 能力时,example 门禁不会魔法升级为 runtime dry-run。
3. **不要复活退役目录 / pin**
`schema_hints/`、`schema_agent_metadata/`、`schema_command_registry/`、committed `schema_catalog/`、`schema_mcp_metadata.json` —— 出现即 policy 失败。
4. **Tier2 半接管**
往 `DeclareLeafMetadata` 塞 `Flags`/`Call`/`RunE` 会 panic;要框架管 flag → 升 Tier1。
5. **Identity 漂移**
`Contract.Identity` 必须与活路径一致;不一致组装失败。排除项用 `schema_command_exclusions.go` 精确路径 + reason,禁止前缀通配。
6. **Selection 里塞 `Reviewed`**
旧 hints 标记;声明载荷携带即组装报错。
7. **在钩子里发明 flag/property**
Schema 看不到;同源门禁(`HOM-P*` / `HOM-D1`)会打回来。
---
## 9. Worked mini-example(前后对照)
### 9.1 helpers:裸叶 → Tier2(模式摘自 `drive mkdir` / `drive delete`)
**Before(概念)**:手写 `cobra.Command` + `RunE` + `Flags().String`,无 `Safety`/`Contract` → Agent Schema 缺叶子或靠已退役 hints。
**After(Tier2,缩写)**:
```go
driveMkdirCmd := &cobra.Command{
Use: "mkdir",
Short: "创建文件夹",
RunE: /* 既有 callMCPTool("create_folder", …) */,
}
driveMkdirCmd.Flags().String("name", "", "…")
driveMkdirCmd.Flags().String("folder", "", "…")
DeclareLeafMetadata(driveMkdirCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "drive", Name: "create_folder",
CanonicalPath: "drive.create_folder",
CLIPath: "drive mkdir", PrimaryCLIPath: "drive mkdir",
},
Description: "创建文件夹",
Interface: &contract.InterfaceSpec{
Mode: "mcp", Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "drive", RPCName: "create_folder"},
},
Selection: contract.SelectionSpec{
AgentSummary: "创建文件夹",
UseWhen: []string{"用户要在钉盘下新建普通文件夹时"},
AvoidWhen: []string{"知识库内建文件夹改用 dws wiki node create …"},
Examples: []string{`dws drive mkdir --name "项目资料" --format json`},
},
Parameters: []contract.ParamDecl{
{Name: "folder", Property: "parentId"},
},
},
})
```
破坏性写示例(`drive delete`):`Confirmation: "user_required"`,`Effect: "destructive"`,`Interface.Ref` 可指向 `doc`/`delete_document`。
路径:`internal/helpers/drive.go`。
### 9.2 Tier1 LeafSpec(`dev app event list` 缩写)
```go
return NewLeafCommand(LeafSpec{
Use: "list", Short: "查询应用已订阅的事件列表",
Tool: devAppEventListTool,
Safety: /* read / low / not_required / idempotent */,
Flags: []LeafFlag{
{Name: "unified-app-id", Bind: "unifiedAppId", Required: true, Trim: true},
{Name: "keyword", Bind: "keyword", OmitEmpty: true, Trim: true},
},
Contract: LeafContract{ /* Identity + Selection + Interface */ },
Call: devAppCallCursor(runner),
})
```
路径:`internal/helpers/devapp.go`。
### 9.3 Shortcut(`chat +conversation-info`)
见 §4.2;完整字面量:`internal/shortcut/chat/chat_conversation.go`。`FromShortcut` 把 `Flags`/`Constraints`/`Safety`/`Contract` 编进 `corecmd.Spec`(`internal/shortcut/adapter.go`)。
---
## 10. Done criteria(完成标准)
迁移 PR 合并前,下列应满足:
| 检查项 | 证据 |
|---|---|
| 叶子有完整 `Safety`(或存量 `runtime_gate` 未扩大) | `dws schema --cli-path "…" -f json` 四字段与声明一致 |
| `user_required` ↔ 运行时确认 | `./scripts/policy/check-runtime-confirmation-truth.sh`;相关 `HOM-S1`/`HOM-S2` |
| Identity / 路径可执行且进 Schema(或精确 exclusion) | `CollectIdentitySpecs` / reverse-completeness;无前缀排除 |
| parameters ⊆ help flags;property 有声明或 mapping exclusion | `check-schema-catalog.sh`;`HOM-P1`/`HOM-D1` |
| 无退役 pin / hints / agent_metadata / committed catalog | policy 脚本;`git status` 无这些路径 |
| `make generate-schema` 通过(aliases + 组装确定性) | CI / 本地 |
| Examples 可执行、无 `--yes` | 组装 example 门禁;必要时 `make test-schema-agent-examples` |
| gofmt;未无关改动 | PR diff |
长期可选(非本阶段硬门槛):Tier2 → Tier1;Shortcut `Execute` 中「只为装配参数」的函数体收敛到 mcpbind 形态 1/2(RFC §3.5 / §5.0.2a.5)。
---
## 附录:相关文件速查
| 主题 | 路径 |
|---|---|
| Leaf Tier1/Tier2 API | `internal/helpers/leaf.go` |
| Leaf → corecmd | `internal/helpers` `FromLeafSpec`;`internal/corecmd` |
| Shortcut → corecmd | `internal/shortcut/adapter.go` |
| Safety / Contract DTO | `internal/corecmd/contract` |
| ContractFinal 注册 | `internal/corecmd/contractfinal` |
| Schema 组装入口 | `internal/cli`:`RegisterSchemaSourceRoot`、`ResolveSchemaBuild`、`ResolveMeta` |
| 精确排除 | `internal/cli/schema_command_exclusions.go` |
| mapping exclusions | `internal/cli/schema_parameter_mapping_ledger.go` |
| declaration-only 根 | `internal/app.NewSchemaSourceRootCommand` |
+1 -1
View File
@@ -1,7 +1,7 @@
# dws dev 命令集 · Agent 人肉手工评测集(10 条复合用例)
> 性质:**人肉手工评测集**——由测评人逐条手工跑、肉眼核对、人工判分,不是自动化脚本。
> 用途:评测 agent(加载 `dingtalk-misc` 的 `references/devapp.md` 后)能否正确处理开放平台 dev 任务。
> 用途:评测 agent(加载 `dingtalk-dev` 技能后)能否正确处理开放平台 dev 任务。
> 特点:10 条**复合用例**,每条串多个子任务,一条覆盖一类完整场景;10 条合起来覆盖全部 34 个子命令 + 8 类横切行为。
> 约定:所有命令应带 `--format json`;写操作应先 `--dry-run` 预览、用户确认后再 `--yes`;应用定位只用 `--unified-app-id`。
+3 -3
View File
@@ -6,7 +6,7 @@
## 一键安装
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-misc` skill(开放平台应用文档落在 misc),**只需要 curl + tar,不需要 git / go / make**。
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-dev` skill,**只需要 curl + tar,不需要 git / go / make**。
### macOS / Linux
@@ -24,7 +24,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
1. 从 `DingTalk-Real-AI/dingtalk-workspace-cli` 的最新 Release 下载对应平台的预编译二进制。
2. 安装 `dws` 到默认目录 `~/.local/bin`。
3. 从 Release 的 skills 包里安装 `dingtalk-misc` skill 到本机已检测到的 Agent 目录。
3. 从 Release 的 skills 包里安装 `dingtalk-dev` skill 到本机已检测到的 Agent 目录。
支持这些环境变量(全部可选):
@@ -33,7 +33,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
| `DEVAPP_REPO` | 覆盖发布仓库,默认 `DingTalk-Real-AI/dingtalk-workspace-cli` |
| `DEVAPP_VERSION` | 钉某个 release tag,默认取最新 release |
| `DWS_INSTALL_DIR` | 二进制安装目录,默认 `~/.local/bin` |
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-misc` skill 安装 |
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-dev` skill 安装 |
> `dws dev` 已在正式版里,所以你也可以直接用标准安装脚本 `install.sh`,二者都会带上 `dws dev`。
File diff suppressed because it is too large Load Diff
+10 -11
View File
@@ -94,7 +94,7 @@ With `-f json`, error responses include structured payloads: `category`, `reason
dws contact user search --query "Alice" -f table # Table (default, human-friendly / 表格,默认)
dws contact user search --query "Alice" -f json # JSON (for agents and piping / 适合 agent)
dws contact user search --query "Alice" -f raw # Raw API response / 原始响应
dws schema -f pretty "calendar event create" --compact # Pretty Agent schema view / Agent Schema 彩色查看
dws schema -f pretty "calendar event create" # Pretty Agent schema view / Agent Schema 彩色查看
```
## Dry Run / 试运行
@@ -119,28 +119,27 @@ Schema 的稳定 `canonical_path`、主 CLI 路径和 aliases 收集自命令树
```bash
dws schema # 当前公开产品面的紧凑概览
dws schema calendar --compact # Agent 产品视图
dws schema "calendar event" --compact # Agent 分组视图
dws schema "calendar event create" --compact # Agent leaf(CLI 空格路径)
dws schema calendar.create_calendar_event --compact # Agent leaf(canonical path)
dws schema --cli-path "calendar event create" --compact # Agent leaf(显式 CLI path)
dws schema "calendar event create" # full leaf,仅用于映射/provenance 审计
dws schema calendar # 展开一个产品
dws schema "calendar event" # 展开一个命令分组
dws schema "calendar event create" # 按 CLI 空格路径查询工具
dws schema calendar.create_calendar_event # 按 canonical path 查询工具
dws schema --cli-path "calendar event create" # 显式 CLI path
dws schema "calendar event create" --compact # 支持:省略 provenance/debug 字段
dws schema --all # 全部工具的完整 leaf Schema,用于审计/CI/baseline
```
兼容入口 `dws schema list` 等价于根概览。`schema --all` 是完整导出:每个工具都包含完整 leaf 参数、约束和安全语义。它输出很大,只用于明确要求的全量导出、审计、CI 或参数 baseline;普通 Agent 任务应按概览、产品/分组、leaf 渐进查询,不要把 `--all` 直接注入上下文。`schema --all --compact` 虽受支持,但会裁掉 provenance 和接口映射字段,不能作为完整 baseline。
省略 `--compact` 的 full leaf、`--all` 中对应工具和 Catalog full tool 均由同一个 resolved `ToolSpec` 投影,内容必须一致;compact leaf 仅做字段白名单投影,不重新解析语义。概览、产品/分组和 Catalog summary 也来自同一 `ToolSpec`。通过 alias 查询时,只允许路径视图发生变化,参数、安全和接口契约不得变化。
Leaf 查询、`--all` 中对应工具和 Catalog full tool 均由同一个 resolved `ToolSpec` 投影,内容必须一致;概览、产品/分组和 Catalog summary 也由该 `ToolSpec` 的统一 summary 投影生成。通过 alias 查询时,只允许 `cli_path` 和 `is_alias` 发生视图变化,参数、安全和接口契约不得变化。
`--compact` 是 Schema 的稳定 Agent 字段白名单,也是普通 Agent 查询的规范选项。它保留 CLI 参数、组合约束、选择和安全语义,但有意省略 `interface_ref`、参数 `property/interface_type` 与 provenance。检查这些映射/审计字段时,使用 full leaf 并通过 `--jq` / `--fields` 精确投影。若兼容旧二进制时收到 `unknown_flag: --compact`,用同一个 Schema 查询去掉 `--compact` 重试;这只降低输出裁剪能力,不表示 leaf 缺失。
`--compact` 是 Schema 的展示选项。当前版本支持该 flag;若兼容旧二进制时收到 `unknown_flag: --compact`,用同一个 Schema 查询去掉 `--compact` 重试。这只降低输出裁剪能力,不表示 leaf 不存在,也不能改用 Schema 查询业务数据。
### Schema、Help 与业务数据的边界
| 问题 | 事实源 |
|------|--------|
| 命令是否由当前二进制暴露、Cobra 接受哪些 flags | `dws <path> --help` |
| Agent 选哪个命令、CLI 参数与组合约束、risk/confirmation | 对应的 Agent leaf `dws schema "<path>" --compact` |
| CLI↔RPC 参数映射、接口绑定与 provenance | full leaf 配合 `--jq` / `--fields` 精确投影 |
| Agent 选哪个命令、参数映射与组合约束、risk/confirmation | 对应的 leaf `dws schema "<path>"` |
| 当前钉钉中的文档、文件、日程、消息等业务数据 | 实际执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
Schema 与 Help 冲突表示发布契约漂移,不能静默猜测。执行参数必须以 Cobra 实际接受的 flag 为准;安全语义冲突时采用更保守的处理(例如先确认)或停止执行并报告漂移。完成命令发现后,仍必须执行真实业务命令;`dws schema` 本身不会读取或搜索业务内容。
+1 -1
View File
@@ -6,7 +6,7 @@
## 第一步:安装 dws
一键脚本会自动下载最新版二进制 + `dingtalk-misc` skill(开放平台应用文档落在 misc),只需要 curl(无需 go / git)。
一键脚本会自动下载最新版二进制 + `dingtalk-dev` skill,只需要 curl(无需 go / git)。
### macOS / Linux
+6 -7
View File
@@ -231,8 +231,7 @@ Cobra hard-required 是独立的 executable fact,并通过 `cli_required`/prov
| 问题 | 事实源 |
|---|---|
| 当前二进制是否暴露命令、Cobra 接受哪些 flags | `dws <path> --help` |
| Agent 选哪个命令、CLI 参数/required/约束、risk/confirmation | Agent leaf `dws schema "<path>" --compact` |
| CLI↔RPC 参数映射、接口绑定、provenance | full leaf 配合 `--jq` / `--fields` 精确投影 |
| Agent 选哪个命令、参数映射/required/约束、risk/confirmation | 对应 leaf `dws schema "<path>"` |
| 钉钉中的文档、文件、日程、消息等实际数据 | 真正执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
Schema 和 Help 冲突是契约漂移,不能静默猜测:
@@ -247,10 +246,10 @@ Schema 和 Help 冲突是契约漂移,不能静默猜测:
```bash
dws schema # 产品紧凑概览
dws schema calendar --compact # Agent 产品摘要
dws schema "calendar event" --compact # Agent 分组摘要
dws schema "calendar event create" --compact # Agent leaf
dws schema "calendar event create" # full leaf,仅用于映射/provenance 审计
dws schema calendar # 产品摘要
dws schema "calendar event" # 分组摘要
dws schema "calendar event create" # 完整 leaf
dws schema "calendar event create" --compact # 支持:裁掉 provenance/debug 字段
dws schema --all # 所有工具的完整 leaf 导出
```
@@ -258,7 +257,7 @@ dws schema --all # 所有工具的完整 leaf 导
`schema --all` 必须包含最终 `SchemaIndex` 中每个 tool 的完整 leaf 参数、约束和安全语义;无业务参数的命令也要包含空 `parameters` 对象。它用于审计、CI 和参数防丢 baseline,但输出很大,普通 Agent 命令发现不得使用,应按 overview -> product/group -> leaf 渐进查询。
`--compact` 是普通 Agent 查询的规范视图:通过正向字段白名单保留选参、约束与安全语义,full 新增字段不会自动进入 Agent 上下文。省略它的 leaf 包含参数 property、接口绑定和 provenance,只用于定向审计;`schema --all --compact` 也可执行,但不能作为完整兼容性 baseline。
`--compact` 当前受支持,适合减少常规 leaf 查询上下文。`schema --all --compact` 也可执行,但会移除 provenance/debug 和接口映射字段,不能作为完整兼容性 baseline。
兼容旧二进制时,如果 Schema 查询返回 `unknown_flag: --compact`,只去掉 `--compact` 重试同一个查询。这是展示能力降级,不代表 leaf 缺失,也不能改用 Schema 查询业务数据。
+6 -7
View File
@@ -70,17 +70,16 @@
> 已落地: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(7)
### im → chat(5)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+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 |
| `+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投影 |
| `+messages-resources-download` | write | dws download-media 走 get_resource_download_url 拿URL,缺分片Range下载/重试/扩展名推断/安全落盘路径校验 |
| `+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`,避免伪全局排序 |
| `+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 富化、资源下载 |
### task → todo(3)
File diff suppressed because it is too large Load Diff
-105
View File
@@ -1,105 +0,0 @@
# DWS Skill 内容框架合同
> 本分支权威合同:`skills/mono` / `skills/multi` 的**内容组织**与 zip 内容树形状。
> 不做安装/升级行为约定。质检见 [skill-mono-multi-qa.md](skill-mono-multi-qa.md)。
> 对齐调研:[skill-wukong-align-plan.md](skill-wukong-align-plan.md)。
## 1. 两棵内容树
| 树 | 路径 | 角色 |
|---|---|---|
| **mono**(单 skill) | `skills/mono/` | 单一 `SKILL.md` 入口 + `references/products/*` 产品面 + 全局协议 |
| **multi**(多 skill) | `skills/multi/` | 平铺 `dingtalk-*` 产品 skill + 必选 `dingtalk-shared` |
Agent / 安装面选哪棵树由**行为分支**决定;本文件只规定树内合同。
## 2. Multi 目录合同(如何新增一个产品 skill)
新建 `skills/multi/<name>/` 时必须满足:
1. **命名**
- 产品 skill:`dingtalk-<product>`(小写、连字符)
- 共享 skill:仅允许 `dingtalk-shared`
2. **根文件**
- 必有 `SKILL.md`(YAML frontmatter + 正文)
- `references/` 推荐;无 reference 的 skill(如极简 profile)须在质检 omit 表登记
- `scripts/` 可选;脚本须被本 skill 树内某 `.md` 引用,或进入 orphan allowlist
3. **Frontmatter 最小集**(产品 / shared)
- `name`:与目录名一致
- `description`:非空,含触发意图与边界
- `metadata.category`:`product` 或 `shared`(允许历史写法把 `cli_version` 放在 frontmatter 顶层)
- `metadata.requires.bins`:含 `dws`
4. **契约块**
- 产品 skill 推荐内嵌 `<!-- DWS_RUNTIME_CONTRACT_START -->…END -->` **或** 明确 PREREQUISITE 指向 `dingtalk-shared`
- `dingtalk-shared` 承载跨产品路由与全局协议落点
5. **与 mono 映射**
- 每个 mono `references/products/<stem>`(文件或目录)必须在
`skills/content-qa/mono-multi-coverage.yaml` 有 `coverage` 或 `omit_coverage` 行
### 2.1 推荐骨架
```text
skills/multi/dingtalk-example/
├── SKILL.md
├── references/
│ ├── example.md # 主产品面
│ └── … # 子章节 / 意图表
└── scripts/ # 可选;须被 md 引用
└── example_helper.py
```
## 3. Mono 目录合同(质检对照基准)
```text
skills/mono/
├── SKILL.md
├── references/
│ ├── products/ # 覆盖质检主源
│ ├── error-codes.md # 全局协议示例
│ ├── error-codes.md
│ └── …
└── scripts/
```
- `references/products/` 下每个顶层 stem(`.md` 去后缀或子目录名)计入覆盖索引。
- 同 stem 的 `.md` + 子目录视为同一产品面(如 `doc.md` + `doc/`)。
## 4. 共享内容(`dingtalk-shared`)
| 职责 | 落点 |
|---|---|
| 跨产品路由 / 工作流 | `references/routing.md`、`workflow-routing.md`、`intent-guide.md` |
| 运行时最小契约长文 | `references/runtime-contract.md`(受 context-budget 约束) |
| 全局协议(确认门禁 / Schema 教学等) | `references/`;见质检基线 |
| 与 mono 全局文同名迁移 | `error-codes`、`url-patterns`、`capability-limits`、`channel-login`、`global-reference`、`recipes/`(`conventions.md`、`meta.md`、`lite-catalog.md`) |
产品专属规则(如 AI 表格 `field-rules`)允许下沉到对应 `dingtalk-*`,须在覆盖表注明。
## 5. Zip 内容布局合同(形状,非安装默认)
发布物 `dws-skills.zip`(及 embed 同源)内容树形状:
| Zip 路径 | 含义 |
|---|---|
| `<root>/` | mono 内容副本(兼容旧面) |
| `<root>/mono/` | 与 `skills/mono/` 同构 |
| `<root>/multi/` | 与 `skills/multi/` 同构 |
质检可断言源树形状;**不**断言安装器默认解压哪棵。
## 6. 与悟空 `dingtalk-skills/` 对照(组织概念 only)
| 维度 | DWS `skills/multi` | 悟空 `dingtalk-skills/`(develop) |
|---|---|---|
| 布局 | flat `dingtalk-*` + `dingtalk-shared` | 同构 flat |
| 集合 | 产品 skill + shared(含 event/profile/…;dev/skill 等长尾落在 misc) | 更小产品集(如 attendance/report 独立目录) |
| 质检权威 | **mono 单 skill 树** | 不作为 DWS 覆盖基准 |
| 不移植 | `_install.sh` / bundle / dual / Qwen overlay | — |
悟空独有命名(如 `dingtalk-attendance`)在 DWS 中由 `dingtalk-misc` 承接对应 mono `attendance*` / `report` / `oa` / `sheet` / `dev` 等面——见覆盖表。
## 7. 变更流程
1. 改 / 增内容 → 更新 `skills/content-qa/mono-multi-coverage.yaml`(coverage 或 omit)
2. 跑 `make skill-mono-multi-content`(该独立门禁不包含在默认 `make policy` 中)
3. 失败则修内容或更新 reviewed omit(disposition + 原因),**禁止**用安装默认值绕过
-76
View File
@@ -1,76 +0,0 @@
# Mono↔Multi Skill 内容质检规格
> 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。
> 执行:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)。
## 1. 质检矩阵
| ID | 类型 | 输入 | 通过准则 |
|---|---|---|---|
| **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` |
| **G5 链接** | 可达性 | 合同覆盖的 paired Markdown | 内联相对链接目标文件或目录存在且不逃出仓库;外链、纯锚点和锚点内容不在检查范围 |
已有门禁(继续复用,不替代本矩阵):`check-skill-commands`、`check-skill-context-budget`、`check-multi-im-skill-chain`、`skill_docs_policy`、whiteboard 成对测试。
## 2. 有意省略 / 延期登记格式
YAML(见 coverage 文件):
```yaml
omit_coverage:
- mono: simple
disposition: covered_by # covered_by | defer | wontfix
via: dingtalk-misc # optional
reason: "拆入 oa/devdoc…"
omit_global:
- id: field-rules-global
mono_path: references/field-rules.md
expected_multi: dingtalk-aitable/references/field-rules.md
disposition: covered_by
reason: "AI 表格字段规则已下沉到 dingtalk-aitable;G3/coverage 不强制全局同名"
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;**不**改安装/升级默认。
## 3. 缺口基线(相对 mono)
| ID | 项 | disposition | 说明 |
|---|---|---|---|
| M1 | recovery-guide / RECOVERY_EVENT_ID 闭环 | **removed** | 已从 mono/multi skill 文档删除;不做移植 |
| M2 | confirmation_required 全局协议 | **done** | `dingtalk-shared/references/confirmation.md` + SKILL 导航 |
| M3 | Schema 渐进查询教学 | **done** | `dingtalk-shared/references/schema-usage.md` |
| M4 | `report_inbox_today.py` | `defer` / orphan 侧 | 验证后迁 misc 或删 |
| M5 | multi LICENSE/NOTICE | `defer` | 内容或打包注入 |
| M6 | aiapp 路由 vs orphan 脚本 | **done(标明未产品化)** | mono 死链移除;`unsupported-scripts.md` |
| X1 | yida/finance/aiapp orphan scripts | **done(登记)** | 由 unsupported-scripts 具名引用 |
| X2 | chat 死链 `extract_media_id.py` | n/a | 现仅为反模式提及 |
| X3 | routing → markdown 错路径 | **done** | 已指 `dingtalk-misc/references/markdown.md`;drive 尾链已修 |
| X4 | event 缺 metadata | **done** | |
| X5 | multi skill 横幅 /「优先 mono」文案 | **done** | 横幅已全部移除 |
| X6 | SAFETY_PREAMBLE_INJECT 无注入器 | **done** | 标记已移除 |
产品面覆盖:见 YAML `coverage`——mono products 均有 multi 承接(misc 聚合 attendance/oa/sheet/…)。
## 4. 与悟空
借鉴 frontmatter / 断链 / requires 等**检查维度**;不运行悟空 bundle zip 校验脚本。覆盖权威始终是 DWS mono。
-272
View File
@@ -1,272 +0,0 @@
# DWS multi-skill **内容框架**对齐方案(相对 dws-wukong develop)
> 状态:**执行中** — Phase 1–3 已落地;M2/M3 已补;**M1 recovery 闭环已从 skill 删除(不做移植)**。
> 合同短文:[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` 按设计不包含该检查)
>
> 撰写 / 收窄 / 质检增补 / 执行:2026-08-05
> 工作树:`/Users/john/GolandProjects/open-source/dws-multi-skill-align`
> 分支:`feat/multi-skill-framework-align`(自 `origin/main` @ `a37e6e68`)
> **本分支范围:只做 skill 内容的这个框架**(目录布局、文档契约、共享内容约定、zip 内容树合同、**相对 mono 的内容质检**)。
> **不做**安装/升级引擎、agent-home、脚本 skill-install 行为翻转。
>
> 对照仓:
>
> | 仓 | 路径 | 基线 |
> |---|---|---|
> | DWS OSS CLI(本工作树) | `dws-multi-skill-align` | `origin/main` |
> | dws-wukong | `~/GolandProjects/open-source/dws-wukong` | `origin/develop` @ `ab76629a`(调研时) |
> | 行为参考(**另一分支**) | `dws-skill-mode-migration` @ `402429ac`/`d5c8982c` | 安装默认 multi / upgrade 强制 multi —— **不在本分支排期** |
> | 内容缺口留档(参考) | 同迁移分支 `docs/skill-capability-completion.md`(M1–M6 / X1 等) | **仅作质检目标线索**,非本分支权威 |
---
## 0. TL;DR
1. **本分支 = skill 内容框架 + 相对 mono 的内容质检**:固化 `skills/multi` 组织合同,并用 **mono 单 skill 布局作对照基准**做覆盖/结构/漂移门禁(文档 + CI 内容护栏)。
2. **对齐悟空**:只取内容树组织概念;质检以 **DWS-native** 设计为主(已有 policy/测试可复用)。悟空 `validate-multiskill-bundle.py` 仅借鉴「frontmatter / 断链 / requires」类检查思路,**不**移植 bundle/安装校验。
3. **安装/升级行为**与 `402429ac`/`d5c8982c` → **单独 follow-up 分支**,本方案只登记。
4. 质检 **不改**默认安装哪棵树;只保证 multi 内容相对 mono **可解释、可覆盖、可回归**。
### 0.1 IN SCOPE
| 类别 | 包含 |
|---|---|
| 内容树结构 | `skills/mono/` 与 `skills/multi/<name>/` 目录合同 |
| 单 skill 约定 | `SKILL.md` frontmatter / 契约块 / Golden Route;`references/`;可选 `scripts/` |
| 共享内容 | `dingtalk-shared` 职责与被引用方式;与 mono 全局文映射(文档级) |
| 命名与集合 | `dingtalk-*` + `dingtalk-shared`;相对悟空的共有/独有清单(文档) |
| Zip **内容布局合同** | `mono/` / `multi/` / 根 mono 副本的内容含义与树形状;不改安装默认 |
| **Mono↔multi 内容质检** | 覆盖、结构、漂移三类门禁;复用/扩展现有 policy 与测试;缺口修复属内容编辑(另批或同分支内容 Phase) |
| 内容架构文档 | 本文件 + 可选短文(架构合同 + 质检矩阵) |
### 0.2 OUT OF SCOPE
| 类别 | 去向 |
|---|---|
| 安装默认 multi、upgrade always-multi | Follow-up 分支(`402429ac`/`d5c8982c`) |
| `LocateSkillsRoot` / `skill_setup` / `paths.go` / `skillhome` / install 脚本行为 | 同上 |
| 安装/运行时 manifest、state.json、mode 切换、telemetry header | 拒绝或行为分支 |
| 悟空 `_install.sh` / dual / Qwen / RewindDesktop / pod | 拒绝 |
| 非 skill 内容的 CLI 功能(schema/shortcut 代码等) | 拒绝 |
| 把质检做成「改安装默认值」的后门 | 拒绝 |
---
## 1. 内容现状盘点
### 1.1 DWS `skills/mono`(质检对照基准 · 单 skill)
```text
skills/mono/
├── SKILL.md
├── references/
│ ├── products/<area>.md|…/ # 产品能力面(质检「覆盖」主源)
│ ├── error-codes.md、… # 全局协议(无 recovery 闭环)
│ └── best_practices/…
└── scripts/
```
### 1.2 DWS `skills/multi`(内容主体)
```text
skills/multi/
├── dingtalk-shared/ # 跨产品契约 / routing / 全局协议应落点
└── dingtalk-*/ # 19 产品 + 各 references、scripts
```
仅 DWS 有(悟空无):dev, event, hrbrain, markdown, pat, profile, skill。
### 1.3 悟空 `dingtalk-skills/`(内容组织对照,非质检权威)
Flat `dingtalk-*` + `dingtalk-shared`;单 skill 骨架同构。**不作为 mono 覆盖基准**(集合更小、不同源)。
### 1.4 Zip 内容布局合同
| Zip 路径 | 内容含义 |
|---|---|
| `<root>/` | mono 副本(兼容) |
| `<root>/mono/` | 显式 mono 内容源 |
| `<root>/multi/` | 与 `skills/multi/` 同构 |
质检可断言「源树形状」;**不**断言安装面默认选哪棵。
### 1.5 现有 DWS skill 内容质检资产(复用清单)
| 资产 | 作用 | 与 mono↔multi 质检关系 |
|---|---|---|
| `scripts/policy/check-skill-commands.sh` + `skill-command-check/` | Skill 文内 `dws …` 命令路径存在性 | **复用**(命令真实性);非覆盖映射 |
| `scripts/policy/check-skill-context-budget.sh` | chat/event/mono/`dingtalk-shared` 上下文预算与冷启动约束 | **复用**(结构/预算);可扩展 shared 引用规则 |
| `scripts/policy/check-multi-im-skill-chain.sh` + `multi-im-skill-chain/` | IM 意图单默认路由、retired scripts、handoff | **复用**(chat/event 链);面窄 |
| `test/unit/skill_docs_policy_test.go` | 退役命令、event 扁平输出契约等 | **复用**;可加 mono↔multi 断言 |
| `test/unit/whiteboard_skill_docs_test.go` | mono/multi whiteboard recipes **字节一致** | **样板**:产品面「同源文件」门禁范式 |
| `test/skill_static`(`-tags skill_verify`) | 文内命令 vs Cobra;multi 查 flag | **复用**(opt-in 深度);非 CI 默认全量时可保持 tags |
| `test/skill_e2e` / `test/run_skill_tests.py` | 执行层 / 用例驱动 | **偏行为**;本分支质检默认不依赖 e2e |
| `Makefile` → `policy` 含 context-budget、multi-im-skill-chain;`skill-command-integrity` 独立 | 已有 CI 钩子 | 新门禁优先挂同类 policy / `test/unit` |
**缺口(尚无的门禁)**:系统的「mono `references/products/*` → multi 目录/文」覆盖表;frontmatter 全集完备性;orphan scripts。全局协议中 **确认门禁 / Schema 教学已补**;**recovery 闭环已从 skill 移除(不再作为缺口)**。
### 1.6 悟空侧类比质检
| 悟空 | 说明 | 本分支 |
|---|---|---|
| `scripts/validate-multiskill-bundle.py` | 校验 **已打好的 bundle zip**:frontmatter keys/category、`requires`、markdown 断链、scenario 编排 | **Adapt 思路** → DWS 源树(`skills/multi` + 对照 mono),不跑 zip 安装语义 |
| `sync-monolith-to-multiskill.py` | mono→multi 派生 | **不**作默认质检手段;DWS 直接维护 multi |
结论:**DWS-native mono↔multi 质检**;悟空仅参考检查维度。
---
## 2. Diff(内容组织 + 质检视角)
### 2.1 已同构
Flat `dingtalk-*` + `dingtalk-shared`;`SKILL.md` + `references/`(+ 可选 `scripts/`)。
### 2.2 分叉与已知内容风险(质检要盯的)
| 风险 ID | 现象(线索) | 质检类型 |
|---|---|---|
| **C-cov** | mono `products/*` 能力面在 multi 无对应 skill/reference,或未登记「有意省略」 | 覆盖 |
| **C-struct** | multi 缺 frontmatter 字段、`references/`、`DWS_RUNTIME_CONTRACT`、对 `dingtalk-shared` 引用不一致 | 结构 |
| **C-drift-global** | 曾关注 recovery / 确认 / Schema;现确认与 Schema 已在 `dingtalk-shared`,**recovery skill 文档已删除** | 漂移(协议) |
| **C-drift-orphan** | multi(或 mono)scripts/refs 无文档引用;或 routing 指向无索引产品(留档 X1/M6) | 漂移(孤儿) |
| **C-pair** | 应对齐的成对文件(如 whiteboard recipes)内容不一致 | 漂移(成对) |
### 2.3 Reject
悟空安装包校验整文件照搬、内容集 19→12 砍产品、安装行为门禁冒充内容质检。
---
## 3. Goals / Non-goals
### 3.1 Goals
1. 固化 multi **内容目录合同**与 mono↔multi **映射说明**。
2. 建立 **质检矩阵**(覆盖 / 结构 / 漂移)并以 mono 为对照基准;有意省略必须 reviewed 登记。
3. **复用** §1.5 资产;新增门禁走 `scripts/policy` 或 `test/unit`,内容-only。
4. (可选)纯内容元数据;**禁止**被安装引擎读取改行为。
5. 质检失败 → 修 **内容**或更新「有意省略」表,不改 setup/upgrade。
### 3.2 Non-goals
安装/升级翻转;cherry-pick 行为提交;取消产品;悟空客户端;非 skill CLI 功能;用质检驱动默认 multi 安装。
---
## 4. 分期(内容框架 + 质检 · 均无安装引擎)
> 批准前 **零编码**(含不实现新 gates)。**已执行**:Phase 1–3 见文首状态。
### Phase 0 — 方案冻结(本文)
| | |
|---|---|
| **范围** | 本文件;§7(含质检轨)勾选 |
| **验收** | owner 重新批准 → ✅「现在开始执行」 |
### Phase 1 — Multi 内容目录合同 + 架构短文 ✅
| | |
|---|---|
| **范围** | `skills/multi` 目录合同;与悟空内容树对照表;zip `multi/` 同构合同 |
| **触达** | `docs/skill-content-framework.md` |
| **验收** | 可指导「如何新增 dingtalk-* 内容目录」 |
### Phase 2 — Mono↔multi **内容质检规格**(矩阵 + 缺口基线) ✅
| | |
|---|---|
| **范围** | 质检规格 + 覆盖/omit 机读表 + 缺口 disposition |
| **触达** | `docs/skill-mono-multi-qa.md`、`skills/content-qa/mono-multi-coverage.yaml` |
| **验收** | 矩阵可人工抽查;缺口均有 disposition |
### Phase 3 — 质检落地:CI 内容护栏(复用 + 新 gate) ✅
| | |
|---|---|
| **范围** | G1–G4 自动门禁 |
| **触达** | `test/unit/mono_multi_skill_content_test.go`、`scripts/policy/check-mono-multi-skill-content.sh`、`Makefile` |
| **验收** | `make skill-mono-multi-content` 绿;已知缺口走 reviewed omit |
### Phase 4 — 可选:内容包元数据 + 缺口修复波次
| | |
|---|---|
| **范围 A** | 纯内容 layout/skill 列表元数据(人不读安装器) |
| **范围 B** | 按 Phase 2 disposition **修内容**:确认 / Schema 已补;**recovery skill 文档已删除(wontfix 移植)**;orphan 脚本仍走 allowlist(M4 等) |
| **验收** | 元数据不驱动安装;修复项关闭对应质检失败或转入 omit |
### 延期登记(非本分支)
| 主题 | 载体 |
|---|---|
| 默认 multi + upgrade always-multi | 行为分支 ← `402429ac`/`d5c8982c` |
| skillhome / 安装面 bootstrap | 行为分支 |
---
## 5. Port / Adapt / Reject
| 项 | 决策 | 说明 |
|---|---|---|
| flat + `dingtalk-shared` 内容模型 | **Port** | 已有;合同 + 质检加固 |
| 悟空 bundle frontmatter/断链/requires 检查维度 | **Adapt** | 做成 DWS 源树门禁,不校验 bundle zip/安装 |
| whiteboard 式 mono/multi 成对一致 | **Port(范式)** | 推广到 reviewed 文件对 |
| `validate-multiskill-bundle.py` 整脚本 | **Reject** | 绑定悟空 zip/Qwen 语义 |
| `_install.sh` / dual / overlay | **Reject** | 非内容 |
| 行为 cherry-pick | **Defer** | 另分支 |
---
## 6. 与 `402429ac` / `d5c8982c`
| | |
|---|---|
| 本分支 cherry-pick? | **否** |
| 质检是否替代行为翻转? | **否** |
| 行为分支 | 另开;可与内容/质检并行 |
---
## 7. 批准清单(请重新勾选)
**范围**
- [x] 本分支 = skill **内容**框架 + **mono↔multi 内容质检**(§0.1);无安装/升级引擎
- [x] `402429ac`/`d5c8982c` 及 setup/paths/install 脚本行为 **不在本分支**
- [x] 取消产品与悟空客户端链路仍拒绝
**内容框架 Phase**
- [x] **Phase 1**:multi 目录合同 + 悟空内容树对照短文
**质检轨 Phase**
- [x] **Phase 2**:质检矩阵 + mono↔multi 覆盖/缺口基线规格(先文档,可执行)
- [x] **Phase 3**:CI 内容护栏(G1–G4)—— 本迭代做 / 拆 PR / 只要规格暂不落地
- [x] 质检失败处置原则:修内容或 reviewed omit,**不**改安装默认
**可选**
- [ ] **Phase 4A** 纯内容元数据:做 / 不做 / 以后
- [x] **Phase 4B** recovery skill 文档 **removed/wontfix**;确认/Schema 已补;剩余 orphan(M4 等)仍 defer / allowlist
**Follow-up 知悉**
- [ ] 安装默认 multi + upgrade always-multi → **另一分支**
---
## 8. 下一步
**Phase 1–3 已落地**(合同短文 + 质检规格 + `skills/content-qa` + CI 门禁)。
Phase 4B:recovery 已删除(不做移植);确认/Schema 已补。剩余 defer:orphan scripts(M4 等)、LICENSE/NOTICE(M5)、Phase 4A 元数据。
安装默认 multi 等行为仍走 **另一分支**。
---
*锚点:`skills/mono`、`skills/multi`、§1.5 policy/测试、wukong `dingtalk-skills/`(组织对照 only)。*
+9 -7
View File
@@ -335,6 +335,12 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
edition.Override(&edition.Hooks{ConfigDir: func() string { return "" }})
captureRuntimeFailure(executor.Invocation{}, nil, nil)
captureRuntimeFailure(executor.Invocation{}, errors.New("raw"), nil)
oldArgs := os.Args
os.Args = []string{"dws", "doc", "download", "--node", "n"}
if got := runtimeCommandPath(executor.Invocation{}); len(got) != 2 {
t.Fatalf("runtime command path = %#v", got)
}
os.Args = oldArgs
t.Setenv(authpkg.AgentCodeEnv, "")
if hostControlProviderFromEnv() != "" {
@@ -354,10 +360,6 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
func TestCrossPlatformCoverageConfigAndCacheCommandRemainingCoverage(t *testing.T) {
for _, command := range []*cobra.Command{newConfigCommand(), newCacheCommand()} {
command.SetOut(io.Discard)
rootWrap := &cobra.Command{Use: "dws"}
rootWrap.PersistentFlags().String("format", "json", "")
rootWrap.AddCommand(command)
command.SetOut(io.Discard)
if err := command.RunE(command, nil); err != nil {
t.Fatal(err)
@@ -387,18 +389,18 @@ func TestCrossPlatformCoverageConfigAndCacheCommandRemainingCoverage(t *testing.
for _, format := range []string{"json", "pretty", "table"} {
_ = cacheRoot.PersistentFlags().Set("format", format)
cacheCmd.SetOut(io.Discard)
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); err != nil {
if err := printCacheCompatNotice(cacheCmd, "status"); err != nil {
t.Fatal(err)
}
}
fail := errors.New("write")
cacheCmd.SetOut(appFailWriter{err: fail})
_ = cacheRoot.PersistentFlags().Set("format", "pretty")
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); !errors.Is(err, fail) {
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
t.Fatalf("pretty write error = %v", err)
}
_ = cacheRoot.PersistentFlags().Set("format", "table")
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); !errors.Is(err, fail) {
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
t.Fatalf("table write error = %v", err)
}
}
+11 -22
View File
@@ -21,10 +21,6 @@ import (
"github.com/spf13/cobra"
)
const cacheUnsupportedMessage = "dws cache 不再支持:服务发现已下线,当前版本使用编译期静态端点目录;dws cache 仅保留为兼容入口,不会刷新端点。"
const cacheReplacementHint = "如遇 endpoint_not_resolved,请先执行 dws upgrade 获取包含最新 internal/syncdata 端点的版本;仍失败时检查 internal/syncdata.StaticServers() 是否覆盖目标 product/server。"
type cacheCompatNotice struct {
Status string `json:"status"`
Command string `json:"command"`
@@ -32,32 +28,24 @@ type cacheCompatNotice struct {
Replacement string `json:"replacement,omitempty"`
}
// newCacheCommand keeps a visible Deprecated compatibility surface for
// historical argv (refresh/status/clean). Behavior is a successful no-op notice.
// Skills must not teach this path. Deprecated leaves are excluded from Schema
// via cobra.IsAvailableCommand() — do not add schema_command_exclusions entries.
func newCacheCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "cache",
Short: "不再支持:服务发现缓存兼容入口",
Long: "此命令组仅为历史 argv 兼容保留。静态端点模式下无需服务发现缓存;Skill / Agent 请勿引导此路径。",
Deprecated: "不再支持;" + cacheUnsupportedMessage,
Args: cobra.NoArgs,
Short: "服务发现缓存兼容入口(静态端点模式已弃用)",
Hidden: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return printCacheCompatNotice(cmd, "dws cache")
return cmd.Help()
},
}
for _, name := range []string{"refresh", "status", "clean"} {
subName := name
sub := &cobra.Command{
Use: subName,
Short: "不再支持:静态端点模式无需服务发现缓存",
Deprecated: "不再支持;" + cacheUnsupportedMessage,
Use: name,
Short: "已弃用:静态端点模式无需服务发现缓存",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return printCacheCompatNotice(cmd, "dws cache "+subName)
return printCacheCompatNotice(cmd, name)
},
}
cmd.AddCommand(sub)
@@ -68,9 +56,9 @@ func newCacheCommand() *cobra.Command {
func printCacheCompatNotice(cmd *cobra.Command, command string) error {
notice := cacheCompatNotice{
Status: "deprecated",
Command: command,
Message: cacheUnsupportedMessage,
Replacement: cacheReplacementHint,
Command: "dws cache " + command,
Message: "服务发现已下线,当前版本使用编译期静态端点目录;dws cache 仅保留为兼容入口,不会刷新端点。",
Replacement: "如遇 endpoint_not_resolved,请先执行 dws upgrade 获取包含最新 internal/syncdata 端点的版本;仍失败时检查 internal/syncdata.StaticServers() 是否覆盖目标 product/server。",
}
format, _ := cmd.Root().PersistentFlags().GetString("format")
switch strings.ToLower(strings.TrimSpace(format)) {
@@ -78,7 +66,8 @@ func printCacheCompatNotice(cmd *cobra.Command, command string) error {
return json.NewEncoder(cmd.OutOrStdout()).Encode(notice)
case "pretty":
data, _ := json.MarshalIndent(notice, "", " ")
_, err := fmt.Fprintln(cmd.OutOrStdout(), string(data))
var err error
_, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
return err
default:
_, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n%s\n", notice.Command, notice.Message, notice.Replacement)
-111
View File
@@ -1,111 +0,0 @@
// 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 (
"bytes"
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageCacheDeprecatedCompatShim(t *testing.T) {
root := NewRootCommand()
group := mustFindCommand(t, root, "cache")
if group.Hidden || group.Deprecated == "" || !group.Runnable() {
t.Fatalf("cache group contract: hidden=%v deprecated=%q runnable=%v", group.Hidden, group.Deprecated, group.Runnable())
}
if group.IsAvailableCommand() {
t.Fatal("deprecated cache group must not be IsAvailableCommand")
}
for _, leaf := range []string{"refresh", "status", "clean"} {
cmd := mustFindCommand(t, root, "cache", leaf)
if cmd.Hidden || cmd.Deprecated == "" || !cmd.Runnable() {
t.Fatalf("cache %s contract: hidden=%v deprecated=%q runnable=%v", leaf, cmd.Hidden, cmd.Deprecated, cmd.Runnable())
}
if cmd.IsAvailableCommand() {
t.Fatalf("deprecated cache %s must not be IsAvailableCommand", leaf)
}
}
var out bytes.Buffer
cmd := NewRootCommand()
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"cache", "refresh", "--format", "json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("cache refresh compatibility stub: %v\n%s", err, out.String())
}
got := out.String()
for _, want := range []string{`"status":"deprecated"`, `"command":"dws cache refresh"`, "不再支持", "服务发现已下线"} {
if !strings.Contains(got, want) {
t.Fatalf("cache refresh output missing %q:\n%s", want, got)
}
}
for _, format := range []string{"", "json", "pretty", "table"} {
var buf bytes.Buffer
parent := &cobra.Command{Use: "dws"}
parent.PersistentFlags().String("format", format, "")
parent.SetOut(&buf)
sub := &cobra.Command{Use: "cache"}
parent.AddCommand(sub)
if err := printCacheCompatNotice(sub, "dws cache status"); err != nil {
t.Fatalf("format=%q: %v", format, err)
}
text := buf.String()
if !strings.Contains(text, "不再支持") && !strings.Contains(text, "服务发现已下线") {
t.Fatalf("format=%q missing notice:\n%s", format, text)
}
if format == "" || format == "json" || format == "pretty" {
if !strings.Contains(text, `"status":"deprecated"`) && !strings.Contains(text, `"status": "deprecated"`) {
t.Fatalf("format=%q missing deprecated JSON status:\n%s", format, text)
}
}
}
for _, format := range []string{"pretty", "table"} {
parent := &cobra.Command{Use: "dws"}
parent.PersistentFlags().String("format", format, "")
parent.SetOut(failWriter{})
sub := &cobra.Command{Use: "cache"}
parent.AddCommand(sub)
if err := printCacheCompatNotice(sub, "dws cache clean"); err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("format=%q write failure = %v, want write failed", format, err)
}
}
parent := newCacheCommand()
var parentOut bytes.Buffer
rootWrap := &cobra.Command{Use: "dws"}
rootWrap.PersistentFlags().String("format", "json", "")
rootWrap.SetOut(&parentOut)
rootWrap.AddCommand(parent)
parent.SetOut(&parentOut)
if err := parent.RunE(parent, nil); err != nil {
t.Fatalf("cache parent RunE = %v, want nil success", err)
}
if !strings.Contains(parentOut.String(), `"command":"dws cache"`) {
t.Fatalf("cache parent notice missing command:\n%s", parentOut.String())
}
cache := newCacheCommand()
cache.SetOut(&bytes.Buffer{})
cache.SetArgs([]string{"status"})
if err := cache.Execute(); err != nil {
t.Fatal(err)
}
}
@@ -1,84 +0,0 @@
// 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)
}
}
}
})
}
}
@@ -1,301 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
_ "embed"
"encoding/json"
stderrors "errors"
"fmt"
"io"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
//go:embed testdata/shortcut_hallucination_badcases_20260728.json
var shortcutHallucinationBadcases20260728JSON []byte
type shortcutHallucinationReplayCorpus struct {
SourceReport string `json:"source_report"`
SourceFile string `json:"source_file"`
SourceDWSCommit string `json:"source_dws_commit"`
Model string `json:"model"`
ExpectedCount int `json:"expected_count"`
Badcases []shortcutHallucinationReplayBadcase `json:"badcases"`
}
type shortcutHallucinationReplayBadcase struct {
ID string `json:"id"`
CaseID string `json:"case_id"`
Run int `json:"run"`
CommandIndex int `json:"command_index"`
Turn int `json:"turn"`
SourcePath string `json:"source_path"`
ExpectedOutcome string `json:"expected_outcome"`
Raw string `json:"raw"`
OriginalExitCode int `json:"original_exit_code"`
IsHelp bool `json:"is_help"`
}
func TestCrossPlatformCoverageShortcutHallucinationBadcases20260728Replay(t *testing.T) {
var corpus shortcutHallucinationReplayCorpus
if err := json.Unmarshal(shortcutHallucinationBadcases20260728JSON, &corpus); err != nil {
t.Fatalf("decode historical shortcut badcase corpus: %v", err)
}
if corpus.SourceReport != "param_hallucination_20260728_120943" ||
corpus.SourceFile != "raw_dashscope_qwen3_7-max_20260728_120943.json" ||
corpus.SourceDWSCommit != "a8e83e5" || corpus.Model != "dashscope/qwen3.7-max" {
t.Fatalf("unexpected historical corpus provenance: %#v", corpus)
}
if corpus.ExpectedCount != 52 || len(corpus.Badcases) != corpus.ExpectedCount {
t.Fatalf("historical shortcut badcases = %d, expected_count=%d; want 52", len(corpus.Badcases), corpus.ExpectedCount)
}
wantOutcomeCounts := map[string]int{
"rewrite": 8,
"ambiguous": 44,
}
wantSourceCounts := map[string]int{
"chat +group-member-list": 1,
"chat +group-send-text": 1,
"chat +list-group-bots": 1,
"chat +list-robot": 1,
"chat +list-robots": 1,
"chat +members": 3,
"chat +message-list": 1,
"chat +read-single": 6,
"chat +rename-group": 1,
"chat +send": 4,
"chat +send-by-bot": 3,
"chat +send-dm": 2,
"chat +send-file": 15,
"chat +send-image": 3,
"chat +send-media": 2,
"chat +send-message": 3,
"chat +send-single": 1,
"chat +send-text": 2,
"chat +send-to": 1,
}
rewriteTargets := map[string]string{
"chat +members": "chat +group-members",
"chat +group-member-list": "chat +group-members",
"chat +list-group-bots": "chat +chat-bots",
"chat +list-robot": "chat +chat-bots",
"chat +list-robots": "chat +chat-bots",
"chat +rename-group": "chat +chat-update",
}
seenIDs := make(map[string]bool, len(corpus.Badcases))
gotOutcomeCounts := make(map[string]int)
gotSourceCounts := make(map[string]int)
for _, badcase := range corpus.Badcases {
badcase := badcase
t.Run(badcase.ID, func(t *testing.T) {
if badcase.ID == "" || seenIDs[badcase.ID] {
t.Fatalf("missing or duplicate historical badcase id %q", badcase.ID)
}
seenIDs[badcase.ID] = true
gotOutcomeCounts[badcase.ExpectedOutcome]++
gotSourceCounts[badcase.SourcePath]++
args := historicalShortcutBadcaseArgv(t, badcase.Raw)
if len(args) < 2 || strings.Join(args[:2], " ") != badcase.SourcePath {
t.Fatalf("raw argv source = %v, fixture source_path=%q", args, badcase.SourcePath)
}
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
switch badcase.ExpectedOutcome {
case "rewrite":
assertHistoricalShortcutRewrite(t, badcase, rewriteTargets[badcase.SourcePath], ctx, err)
case "ambiguous":
assertHistoricalShortcutReason(t, badcase, ctx, err, "ambiguous_command_fallback")
entry, ok := cli.LookupCommandPathFallback(badcase.SourcePath)
if !ok || entry.Mode != cli.CommandPathFallbackAmbiguous || len(entry.Candidates) < 2 {
t.Fatalf("ambiguous fallback table entry = %#v, %v", entry, ok)
}
case "unknown_shortcut":
assertHistoricalShortcutReason(t, badcase, ctx, err, "unknown_shortcut")
if entry, ok := cli.LookupCommandPathFallback(badcase.SourcePath); ok {
t.Fatalf("contract-incomplete path unexpectedly entered fallback table: %#v", entry)
}
default:
t.Fatalf("unsupported expected_outcome %q", badcase.ExpectedOutcome)
}
})
}
if !reflect.DeepEqual(gotOutcomeCounts, wantOutcomeCounts) {
t.Errorf("historical outcome counts = %v, want %v", gotOutcomeCounts, wantOutcomeCounts)
}
if !reflect.DeepEqual(gotSourceCounts, wantSourceCounts) {
t.Errorf("historical source counts = %v, want %v", gotSourceCounts, wantSourceCounts)
}
}
func TestCrossPlatformCoverageShortcutHallucinationMerged20260720Replay(t *testing.T) {
tests := []struct {
id string
raw string
occurrences int
outcome string
target string
}{
{
id: "dws_im_v2_0013-search-group",
raw: `dws chat +search-group --name "dws测试群02" --format json`,
occurrences: 1,
outcome: "official_alias",
target: "chat +chat-search",
},
{
id: "dws_oa_0017-list-processes",
raw: `dws oa +list-processes --format json --limit 50`,
occurrences: 2,
outcome: "ambiguous",
},
}
totalOccurrences := 0
for _, test := range tests {
test := test
t.Run(test.id, func(t *testing.T) {
totalOccurrences += test.occurrences
args := historicalShortcutBadcaseArgv(t, test.raw)
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
badcase := shortcutHallucinationReplayBadcase{
ID: test.id,
SourcePath: strings.Join(args[:2], " "),
ExpectedOutcome: test.outcome,
Raw: test.raw,
}
switch test.outcome {
case "rewrite":
assertHistoricalShortcutRewrite(t, badcase, test.target, ctx, err)
case "official_alias":
assertHistoricalOfficialAlias(t, badcase, test.target, ctx, err)
case "ambiguous":
assertHistoricalShortcutReason(t, badcase, ctx, err, "ambiguous_command_fallback")
default:
t.Fatalf("unsupported merged expected outcome %q", test.outcome)
}
})
}
if totalOccurrences != 3 {
t.Fatalf("merged shortcut hallucination occurrences = %d, want 3", totalOccurrences)
}
}
func assertHistoricalOfficialAlias(
t *testing.T,
badcase shortcutHallucinationReplayBadcase,
wantTarget string,
ctx *pipeline.Context,
err error,
) {
t.Helper()
if err != nil {
t.Fatalf("historical official alias %q returned error: %v", badcase.SourcePath, err)
}
if wantTarget == "" || ctx == nil || ctx.Command != "dws "+wantTarget {
t.Fatalf("historical official alias context = %#v; want command dws %s", ctx, wantTarget)
}
for _, correction := range ctx.Corrections {
if correction.Handler == "command-path-fallback" {
t.Fatalf("historical official alias received fallback correction: %#v", ctx.Corrections)
}
}
if entry, ok := cli.LookupCommandPathFallback(badcase.SourcePath); ok {
t.Fatalf("official alias unexpectedly remains in fallback table: %#v", entry)
}
}
func historicalShortcutBadcaseArgv(t *testing.T, raw string) []string {
t.Helper()
command := strings.TrimSpace(raw)
for _, suffix := range []string{" 2>&1 | head -50", " 2>&1"} {
command = strings.TrimSuffix(command, suffix)
}
argv, err := cli.ParseAgentExampleArgv(command)
if err != nil {
t.Fatalf("parse historical raw command %q without a shell: %v", raw, err)
}
if len(argv) < 3 || argv[0] != "dws" {
t.Fatalf("historical raw command did not produce dws argv: %q => %v", raw, argv)
}
return append([]string(nil), argv[1:]...)
}
func assertHistoricalShortcutRewrite(
t *testing.T,
badcase shortcutHallucinationReplayBadcase,
wantTarget string,
ctx *pipeline.Context,
err error,
) {
t.Helper()
if wantTarget == "" {
t.Fatalf("historical rewrite %q has no reviewed target", badcase.SourcePath)
}
if ctx == nil || ctx.Command != "dws "+wantTarget {
t.Fatalf("historical rewrite context = %#v, error=%v; want command dws %s", ctx, err, wantTarget)
}
found := false
for _, correction := range ctx.Corrections {
if correction.Handler == "command-path-fallback" && correction.Original == badcase.SourcePath && correction.Corrected == wantTarget {
found = true
break
}
}
if !found {
t.Fatalf("historical rewrite corrections = %#v; want %q -> %q", ctx.Corrections, badcase.SourcePath, wantTarget)
}
wantPrefix := strings.Fields(wantTarget)
if len(ctx.Args) < len(wantPrefix) || !reflect.DeepEqual(ctx.Args[:len(wantPrefix)], wantPrefix) {
t.Fatalf("historical rewrite argv = %v, want prefix %v", ctx.Args, wantPrefix)
}
if reason := structuredShortcutReplayReason(err); reason == "unknown_shortcut" || reason == "ambiguous_command_fallback" {
t.Fatalf("historical rewrite returned command-resolution reason %q: %v", reason, err)
}
}
func assertHistoricalShortcutReason(
t *testing.T,
badcase shortcutHallucinationReplayBadcase,
ctx *pipeline.Context,
err error,
wantReason string,
) {
t.Helper()
if ctx == nil {
t.Fatalf("historical %s badcase returned nil context: %v", wantReason, err)
}
if got := structuredShortcutReplayReason(err); got != wantReason {
t.Fatalf("historical badcase %q reason = %q, error=%v; want %q (original exit=%d help=%v)", badcase.Raw, got, err, wantReason, badcase.OriginalExitCode, badcase.IsHelp)
}
if strings.Contains(strings.ToLower(fmt.Sprint(err)), "unknown flag") {
t.Fatalf("historical badcase %q regressed to unknown flag: %v", badcase.Raw, err)
}
}
func structuredShortcutReplayReason(err error) string {
if err == nil {
return ""
}
var structured *apperrors.Error
if stderrors.As(err, &structured) {
return structured.Reason
}
return ""
}
@@ -1,603 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"bytes"
"context"
stderrors "errors"
"io"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
const (
runtimeShortcutFallbackChildEnv = "DWS_RUNTIME_SHORTCUT_FALLBACK_CHILD"
runtimeShortcutFallbackConfigEnv = "DWS_RUNTIME_SHORTCUT_FALLBACK_CONFIG_DIR"
)
func TestCrossPlatformCoverageRuntimeUserShortcutTakesPrecedenceOverReviewedFallback(t *testing.T) {
if os.Getenv(runtimeShortcutFallbackChildEnv) == "1" {
t.Setenv("DWS_CONFIG_DIR", os.Getenv(runtimeShortcutFallbackConfigEnv))
engine := newPipelineEngine()
root := NewRootCommandWithEngine(context.Background(), engine)
runtimeCommand := exactAppCommand(root, "chat +members")
if runtimeCommand == nil || !runtimeCommand.Runnable() || cmdutil.IsHintOnlyCommand(runtimeCommand) {
var userDefined []string
for _, candidate := range shortcut.All() {
if candidate.UserDefined {
userDefined = append(userDefined, candidate.Service+" "+candidate.Command)
}
}
t.Fatalf("runtime shortcut was not mounted as an executable command: command=%#v user_defined=%v config=%q", runtimeCommand, userDefined, os.Getenv("DWS_CONFIG_DIR"))
}
root.SetOut(io.Discard)
root.SetErr(io.Discard)
args := []string{"chat", "+members", "--sentinel", "fixture", "--mock", "--format", "json"}
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, engine, args)
if err != nil {
t.Fatal(err)
}
if ctx == nil || ctx.Command != "dws chat +members" {
t.Fatalf("runtime shortcut context = %#v", ctx)
}
assertNoCommandPathFallbackCorrection(t, ctx)
if err := root.Execute(); err != nil {
t.Fatalf("runtime shortcut execute: %v", err)
}
return
}
configDir := t.TempDir()
shortcutDir := filepath.Join(configDir, "shortcuts")
if err := os.MkdirAll(shortcutDir, 0o700); err != nil {
t.Fatal(err)
}
definition := `version: 1
service: chat
command: "+members"
product: chat
description: runtime members fixture
execute:
tool: fixture_user_members
bind:
sentinel: "${sentinel}"
flags:
- name: sentinel
type: string
required: true
desc: fixture sentinel
`
if err := os.WriteFile(filepath.Join(shortcutDir, "chat.members.yaml"), []byte(definition), 0o600); err != nil {
t.Fatal(err)
}
command := exec.Command(os.Args[0], "-test.run=^TestCrossPlatformCoverageRuntimeUserShortcutTakesPrecedenceOverReviewedFallback$", "-test.count=1")
command.Env = append(os.Environ(), runtimeShortcutFallbackConfigEnv+"="+configDir, runtimeShortcutFallbackChildEnv+"=1")
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("runtime shortcut child failed: %v\n%s", err, strings.TrimSpace(string(output)))
}
if !strings.Contains(string(output), `"_tool": "fixture_user_members"`) {
t.Fatalf("runtime shortcut child used the wrong command:\n%s", strings.TrimSpace(string(output)))
}
}
func TestCrossPlatformCoverageReviewedCommandFallbacksReachCanonicalDryRunPayload(t *testing.T) {
_, canonicalQuery, canonicalQueryAttempts, err := executeParamAliasDryRunE2E(t,
"chat", "+chat-search", "--query", "project", "--dry-run",
)
if err != nil || len(canonicalQueryAttempts) != 0 {
t.Fatalf("canonical query preview = %#v, attempts=%v, error=%v", canonicalQuery, canonicalQueryAttempts, err)
}
tests := []struct {
name string
args []string
wantCommand string
wantPreview paramAliasDryRunPreview
}{
{
name: "group search query",
args: []string{"chat", "+group-search", "--query", "project", "--dry-run"},
wantCommand: "dws chat +chat-search",
wantPreview: canonicalQuery,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctx, preview, attempts, executeErr := executeParamAliasDryRunE2E(t, test.args...)
if executeErr != nil {
t.Fatalf("fallback execute error = %v", executeErr)
}
if len(attempts) != 0 {
t.Fatalf("fallback crossed dry-run dispatch boundary: %#v", attempts)
}
if !reflect.DeepEqual(preview, test.wantPreview) {
t.Fatalf("fallback preview = %#v, want canonical %#v", preview, test.wantPreview)
}
if ctx == nil || ctx.Command != test.wantCommand || len(ctx.Corrections) == 0 {
t.Fatalf("fallback context = %#v, want command %q", ctx, test.wantCommand)
}
correction := ctx.Corrections[0]
if correction.Handler != "command-path-fallback" || correction.Kind != "reviewed-fallback" {
t.Fatalf("fallback correction = %#v", correction)
}
})
}
}
func TestCrossPlatformCoverageOfficialCommandAliasesBypassFallbackAndReachEquivalentPayload(t *testing.T) {
_, canonicalQuery, canonicalQueryAttempts, err := executeParamAliasDryRunE2E(t,
"chat", "+chat-search", "--query", "project", "--dry-run",
)
if err != nil || len(canonicalQueryAttempts) != 0 {
t.Fatalf("canonical query preview = %#v, attempts=%v, error=%v", canonicalQuery, canonicalQueryAttempts, err)
}
_, canonicalKeyword, canonicalKeywordAttempts, err := executeParamAliasDryRunE2E(t,
"chat", "+chat-search", "--keyword", "project", "--dry-run",
)
if err != nil || len(canonicalKeywordAttempts) != 0 {
t.Fatalf("canonical keyword preview = %#v, attempts=%v, error=%v", canonicalKeyword, canonicalKeywordAttempts, err)
}
_, nativeCanonical, nativeCanonicalAttempts, err := executeParamAliasDryRunE2E(t,
"chat", "search", "--query", "project", "--dry-run",
)
if err != nil || len(nativeCanonicalAttempts) != 0 {
t.Fatalf("native canonical preview = %#v, attempts=%v, error=%v", nativeCanonical, nativeCanonicalAttempts, err)
}
tests := []struct {
name string
args []string
wantCommand string
wantPreview paramAliasDryRunPreview
}{
{
name: "search group shortcut alias",
args: []string{"chat", "+search-group", "--keyword", "project", "--dry-run"},
wantCommand: "dws chat +chat-search",
wantPreview: canonicalKeyword,
},
{
name: "chat group search shortcut alias",
args: []string{"chat", "+chat-group-search", "--query", "project", "--dry-run"},
wantCommand: "dws chat +chat-search",
wantPreview: canonicalQuery,
},
{
name: "hidden native compatibility leaf",
args: []string{"chat", "group", "search", "--query", "project", "--dry-run"},
wantCommand: "dws chat group search",
wantPreview: nativeCanonical,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctx, preview, attempts, executeErr := executeParamAliasDryRunE2E(t, test.args...)
if executeErr != nil {
t.Fatalf("official alias execute error = %v", executeErr)
}
if len(attempts) != 0 {
t.Fatalf("official alias crossed dry-run dispatch boundary: %#v", attempts)
}
if !reflect.DeepEqual(preview, test.wantPreview) {
t.Fatalf("official alias preview = %#v, want canonical %#v", preview, test.wantPreview)
}
if ctx == nil || ctx.Command != test.wantCommand {
t.Fatalf("official alias context = %#v, want command %q", ctx, test.wantCommand)
}
assertNoCommandPathFallbackCorrection(t, ctx)
})
}
}
func TestCrossPlatformCoverageReviewedReadFallbacksResolveCanonicalLeafBeforeParameterValidation(t *testing.T) {
tests := []struct {
name string
args []string
target string
}{
{
name: "members",
args: []string{"chat", "+members", "--group", "Fixture Group"},
target: "chat +group-members",
},
{
name: "group member list",
args: []string{"chat", "+group-member-list", "--group-name", "Fixture Group"},
target: "chat +group-members",
},
{
name: "list group bots",
args: []string{"chat", "+list-group-bots", "--group", "cid-fixture"},
target: "chat +chat-bots",
},
{
name: "list robot",
args: []string{"chat", "+list-robot", "--group", "cid-fixture"},
target: "chat +chat-bots",
},
{
name: "list robots",
args: []string{"chat", "+list-robots", "--group", "cid-fixture"},
target: "chat +chat-bots",
},
{
name: "bot list",
args: []string{"chat", "+bot-list", "--group", "cid-fixture"},
target: "chat +chat-bots",
},
{
name: "conversation detail",
args: []string{"chat", "+conversation-detail", "--group", "cid-fixture"},
target: "chat +conversation-info",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(test.args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
if err != nil {
t.Fatal(err)
}
if ctx == nil || ctx.Command != "dws "+test.target || len(ctx.Corrections) == 0 {
t.Fatalf("read fallback context = %#v", ctx)
}
wantPrefix := strings.Fields(test.target)
if len(ctx.Args) < len(wantPrefix) || !reflect.DeepEqual(ctx.Args[:len(wantPrefix)], wantPrefix) {
t.Fatalf("read fallback args = %v, want prefix %v", ctx.Args, wantPrefix)
}
})
}
}
func TestCrossPlatformCoverageOfficialGroupMembersAliasBypassesCommandFallback(t *testing.T) {
args := []string{"chat", "+chat-group-members", "--conversation-id", "cid-fixture", "--member-types", "user"}
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err != nil {
t.Fatal(err)
}
if ctx == nil || ctx.Command != "dws chat +chat-members-list" {
t.Fatalf("official group-members alias context = %#v", ctx)
}
assertNoCommandPathFallbackCorrection(t, ctx)
}
func TestCrossPlatformCoverageReviewedRenameFallbackResolvesCanonicalLeafBeforeParameterValidation(t *testing.T) {
args := []string{"chat", "+rename-group", "--id", "cid-fixture", "--name", "Fixture Group"}
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err != nil {
t.Fatal(err)
}
if ctx == nil || ctx.Command != "dws chat +chat-update" || len(ctx.Corrections) == 0 {
t.Fatalf("rename fallback context = %#v", ctx)
}
wantPrefix := []string{"chat", "+chat-update"}
if len(ctx.Args) < len(wantPrefix) || !reflect.DeepEqual(ctx.Args[:len(wantPrefix)], wantPrefix) {
t.Fatalf("rename fallback args = %v, want prefix %v", ctx.Args, wantPrefix)
}
}
func TestCrossPlatformCoverageReviewedDocHistorySaveFallbacksPreserveArguments(t *testing.T) {
sources := []string{"+create-version", "+save-version", "+snapshot", "+version-create"}
for _, source := range sources {
t.Run(source, func(t *testing.T) {
args := []string{"doc", source, "--node", "node-fixture", "--mock", "--format", "json"}
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err != nil {
t.Fatal(err)
}
if ctx == nil || ctx.Command != "dws doc +history-save" || len(ctx.Corrections) == 0 {
t.Fatalf("history-save fallback context = %#v", ctx)
}
wantArgs := []string{"doc", "+history-save", "--node", "node-fixture", "--mock", "--format", "json"}
if !reflect.DeepEqual(ctx.Args, wantArgs) {
t.Fatalf("history-save fallback args = %v, want %v", ctx.Args, wantArgs)
}
correction := ctx.Corrections[0]
if correction.Handler != "command-path-fallback" || correction.Kind != "reviewed-fallback" {
t.Fatalf("history-save correction = %#v", correction)
}
})
}
}
func TestCrossPlatformCoverageDocExportPDFRemainsUnknownShortcut(t *testing.T) {
if entry, ok := cli.LookupCommandPathFallback("doc +export-pdf"); ok {
t.Fatalf("+export-pdf must not receive a path-only fallback: %#v", entry)
}
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
args := []string{"doc", "+export-pdf", "--node", "node-fixture", "--mock", "--format", "json"}
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if ctx == nil {
t.Fatal("+export-pdf returned nil context")
}
var structured *apperrors.Error
if !stderrors.As(err, &structured) || structured.Reason != "unknown_shortcut" {
t.Fatalf("+export-pdf preparse error = %T %#v", err, err)
}
if len(ctx.Corrections) != 0 {
t.Fatalf("+export-pdf unexpectedly received corrections: %#v", ctx.Corrections)
}
}
func TestCrossPlatformCoverageReviewedAmbiguousCommandFallbackNeverDispatches(t *testing.T) {
tests := []struct {
path string
candidates []string
}{
{path: "chat +group-send-text", candidates: []string{"chat +send-to-group", "chat +messages-send"}},
{path: "chat +message-list", candidates: []string{"chat +chat-messages", "chat +messages-list-direct", "chat +search-msg", "chat +unread-chats"}},
{path: "chat +read-single", candidates: []string{"chat +messages-list-direct", "chat +chat-messages"}},
{path: "chat +send", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
{path: "chat +send-by-bot", candidates: []string{"chat +messages-send", "chat message send-by-bot"}},
{path: "chat +send-dm", candidates: []string{"chat +dm", "chat +messages-send"}},
{path: "chat +send-message", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
{path: "chat +send-single", candidates: []string{"chat +dm", "chat +messages-send"}},
{path: "chat +send-text", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
{path: "chat +send-to", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
{path: "chat +send-file", candidates: []string{"chat +messages-send", "chat message send"}},
{path: "chat +send-image", candidates: []string{"chat +messages-send", "chat message send"}},
{path: "chat +send-media", candidates: []string{"chat +messages-send", "chat message send"}},
{path: "chat +conversation-category-list", candidates: []string{"chat +category-list", "chat +category-list-conversations"}},
{path: "chat +conversation-group-list", candidates: []string{"chat +category-list-conversations", "chat +conversation-list"}},
{path: "chat +list-my-groups", candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"}},
{path: "oa +list-processes", candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}},
}
for _, test := range tests {
t.Run(test.path, func(t *testing.T) {
caller := &paramAliasCaptureCaller{}
args := append(strings.Fields(test.path), "--format", "json")
ctx, err := executeParamAliasE2E(t, caller, args...)
if ctx == nil {
t.Fatal("ambiguous command fallback returned nil context")
}
var structured *apperrors.Error
if !stderrors.As(err, &structured) || structured.Reason != "ambiguous_command_fallback" || structured.ExitCode() != 3 {
t.Fatalf("ambiguous error = %T %#v", err, err)
}
if len(structured.Actions) != len(test.candidates) || len(caller.calls) != 0 {
t.Fatalf("ambiguous actions=%v calls=%#v", structured.Actions, caller.calls)
}
for _, candidate := range test.candidates {
if !strings.Contains(structured.Hint, "dws "+candidate) {
t.Errorf("ambiguous hint %q missing candidate %q", structured.Hint, candidate)
}
}
})
}
}
func TestCrossPlatformCoverageCanonicalShortcutBadFlagDoesNotEnterCommandFallback(t *testing.T) {
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
args := []string{"chat", "+chat-search", "--definitely-not-a-real-flag", "project"}
root.SetArgs(args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
if err != nil {
t.Fatalf("preparse error = %v", err)
}
if ctx == nil || ctx.Command != "dws chat +chat-search" {
t.Fatalf("canonical context = %#v", ctx)
}
for _, correction := range ctx.Corrections {
if correction.Handler == "command-path-fallback" {
t.Fatalf("canonical command received fallback correction: %#v", ctx.Corrections)
}
}
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "unknown flag") {
t.Fatalf("canonical bad flag error = %v", err)
}
}
func TestCrossPlatformCoverageRewrittenShortcutStillUsesCanonicalParameterErrors(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{
name: "missing required query",
args: []string{"chat", "+group-search", "--dry-run"},
want: "请至少指定 --query、--keyword 之一",
},
{
name: "unknown canonical flag",
args: []string{"chat", "+group-search", "--definitely-not-a-real-flag", "project"},
want: "unknown flag",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(test.args)
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
if err != nil {
t.Fatalf("preparse error = %v", err)
}
if ctx == nil || ctx.Command != "dws chat +chat-search" || len(ctx.Corrections) == 0 ||
ctx.Corrections[0].Handler != "command-path-fallback" {
t.Fatalf("rewritten context = %#v", ctx)
}
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("canonical parameter error = %v, want containing %q", err, test.want)
}
})
}
}
func TestCrossPlatformCoverageCommandFallbackNamesStayOutOfHelpSchemaAndShortcutCatalog(t *testing.T) {
invalidShortcuts := map[string]bool{
"+bot-list": true,
"+conversation-detail": true,
"+conversation-category-list": true,
"+conversation-group-list": true,
"+list-my-groups": true,
"+group-search": true,
"+members": true,
"+group-member-list": true,
"+list-group-bots": true,
"+list-robot": true,
"+list-robots": true,
"+message-list": true,
"+read-single": true,
"+rename-group": true,
"+send": true,
"+send-by-bot": true,
"+send-dm": true,
"+send-message": true,
"+send-single": true,
"+send-text": true,
"+send-to": true,
"+send-file": true,
"+send-image": true,
"+send-media": true,
"+group-send-text": true,
}
root := NewSchemaSourceRootCommand()
chat := exactAppCommand(root, "chat")
if chat == nil {
t.Fatal("chat command missing")
}
for _, child := range chat.Commands() {
if invalidShortcuts[child.Name()] {
t.Fatalf("fallback name %q became a real command", child.Name())
}
for _, alias := range child.Aliases {
if invalidShortcuts[alias] {
t.Fatalf("fallback name %q became a Cobra alias", alias)
}
}
}
for path := range invalidShortcuts {
if _, ok := cli.ResolveMeta("chat " + path); ok {
t.Fatalf("fallback path %q leaked into embedded Schema", path)
}
}
for _, declared := range shortcut.All() {
if declared.Service == "chat" && invalidShortcuts[declared.Command] {
t.Fatalf("fallback name %q leaked into shortcut catalog", declared.Command)
}
}
if _, ok := cli.ResolveMeta("oa +list-processes"); ok {
t.Fatal("fallback path oa +list-processes leaked into embedded Schema")
}
for _, declared := range shortcut.All() {
if declared.Service == "oa" && declared.Command == "+list-processes" {
t.Fatal("fallback name +list-processes leaked into shortcut catalog")
}
}
group := exactAppCommand(root, "chat group")
searchCompatibility := exactAppCommand(root, "chat group search")
if group == nil || searchCompatibility == nil || !searchCompatibility.Hidden || cmdutil.IsHintOnlyCommand(searchCompatibility) {
t.Fatalf("chat group search must be a hidden executable compatibility leaf: group=%v search=%v", group, searchCompatibility)
}
var help bytes.Buffer
group.SetOut(&help)
if err := group.Help(); err != nil {
t.Fatal(err)
}
if strings.Contains(help.String(), "\n search ") {
t.Fatalf("hidden fallback source leaked into group help:\n%s", help.String())
}
}
func TestCrossPlatformCoverageDocCommandFallbackNamesStayOutOfHelpSchemaAndShortcutCatalog(t *testing.T) {
invalidShortcuts := map[string]bool{
"+create-version": true,
"+save-version": true,
"+snapshot": true,
"+version-create": true,
"+export-pdf": true,
}
root := NewSchemaSourceRootCommand()
doc := exactAppCommand(root, "doc")
if doc == nil {
t.Fatal("doc command missing")
}
for _, child := range doc.Commands() {
if invalidShortcuts[child.Name()] {
t.Fatalf("invalid shortcut %q became a real command", child.Name())
}
for _, alias := range child.Aliases {
if invalidShortcuts[alias] {
t.Fatalf("invalid shortcut %q became a Cobra alias", alias)
}
}
}
for path := range invalidShortcuts {
if _, ok := cli.ResolveMeta("doc " + path); ok {
t.Fatalf("invalid path %q leaked into embedded Schema", path)
}
}
for _, declared := range shortcut.All() {
if declared.Service == "doc" && invalidShortcuts[declared.Command] {
t.Fatalf("invalid shortcut %q leaked into shortcut catalog", declared.Command)
}
}
}
func assertNoCommandPathFallbackCorrection(t *testing.T, ctx *pipeline.Context) {
t.Helper()
for _, correction := range ctx.Corrections {
if correction.Handler == "command-path-fallback" {
t.Fatalf("official command unexpectedly received fallback correction: %#v", ctx.Corrections)
}
}
}
func exactAppCommand(root *cobra.Command, path string) *cobra.Command {
current := root
parts := strings.Fields(path)
if len(parts) > 0 && parts[0] == root.Name() {
parts = parts[1:]
}
for _, part := range parts {
var next *cobra.Command
for _, child := range current.Commands() {
if child.Name() == part {
next = child
break
}
}
if next == nil {
return nil
}
current = next
}
return current
}
+173 -7
View File
@@ -32,6 +32,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
@@ -226,6 +227,115 @@ func TestCrossPlatformCoverageDocDownloadPureCoverage(t *testing.T) {
}
}
func TestCrossPlatformCoverageRecoveryPureCoverage(t *testing.T) {
if _, err := decodeRecoveryAttempts(nil, nil, "", ""); err != nil {
t.Fatal(err)
}
if _, err := decodeRecoveryAttempts(json.RawMessage("null"), nil, "", ""); err != nil {
t.Fatal(err)
}
if got, err := decodeRecoveryAttempts(json.RawMessage(`[{"command_summary":"one"}]`), nil, "", ""); err != nil || len(got) != 1 {
t.Fatalf("array attempts = %#v %v", got, err)
}
if _, err := decodeRecoveryAttempts(json.RawMessage("{"), nil, "", ""); err == nil {
t.Fatal("malformed attempts succeeded")
}
if got, err := decodeRecoveryAttempts(json.RawMessage("2"), []string{"a"}, "ok", ""); err != nil || len(got) != 2 {
t.Fatalf("legacy attempts = %#v %v", got, err)
}
if legacyRecoveryAttempts(0, nil, "", "") != nil || len(legacyRecoveryAttempts(1, nil, "", "")) != 1 {
t.Fatal("legacy attempts edge mismatch")
}
for _, args := range [][]string{
{"dws", "--debug", "doc", "get", "--node", "n"},
{"dws", "--format=json", "doc", "--", "ignored"},
{"dws", "--unknown", "value", "doc"},
} {
old := os.Args
os.Args = args
_ = currentCommandPath()
os.Args = old
}
for _, inv := range []executor.Invocation{
{LegacyPath: "legacy path"},
{CanonicalProduct: "doc", Tool: "get"},
{CanonicalProduct: "doc"},
{},
} {
old := os.Args
os.Args = []string{"dws"}
_ = runtimeCommandPath(inv)
os.Args = old
}
if cloneRecoveryArgs(nil) != nil {
t.Fatal("empty recovery args should clone to nil")
}
original := map[string]any{"x": 1}
clone := cloneRecoveryArgs(original)
clone["x"] = 2
if original["x"] != 1 {
t.Fatal("recovery args were not cloned")
}
if got, _ := (*recoveryRuntime)(nil).Search(context.Background(), "query", recovery.RecoveryContext{}); got.DocSearch.Status != "skipped" {
t.Fatalf("nil recovery search = %#v", got)
}
if got, _ := (&recoveryRuntime{}).Search(context.Background(), " ", recovery.RecoveryContext{}); got.DocSearch.Status != "skipped" {
t.Fatalf("blank recovery search = %#v", got)
}
if _, err := (*recoveryRuntime)(nil).CallToolDirect(context.Background(), "x", "y", nil); err == nil {
t.Fatal("nil recovery runtime call succeeded")
}
if _, err := (&recoveryRuntime{}).resolveEndpoint(context.Background(), "missing", "tool"); err == nil || !strings.Contains(err.Error(), `endpoint not resolved for product "missing" (tool "tool")`) {
t.Fatalf("missing recovery endpoint error = %v", err)
} else {
var apiErr *apperrors.Error
if !errors.As(err, &apiErr) || apiErr.Category != apperrors.CategoryAPI || apiErr.Operation != "discovery.resolve" || apiErr.Reason != "endpoint_not_resolved" {
t.Fatalf("missing recovery endpoint classification = %#v", err)
}
}
t.Setenv("DINGTALK_OK_MCP_URL", " https://catalog.test ")
runtime := &recoveryRuntime{}
if got, err := runtime.resolveEndpoint(context.Background(), "ok", "tool"); err != nil || got != "https://catalog.test" {
t.Fatalf("recovery endpoint override = %q %v", got, err)
}
if recoveryRuntimeToken(nil) != "" || recoveryRuntimeToken(&GlobalFlags{Token: " token "}) != "token" {
t.Fatal("recovery token mismatch")
}
if toRecoveryToolResponse(nil) != nil {
t.Fatal("nil recovery response should stay nil")
}
response := toRecoveryToolResponse(&transport.ToolCallResult{IsError: true, Blocks: []transport.ContentBlock{{Type: "text", Text: "body"}}})
if response == nil || !response.IsError || len(response.Content) != 1 {
t.Fatalf("recovery response = %#v", response)
}
items := []any{map[string]any{"title": "A", "url": "u", "desc": "d"}, "skip", map[string]any{}}
for _, payload := range []map[string]any{
nil,
{"items": items},
{"data": map[string]any{"items": items}},
{"result": map[string]any{"items": items}},
} {
_ = parseDocSearchItemsFromMap(payload)
}
if toDocSearchItems("bad") != nil {
t.Fatal("non-list doc items accepted")
}
result := &transport.ToolCallResult{Content: map[string]any{}, Blocks: []transport.ContentBlock{{Text: "{"}, {Text: `{"items":[{"title":"B"}]}`}}}
if got := parseDocSearchItems(result); len(got) != 1 {
t.Fatalf("block doc items = %#v", got)
}
if parseDocSearchItems(nil) != nil {
t.Fatal("nil doc result should be nil")
}
searchItems := []recovery.DocSearchItem{{Title: "query", URL: "u"}, {Title: "other"}, {Title: "third"}, {Title: "fourth"}}
if len(rerankDocSearchHits("query", recovery.RecoveryContext{ToolName: "tool", CommandPath: []string{"doc"}}, searchItems)) != 3 || rerankDocSearchHits("", recovery.RecoveryContext{}, nil) != nil {
t.Fatal("doc search reranking mismatch")
}
}
func TestCrossPlatformCoverageSmallAppRegistryAndRootCoverage(t *testing.T) {
RegisterPluginAuth("coverage-registry", &PluginAuth{Token: "token"})
t.Cleanup(func() {
@@ -401,6 +511,59 @@ func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) {
_ = defaultPATMCPEndpoint()
}
func TestCrossPlatformCoverageRecoveryLoadExecutionCoverage(t *testing.T) {
if _, err := loadRecoveryExecution(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Fatal("missing recovery execution succeeded")
}
path := filepath.Join(t.TempDir(), "execution.json")
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := loadRecoveryExecution(path); err == nil {
t.Fatal("malformed recovery execution succeeded")
}
for name, body := range map[string]string{
"legacy": `{"action":" one ","attempt":2,"result":" ok ","error":" bad "}`,
"modern": `{"actions":["one"],"attempts":[{"command_summary":"one"}],"error_summary":"bad"}`,
} {
t.Run(name, func(t *testing.T) {
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
if got, err := loadRecoveryExecution(path); err != nil || len(got.Actions) != 1 || len(got.Attempts) == 0 {
t.Fatalf("loaded execution = %#v %v", got, err)
}
})
}
}
func TestCrossPlatformCoverageRecoveryRuntimeHTTP(t *testing.T) {
var result map[string]any = map[string]any{"content": []map[string]any{{"type": "text", "text": `{"items":[{"title":"query result","url":"u"}]}`}}}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
ID int `json:"id"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result})
}))
defer server.Close()
SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: server.URL, CLI: mcptypes.CLIOverlay{ID: "devdoc", Tools: []mcptypes.CLITool{{Name: "search_open_platform_docs_rag"}}}}})
t.Cleanup(func() { SetDynamicServers(nil) })
runtime := &recoveryRuntime{transport: transport.NewClient(server.Client()), flags: &GlobalFlags{Token: "token"}}
got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{ToolName: "search"})
if err != nil || got.DocSearch.Status != "success" || len(got.KBHits) == 0 {
t.Fatalf("recovery search = %#v %v", got, err)
}
result = map[string]any{"isError": true, "content": []map[string]any{{"type": "text", "text": "failed"}}}
if _, err := runtime.CallToolDirect(context.Background(), "devdoc", "search_open_platform_docs_rag", nil); err == nil {
t.Fatal("recovery MCP error succeeded")
}
server.Close()
if _, err := runtime.CallToolDirect(context.Background(), "devdoc", "search_open_platform_docs_rag", nil); err == nil {
t.Fatal("recovery network error succeeded")
}
}
func TestCrossPlatformCoverageEventCommandPureCoverage(t *testing.T) {
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
@@ -597,7 +760,7 @@ func TestCrossPlatformCoverageVersionCacheCompletionCoverage(t *testing.T) {
child := &cobra.Command{Use: "child"}
root.AddCommand(child)
child.SetOut(io.Discard)
if err := printCacheCompatNotice(child, "dws cache status"); err != nil {
if err := printCacheCompatNotice(child, "status"); err != nil {
t.Fatalf("cache %s: %v", format, err)
}
root.RemoveCommand(child)
@@ -1584,6 +1747,9 @@ func TestCrossPlatformCoverageDoctorCommandCoverage(t *testing.T) {
}
for _, jsonOut := range []bool{false, true} {
if got := doctorCheckCache(io.Discard, jsonOut); got.Status != statusPass {
t.Fatal("cache check failed")
}
if got := doctorCheckPerf(io.Discard, jsonOut); got.Status != statusPass {
t.Fatalf("perf check = %#v", got)
}
@@ -2002,7 +2168,7 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
}
_ = os.Symlink(filepath.Join(mono, "SKILL.md"), filepath.Join(mono, "linked.md"))
multi := filepath.Join(t.TempDir(), "multi")
for _, name := range []string{"dingtalk-shared", "dingtalk-a", "dingtalk-b"} {
for _, name := range []string{"dws-shared", "dingtalk-a", "dingtalk-b"} {
dir := filepath.Join(multi, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
@@ -2027,10 +2193,10 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")); err != nil {
t.Fatal(err)
}
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") {
t.Fatalf("multi setup = %q, %v", output, err)
if output, warnings, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") || warnings == "" {
t.Fatalf("multi setup = %q / %q, %v", output, warnings, err)
}
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-shared", "SKILL.md")); err != nil {
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws-shared", "SKILL.md")); err != nil {
t.Fatal(err)
}
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--dry-run", "--exclude", "b"); err != nil || !strings.Contains(output, "DRY-RUN") {
@@ -2054,7 +2220,7 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
}
func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
all := []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}
all := []string{"dingtalk-a", "dingtalk-b", "dws-shared"}
for _, tc := range []struct {
include []string
exclude []string
@@ -2072,7 +2238,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
t.Errorf("filter %#v/%#v = %v", tc.include, tc.exclude, err)
}
}
for _, selected := range [][]string{nil, {"dingtalk-shared"}, {"dingtalk-a"}} {
for _, selected := range [][]string{nil, {"dws-shared"}, {"dingtalk-a"}} {
_ = ensureMandatorySharedSkill(selected, all)
}
_ = ensureMandatorySharedSkill([]string{"dingtalk-a"}, []string{"dingtalk-a"})
+1
View File
@@ -302,6 +302,7 @@ func TestCrossPlatformCoverageRootUtilityAndTimingCoverage(t *testing.T) {
_ = newConfigCommand()
_ = newCacheCommand()
_ = newVersionCommand()
_ = newRecoveryCommand(&GlobalFlags{})
_ = newAPICommand(&GlobalFlags{})
_ = NewRootCommand(context.Background())
}
+22 -1
View File
@@ -61,7 +61,7 @@ func newDoctorCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "doctor",
Short: "环境健康检查",
Long: "一键检查登录态、网络连通性和版本更新,快速定位常见问题。",
Long: "一键检查登录态、网络连通性、缓存状态和版本更新,快速定位常见问题。",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: runDoctor,
@@ -92,6 +92,9 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
networkResult := doctorCheckNetwork(cmd.Context(), w, jsonOut, networkTimeout)
checks = append(checks, networkResult)
cacheResult := doctorCheckCache(w, jsonOut)
checks = append(checks, cacheResult)
versionResult := doctorCheckVersion(w, jsonOut, networkTimeout)
checks = append(checks, versionResult)
@@ -294,6 +297,24 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
return r
}
// ── Cache check ─────────────────────────────────────────────────────────
func doctorCheckCache(w io.Writer, jsonOut bool) checkResult {
if !jsonOut {
fmt.Fprint(w, tui.Dim("检查缓存状态... "))
}
r := checkResult{
Name: "cache",
Status: statusPass,
Message: "静态端点模式, 无需缓存",
}
if !jsonOut {
printCheckResult(w, r)
}
return r
}
// ── Version check ───────────────────────────────────────────────────────
func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
+25
View File
@@ -109,6 +109,31 @@ func TestPrintCheckResultNoHint(t *testing.T) {
}
}
func TestDoctorCheckCacheEmpty(t *testing.T) {
t.Setenv("DWS_CACHE_DIR", t.TempDir())
var buf bytes.Buffer
r := doctorCheckCache(&buf, false)
if r.Status != statusPass {
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
}
}
func TestDoctorCheckCacheEmptyJSON(t *testing.T) {
t.Setenv("DWS_CACHE_DIR", t.TempDir())
var buf bytes.Buffer
r := doctorCheckCache(&buf, true)
if r.Status != statusPass {
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
}
if buf.Len() != 0 {
t.Error("expected no output in JSON mode")
}
}
func TestDoctorCheckAuthReportsKeychainUnavailable(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
+12 -12
View File
@@ -80,12 +80,12 @@ func newEventCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "event",
Selection: contract.ProductSelectionDecl{
AgentSummary: "实时监听当前用户相关的个人 IM 与 OA 审批事件,并管理订阅生命周期",
AgentSummary: "订阅/消费个人消息、动作与群生命周期事件,并管理订阅生命周期",
UseWhen: []string{
"需要实时监听未来发生的个人消息、消息动作、群生命周期或 OA 审批任务/实例事件,或管理个人事件订阅生命周期",
"需要实时监听个人消息接收、全量消息、已读、撤回、表情回应或群生命周期事件,或管理个人事件订阅生命周期",
},
AvoidWhen: []string{
"查历史聊天或主动发消息用 chat;查询或处理审批实例/任务用 oa;配置开放平台应用事件回调用 dev app event",
"查历史聊天或主动发消息分别用 chat 查询/发送命令",
},
},
})
@@ -99,7 +99,6 @@ func newEventCommand() *cobra.Command {
RunE: func(c *cobra.Command, _ []string) error { return c.Help() },
}
cmd.AddCommand(
newEventListenIMCommand(),
newEventConsumeCommand(),
newEventListCommand(),
newEventSchemaCommand(),
@@ -333,7 +332,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
f.BoolVar(&force, "force", false,
"仅 --foreground 模式生效:跳过单实例锁 (慎用:会让云事件被随机切分)")
f.BoolVar(&dryRun, "dry-run", false,
"仅打印解析后的配置;不创建订阅、不连接 bus;复用 --subscribe-id 时会只读查询控制面")
"仅打印解析后的配置,不连接 bus / 云端")
f.BoolVar(&foreground, "foreground", false,
"当前进程直接跑 bus 服务、不 fork、不打印事件(给 systemd/k8s 托管用);读事件不要用它")
f.StringVar(&personalOpts.SubscribeID, "subscribe-id", "",
@@ -398,21 +397,22 @@ 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: "消费 OA、群生命周期或需要底层控制的个人事件流;Agent 通常使用 --flatten 输出 NDJSON",
AgentSummary: "订阅并持续消费一个或多个兼容的个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
UseWhen: []string{
"需要监听六个公开 OA 审批任务/实例 EventKey 中的一个或多个事件",
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"用户明确要求监听当前身份的所有单聊或所有群消息",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"需要监听指定群的标题变更、成员进退群或群解散事件",
"用户显式给出原始 EventKey、Filter DSL、subscribe_id,要求原始 transport envelope,或需要普通 IM facade 不提供的高级多事件控制",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标",
"同一目标、同一过滤条件需要同时监听多个兼容事件",
},
AvoidWhen: []string{
"普通 @我、指定发送人/群、全部单聊/群聊及 message/reaction/read/recall 监听优先使用 event +listen-im",
"只查历史聊天记录时用 chat 查询命令",
"查询、同意、拒绝、转交、撤销或发起审批时用 oa;配置应用事件回调时用 dev app event",
"只看事件目录/字段时用 event list / event schema",
},
Examples: []string{
"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",
"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",
},
},
},
-295
View File
@@ -1,295 +0,0 @@
// 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 (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
"github.com/spf13/cobra"
)
type listenIMOptions struct {
Kind string
Events []string
UserID string
OpenDingTalkID string
UserQuery string
ChatID string
ChatQuery string
QueryCSV string
MaxEvents int
Duration time.Duration
DryRun bool
ControlBaseURL string
StreamTicketMode string
StreamTicketURL string
StreamSourceID string
}
type listenIMPlan struct {
EventKeys []string
UserID string
OpenDingTalkID string
GroupID string
ResolvedTargets []any
}
type eventTargetReader struct{}
func (eventTargetReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
text, err := helpers.CallMCPReadToolTextOnServer(product, tool, params)
if err != nil {
return nil, err
}
if strings.TrimSpace(text) == "" {
return map[string]any{}, nil
}
var data map[string]any
if err := json.Unmarshal([]byte(text), &data); err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err))
}
return data, nil
}
var eventListenIMReader = func() targetresolver.Reader { return eventTargetReader{} }
func newEventListenIMCommand() *cobra.Command {
var opts listenIMOptions
cmd := &cobra.Command{
Use: "+listen-im",
Short: "按 IM 意图解析目标并监听一个或多个个人消息事件",
Long: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey," +
"自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期;本命令只处理 IM,不接收 OA 审批事件。",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, _ []string) error {
plan, err := compileListenIMPlan(eventListenIMReader(), opts)
if err != nil {
return fmt.Errorf("event +listen-im: %w", err)
}
consumeOpts := personalConsumeOptions{
EventKey: firstArg(plan.EventKeys),
EventKeys: plan.EventKeys,
Flatten: true,
UserID: plan.UserID,
OpenDingTalkID: plan.OpenDingTalkID,
GroupID: plan.GroupID,
QueryCSV: opts.QueryCSV,
ControlBaseURL: opts.ControlBaseURL,
StreamTicketMode: opts.StreamTicketMode,
StreamTicketURL: opts.StreamTicketURL,
StreamSourceID: opts.StreamSourceID,
Common: commonConsumeOptions{
FormatRaw: "ndjson",
MaxEvents: opts.MaxEvents,
Duration: opts.Duration,
DryRun: opts.DryRun,
},
}
return eventRunPersonalConsume(c, consumeOpts)
},
}
f := cmd.Flags()
f.StringVar(&opts.Kind, "kind", "at-me", "监听意图: at-me|sender|group|all-direct|all-group")
f.StringSliceVar(&opts.Events, "events", []string{"message"}, "事件种类: message,reaction,read,recall")
f.StringVar(&opts.UserID, "user", "", "指定发送人/单聊对端 userId")
f.StringVar(&opts.OpenDingTalkID, "open-dingtalk-id", "", "指定发送人/单聊对端 openDingTalkId")
f.StringVar(&opts.UserQuery, "user-query", "", "按姓名/花名唯一解析指定发送人")
f.StringVar(&opts.ChatID, "chat-id", "", "指定群 openConversationId")
f.StringVar(&opts.ChatQuery, "chat-query", "", "按群名唯一解析指定群")
f.StringVar(&opts.QueryCSV, "query", "", "消息文本关键词过滤,逗号分隔;仅 message 事件")
f.IntVar(&opts.MaxEvents, "max-events", 0, "收到 N 条后退出 (0 = 不限)")
f.DurationVar(&opts.Duration, "duration", 0, "运行时长上限 (Go duration,如 30s/5m;0 = 不限)")
f.BoolVar(&opts.DryRun, "dry-run", false, "解析目标并打印订阅计划,不创建订阅或连接 bus")
f.StringVar(&opts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base 派生 /dws")
f.StringVar(&opts.StreamTicketMode, "stream-ticket-mode", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_MODE")), "个人 Stream 建联模式;默认 normal")
f.StringVar(&opts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")), "个人 Stream sourceId;开源版默认 open")
f.StringVar(&opts.StreamTicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")), "个人 Stream 取票 URL")
hideEventInternalFlags(cmd, "personal-event-base-url", "stream-ticket-mode", "stream-source-id", "stream-ticket-url")
cli.AnnotateRuntimeFlagEnum(cmd, "kind", "at-me", "sender", "group", "all-direct", "all-group")
cli.AnnotateRuntimeFlagEnum(cmd, "events", "message", "reaction", "read", "recall")
cli.AnnotateRuntimeConstraints(cmd, cli.RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"user", "open-dingtalk-id", "user-query", "chat-id", "chat-query"}},
})
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: helpers.LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "event",
Name: "listen_im",
CanonicalPath: "event.listen_im",
CLIPath: "event +listen-im",
PrimaryCLIPath: "event +listen-im",
},
Description: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey,自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期。",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
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 message/reaction/read/recall 事件",
UseWhen: []string{
"已知要监听 @我、指定发送人、指定群、全部单聊或全部群聊的 message/reaction/read/recall 事件时使用;姓名用 --user-query、群名用 --chat-query,CLI 会唯一解析目标并把多个兼容事件合并到一个消费生命周期。",
},
AvoidWhen: []string{
"OA 审批事件、群标题/成员/解散等生命周期事件、显式 EventKey、复用 subscribe_id、Filter DSL、原始 transport envelope 或其它底层控制使用 event consume;只查历史消息使用 chat 查询入口",
},
Examples: []string{
"dws event +listen-im --kind at-me --max-events 1",
"dws event +listen-im --kind group --events message,reaction --chat-id <openConversationId> --duration 10m",
},
},
Parameters: []contract.ParamDecl{
{Name: "chat-id", Property: "chatId"},
{Name: "chat-query", Property: "chatQuery"},
{Name: "dry-run", Property: "dryRun"},
{Name: "duration", Property: "duration"},
{Name: "events", Property: "events"},
{Name: "kind", Property: "kind"},
{Name: "max-events", Property: "maxEvents"},
{Name: "open-dingtalk-id", Property: "openDingtalkId"},
{Name: "query", Property: "query"},
{Name: "user", Property: "user"},
{Name: "user-query", Property: "userQuery"},
},
},
})
return cmd
}
func compileListenIMPlan(reader targetresolver.Reader, opts listenIMOptions) (listenIMPlan, error) {
kind := strings.ToLower(strings.TrimSpace(opts.Kind))
if kind == "" {
kind = "at-me"
}
events := uniqueListenIMValues(opts.Events)
if len(events) == 0 {
return listenIMPlan{}, apperrors.NewValidation("--events 至少包含一个事件种类")
}
if strings.TrimSpace(opts.QueryCSV) != "" {
for _, eventName := range events {
if eventName != "message" {
return listenIMPlan{}, apperrors.NewValidation("--query 只支持 message 事件")
}
}
}
plan := listenIMPlan{}
var err error
switch kind {
case "at-me", "all-direct", "all-group":
if listenIMTargetCount(opts) != 0 {
return listenIMPlan{}, apperrors.NewValidation(fmt.Sprintf("--kind %s 不接受用户或群目标", kind))
}
case "sender":
if listenIMUserTargetCount(opts) != 1 || listenIMChatTargetCount(opts) != 0 {
return listenIMPlan{}, apperrors.NewValidation("--kind sender 必须且只能指定 --user、--open-dingtalk-id 或 --user-query 之一")
}
plan.UserID = strings.TrimSpace(opts.UserID)
plan.OpenDingTalkID = strings.TrimSpace(opts.OpenDingTalkID)
if query := strings.TrimSpace(opts.UserQuery); query != "" {
resolved, resolveErr := targetresolver.ResolveUser(reader, query, targetresolver.IdentityAny)
if resolveErr != nil {
return listenIMPlan{}, resolveErr
}
plan.ResolvedTargets = append(plan.ResolvedTargets, resolved)
plan.UserID = resolved.Selected.UserID
if plan.UserID == "" {
plan.OpenDingTalkID = resolved.Selected.OpenDingTalkID
}
}
case "group":
if listenIMChatTargetCount(opts) != 1 || listenIMUserTargetCount(opts) != 0 {
return listenIMPlan{}, apperrors.NewValidation("--kind group 必须且只能指定 --chat-id 或 --chat-query 之一")
}
plan.GroupID = strings.TrimSpace(opts.ChatID)
if query := strings.TrimSpace(opts.ChatQuery); query != "" {
resolved, resolveErr := targetresolver.ResolveChat(reader, query)
if resolveErr != nil {
return listenIMPlan{}, resolveErr
}
plan.ResolvedTargets = append(plan.ResolvedTargets, resolved)
plan.GroupID = resolved.Selected.OpenConversationID
}
default:
return listenIMPlan{}, apperrors.NewValidation("--kind 必须是 at-me、sender、group、all-direct 或 all-group")
}
plan.EventKeys, err = listenIMEventKeys(kind, events)
if err != nil {
return listenIMPlan{}, err
}
return plan, nil
}
func listenIMEventKeys(kind string, events []string) ([]string, error) {
mapping := map[string]map[string]string{
"at-me": {"message": personal.EventMention},
"sender": {"message": personal.EventFromUser, "reaction": personal.EventReactionO2O, "read": personal.EventReadO2O, "recall": personal.EventRecallO2O},
"group": {"message": personal.EventInChat, "reaction": personal.EventReactionGroup, "read": personal.EventReadGroup, "recall": personal.EventRecallGroup},
"all-direct": {"message": personal.EventAllSingleChat},
"all-group": {"message": personal.EventAllGroupChat},
}
byEvent := mapping[kind]
keys := make([]string, 0, len(events))
for _, eventName := range events {
key := byEvent[eventName]
if key == "" {
return nil, apperrors.NewValidation(fmt.Sprintf("--kind %s 不支持 event %s", kind, eventName))
}
keys = append(keys, key)
}
return keys, nil
}
func listenIMUserTargetCount(opts listenIMOptions) int {
return nonEmptyListenIMCount(opts.UserID, opts.OpenDingTalkID, opts.UserQuery)
}
func listenIMChatTargetCount(opts listenIMOptions) int {
return nonEmptyListenIMCount(opts.ChatID, opts.ChatQuery)
}
func listenIMTargetCount(opts listenIMOptions) int {
return listenIMUserTargetCount(opts) + listenIMChatTargetCount(opts)
}
func nonEmptyListenIMCount(values ...string) int {
count := 0
for _, value := range values {
if strings.TrimSpace(value) != "" {
count++
}
}
return count
}
func uniqueListenIMValues(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}
-360
View File
@@ -1,360 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"bytes"
"context"
"errors"
"fmt"
"reflect"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"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/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type listenIMFakeReader struct {
responses map[string]map[string]any
calls []string
}
type listenIMErrorReader struct{ err error }
func (r listenIMErrorReader) CallMCPData(string, string, map[string]any) (map[string]any, error) {
return nil, r.err
}
type listenIMHelperCaller struct {
text string
err error
}
func (c listenIMHelperCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return c.result()
}
func (c listenIMHelperCaller) CallReadTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return c.result()
}
func (c listenIMHelperCaller) result() (*edition.ToolResult, error) {
if c.err != nil {
return nil, c.err
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: c.text}}}, nil
}
func (listenIMHelperCaller) Format() string { return "json" }
func (listenIMHelperCaller) DryRun() bool { return false }
func (listenIMHelperCaller) Fields() string { return "" }
func (listenIMHelperCaller) JQ() string { return "" }
func (f *listenIMFakeReader) CallMCPData(product, tool string, _ map[string]any) (map[string]any, error) {
key := product + "/" + tool
f.calls = append(f.calls, key)
if response, ok := f.responses[key]; ok {
return response, nil
}
return map[string]any{}, nil
}
func TestCrossPlatformCoverageCompileListenIMPlanResolvesGroupAndMapsMultipleEvents(t *testing.T) {
reader := &listenIMFakeReader{responses: map[string]map[string]any{
"im/search_groups": {
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
},
}}
plan, err := compileListenIMPlan(reader, listenIMOptions{
Kind: "group",
Events: []string{"message", "reaction", "recall"},
ChatQuery: "项目群",
})
if err != nil {
t.Fatal(err)
}
wantKeys := []string{personal.EventInChat, personal.EventReactionGroup, personal.EventRecallGroup}
if !reflect.DeepEqual(plan.EventKeys, wantKeys) || plan.GroupID != "cid-1" {
t.Fatalf("plan = %#v, want keys=%v group=cid-1", plan, wantKeys)
}
if !reflect.DeepEqual(reader.calls, []string{"im/search_groups"}) {
t.Fatalf("resolver calls = %#v", reader.calls)
}
}
func TestCrossPlatformCoverageCompileListenIMPlanReturnsStructuredAmbiguityBeforeSubscription(t *testing.T) {
reader := &listenIMFakeReader{responses: map[string]map[string]any{
"contact/search_contact_by_key_word": {
"result": []any{
map[string]any{"name": "张三", "userId": "u1"},
map[string]any{"name": "张三", "userId": "u2"},
},
},
}}
_, err := compileListenIMPlan(reader, listenIMOptions{
Kind: "sender",
Events: []string{"message"},
UserQuery: "张三",
})
if err == nil {
t.Fatal("ambiguous sender unexpectedly compiled")
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "resolution_ambiguous" {
t.Fatalf("ambiguity error = %#v", err)
}
}
func TestCrossPlatformCoverageEventListenIMCommandDelegatesOneCompiledConsumeLifecycle(t *testing.T) {
reader := &listenIMFakeReader{responses: map[string]map[string]any{
"im/search_groups": {
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
},
}}
oldReader := eventListenIMReader
oldRun := eventRunPersonalConsume
t.Cleanup(func() {
eventListenIMReader = oldReader
eventRunPersonalConsume = oldRun
})
eventListenIMReader = func() targetresolver.Reader { return reader }
var captured personalConsumeOptions
var calls int
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
calls++
captured = opts
return nil
}
cmd := newEventListenIMCommand()
cmd.SetArgs([]string{
"--kind", "group",
"--events", "message,reaction",
"--chat-query", "项目群",
"--max-events", "2",
"--duration", "30s",
"--dry-run",
})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if calls != 1 {
t.Fatalf("consume lifecycle calls = %d, want 1", calls)
}
if !reflect.DeepEqual(captured.EventKeys, []string{personal.EventInChat, personal.EventReactionGroup}) ||
captured.GroupID != "cid-1" || !captured.Flatten || !captured.Common.DryRun ||
captured.Common.MaxEvents != 2 || captured.Common.Duration.String() != "30s" {
t.Fatalf("captured options = %#v", captured)
}
}
func TestCrossPlatformCoverageCompileListenIMPlanRejectsIncompatibleKindAndTargets(t *testing.T) {
reader := &listenIMFakeReader{}
cases := []listenIMOptions{
{Kind: "at-me", Events: []string{"reaction"}},
{Kind: "all-group", Events: []string{"message"}, ChatID: "cid"},
{Kind: "sender", Events: []string{"message"}},
{Kind: "group", Events: []string{"message"}, ChatID: "cid", ChatQuery: "群"},
{Kind: "group", Events: []string{"message", "reaction"}, ChatID: "cid", QueryCSV: "关键词"},
}
for _, opts := range cases {
if _, err := compileListenIMPlan(reader, opts); err == nil {
t.Errorf("options unexpectedly accepted: %#v", opts)
}
}
}
func TestCrossPlatformCoverageListenIMCompletionBranches(t *testing.T) {
for _, tc := range []struct {
name string
text string
err error
ok bool
}{
{name: "transport", err: errors.New("transport")},
{name: "empty", text: " ", ok: true},
{name: "invalid json", text: "{invalid"},
{name: "valid", text: `{"result":{"ok":true}}`, ok: true},
} {
t.Run("reader "+tc.name, func(t *testing.T) {
helpers.InitDeps(listenIMHelperCaller{text: tc.text, err: tc.err})
data, err := (eventTargetReader{}).CallMCPData("im", "search_groups", nil)
if (err == nil) != tc.ok {
t.Fatalf("data=%#v error=%v ok=%v", data, err, tc.ok)
}
})
}
if plan, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Events: []string{" MESSAGE ", "message"}}); err != nil || len(plan.EventKeys) != 1 {
t.Fatalf("default/deduplicated plan = %#v, %v", plan, err)
}
if _, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Kind: "at-me"}); err == nil {
t.Fatal("empty event set unexpectedly accepted")
}
if _, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Kind: "unknown", Events: []string{"message"}}); err == nil {
t.Fatal("unknown kind unexpectedly accepted")
}
reader := &listenIMFakeReader{responses: map[string]map[string]any{
"contact/search_contact_by_key_word": {
"result": []any{map[string]any{"name": "甲", "openDingTalkId": "D-user"}},
},
}}
plan, err := compileListenIMPlan(reader, listenIMOptions{Kind: "sender", Events: []string{"message"}, UserQuery: "甲"})
if err != nil || plan.UserID != "" || plan.OpenDingTalkID != "D-user" {
t.Fatalf("open-id sender plan = %#v, %v", plan, err)
}
wantErr := errors.New("resolution failed")
if _, err := compileListenIMPlan(listenIMErrorReader{err: wantErr}, listenIMOptions{Kind: "sender", Events: []string{"message"}, UserQuery: "甲"}); !errors.Is(err, wantErr) {
t.Fatalf("sender resolution error = %v", err)
}
if _, err := compileListenIMPlan(listenIMErrorReader{err: wantErr}, listenIMOptions{Kind: "group", Events: []string{"message"}, ChatQuery: "群"}); !errors.Is(err, wantErr) {
t.Fatalf("group resolution error = %v", err)
}
cmd := newEventListenIMCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{"--kind", "sender"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "event +listen-im") {
t.Fatalf("command compile error = %v", err)
}
}
func TestCrossPlatformCoverageEventListenIME2ELifecycleCleansAndRollsBack(t *testing.T) {
newReader := func() *listenIMFakeReader {
return &listenIMFakeReader{responses: map[string]map[string]any{
"im/search_groups": {
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
},
}}
}
installFacade := func(t *testing.T, reader *listenIMFakeReader) {
t.Helper()
oldReader := eventListenIMReader
oldRun := eventRunPersonalConsume
t.Cleanup(func() {
eventListenIMReader = oldReader
eventRunPersonalConsume = oldRun
})
eventListenIMReader = func() targetresolver.Reader { return reader }
eventRunPersonalConsume = runPersonalEventConsume
}
installLifecycle := func(t *testing.T) {
t.Helper()
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", CorpID: "corp", UserID: "user",
ClientID: "client", SourceID: "open",
}, nil
}
personalUpsertRunState = func(string, personal.RunState) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
}
t.Run("ready then clean every created subscription", func(t *testing.T) {
reader := newReader()
installFacade(t, reader)
installLifecycle(t)
var created, deleted, removed []string
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
created = append(created, opts.EventKey)
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}, opts.EventKey, "group", nil
}
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
deleted = append(deleted, id)
return nil
}
personalRemoveRunStates = func(_ string, ids []string) error {
removed = append(removed, ids...)
return nil
}
personalConsumeRunMany = func(_ context.Context, cfg consume.Config, specs []consume.ConsumerSpec) error {
if len(specs) != 2 || !cfg.Flatten {
t.Fatalf("consume specs/config = %#v / %#v", specs, cfg)
}
fmt.Fprintf(cfg.Stderr, "[event] ready event_count=%d bus_pid=123\n", len(specs))
return nil
}
cmd := newEventListenIMCommand()
var stderr bytes.Buffer
cmd.SetErr(&stderr)
cmd.SetArgs([]string{
"--kind", "group", "--events", "message,reaction",
"--chat-query", "项目群", "--max-events", "1",
})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
wantEvents := []string{personal.EventInChat, personal.EventReactionGroup}
if !reflect.DeepEqual(created, wantEvents) {
t.Fatalf("created = %#v, want %#v", created, wantEvents)
}
wantDeleted := []string{"sub-" + personal.EventReactionGroup, "sub-" + personal.EventInChat}
if !reflect.DeepEqual(deleted, wantDeleted) || !reflect.DeepEqual(removed, wantDeleted) {
t.Fatalf("deleted=%#v removed=%#v want=%#v", deleted, removed, wantDeleted)
}
if !strings.Contains(stderr.String(), "[event] ready event_count=2") {
t.Fatalf("missing ready marker: %s", stderr.String())
}
if !reflect.DeepEqual(reader.calls, []string{"im/search_groups"}) {
t.Fatalf("resolver calls = %#v", reader.calls)
}
})
t.Run("second create failure rolls back first without starting consumer", func(t *testing.T) {
reader := newReader()
installFacade(t, reader)
installLifecycle(t)
wantErr := errors.New("second subscription failed")
calls := 0
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
calls++
if calls == 2 {
return nil, "", "", wantErr
}
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "group", nil
}
var deleted, removed []string
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
deleted = append(deleted, id)
return nil
}
personalRemoveRunStates = func(_ string, ids []string) error {
removed = append(removed, ids...)
return nil
}
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error {
t.Fatal("consumer started after partial subscription failure")
return nil
}
cmd := newEventListenIMCommand()
var stderr bytes.Buffer
cmd.SetErr(&stderr)
cmd.SilenceUsage = true
cmd.SetArgs([]string{
"--kind", "group", "--events", "message,reaction",
"--chat-query", "项目群",
})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), wantErr.Error()) {
t.Fatalf("error = %v, want %v", err, wantErr)
}
if !reflect.DeepEqual(deleted, []string{"sub-first"}) || !reflect.DeepEqual(removed, []string{"sub-first"}) {
t.Fatalf("rollback deleted=%#v removed=%#v", deleted, removed)
}
})
}
+2 -2
View File
@@ -152,8 +152,8 @@ func TestCrossPlatformCoveragePersonalSubscriptionProtectionCoversAllPublicEvent
}
}
if publicCount != 22 {
t.Fatalf("public personal events = %d, want 22 (16 IM + 6 OA)", publicCount)
if publicCount != 16 {
t.Fatalf("public personal events = %d, want 16", publicCount)
}
for _, ruleType := range []string{"at", "all", "singleChat", "sender", "group"} {
if !ruleTypes[ruleType] {
+26 -97
View File
@@ -26,7 +26,6 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"text/tabwriter"
"time"
@@ -202,15 +201,12 @@ func newEventSchemaCommand() *cobra.Command {
},
Selection: contract.SelectionSpec{
AgentSummary: "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
UseWhen: []string{"已知任一公开个人 IM 或 OA event_key,消费前需要理解 --flatten 输出字段或 payload 契约"},
UseWhen: []string{"已知任一公开个人 IM event_key,消费前需要理解输出字段或保守 payload 契约"},
AvoidWhen: []string{
"查询 CLI 命令参数契约时用顶层 dws schema",
"要实际收事件时用 event consume",
},
Examples: []string{
"dws event schema user_im_message_receive_at --flatten --format json",
"dws event schema user_oa_approval_task_created --flatten --format json",
},
Examples: []string{"dws event schema user_im_message_receive_at --flatten --format json"},
},
},
})
@@ -266,9 +262,6 @@ 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
@@ -297,18 +290,11 @@ 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,
@@ -383,6 +369,7 @@ 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(
@@ -421,17 +408,9 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
)
return fmt.Errorf("event consume --as user: %w", err)
}
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})
})
cleanup := func(cleanupCtx context.Context) {
_ = personalDeleteSubscription(client, cleanupCtx, sub.SubscribeID)
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
}
if err := personalUpsertRunState(workDir, personal.RunState{
SubscribeID: sub.SubscribeID,
@@ -442,19 +421,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 {
cleanupOwnedSubscription(context.Background())
cleanup(context.Background())
return fmt.Errorf("event consume --as user: %w", err)
}
// Ownership-based cleanup: a subscription this run CREATED is
@@ -463,8 +442,9 @@ 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.
if ownsSubscription {
defer cleanupOwnedSubscription(context.Background())
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
if opts.Ephemeral || selfCreated {
defer cleanup(context.Background())
}
cfg.EventKey = eventKey
@@ -486,9 +466,17 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
Source: foregroundSource,
}
bus.ApplyEnvTuning(&busCfg)
return personalBusRun(ctx, busCfg)
err = personalBusRun(ctx, busCfg)
if err != nil && !opts.Ephemeral {
cleanup(context.Background())
}
return err
}
return personalConsumeRun(ctx, cfg)
err = personalConsumeRun(ctx, cfg)
if err != nil && !opts.Ephemeral {
cleanup(context.Background())
}
return err
}
type personalMultiSubscription struct {
@@ -700,9 +688,6 @@ 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
@@ -829,9 +814,6 @@ 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,
@@ -844,37 +826,6 @@ 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
@@ -888,9 +839,6 @@ 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,
@@ -937,32 +885,13 @@ func ensurePersonalSubscription(ctx context.Context, client *personal.Client, id
if err != nil {
return nil, "", "", err
}
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
}
eventKey := firstNonEmptyPersonalString(opts.EventKey, sub.EventKey)
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,7 +18,6 @@ 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"
)
@@ -37,20 +36,18 @@ func TestCrossPlatformCoveragePersonalEventRemainingSchemaAndSubscriptionCoverag
}
}
testseam.Protect(t, &personalGetSubscription)
testseam.Protect(t, &personalCreateSubscription)
oldGet := personalGetSubscription
oldCreate := personalCreateSubscription
t.Cleanup(func() {
personalGetSubscription = oldGet
personalCreateSubscription = oldCreate
})
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
}
-627
View File
@@ -1,627 +0,0 @@
// 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)
}
}
@@ -1,427 +0,0 @@
// 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)
}
}
}
+3 -3
View File
@@ -10,7 +10,7 @@ import (
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
root := &cobra.Command{Use: "dws"}
event := newEventCommand()
markdown := &cobra.Command{Use: "markdown"}
@@ -37,7 +37,7 @@ func TestCrossPlatformCoverageEventCommandRemainsVisibleAsBuiltInPublicGroup(t *
leaves = append(leaves, command.Name())
}
sort.Strings(leaves)
want := []string{"+listen-im", "consume", "list", "schema", "status", "stop"}
want := []string{"consume", "list", "schema", "status", "stop"}
if len(leaves) != len(want) {
t.Fatalf("public event leaves = %v, want %v", leaves, want)
}
@@ -48,7 +48,7 @@ func TestCrossPlatformCoverageEventCommandRemainsVisibleAsBuiltInPublicGroup(t *
}
}
func TestCrossPlatformCoveragePluginCannotReplaceBuiltInEventCommand(t *testing.T) {
func TestPluginCannotReplaceBuiltInEventCommand(t *testing.T) {
root := &cobra.Command{Use: "dws"}
builtIn := newEventCommand()
root.AddCommand(builtIn)
@@ -1,74 +0,0 @@
// 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)
}
}
}
@@ -10,7 +10,7 @@ import (
// TestMultiSkillSharedContractKeepsAccountSafetyRule pins the multi-account
// safety rule that release run 30437390088 found missing: the MultiSkill e2e
// contract asserts the exact phrase below inside the installed
// dingtalk-shared/SKILL.md, so removing it from the embedded skill source must
// dws-shared/SKILL.md, so removing it from the embedded skill source must
// fail at PR time instead of at release time.
func TestMultiSkillSharedContractKeepsAccountSafetyRule(t *testing.T) {
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
@@ -19,12 +19,12 @@ func TestMultiSkillSharedContractKeepsAccountSafetyRule(t *testing.T) {
}
t.Cleanup(cleanup)
data, err := os.ReadFile(filepath.Join(dir, "dingtalk-shared", "SKILL.md"))
data, err := os.ReadFile(filepath.Join(dir, "dws-shared", "SKILL.md"))
if err != nil {
t.Fatalf("read embedded dingtalk-shared/SKILL.md: %v", err)
t.Fatalf("read embedded dws-shared/SKILL.md: %v", err)
}
const rule = "禁止选择第一项、最近登录或最近使用账号"
if !strings.Contains(string(data), rule) {
t.Fatalf("embedded dingtalk-shared/SKILL.md lost the mandatory account safety rule %q", rule)
t.Fatalf("embedded dws-shared/SKILL.md lost the mandatory account safety rule %q", rule)
}
}
+14 -14
View File
@@ -33,43 +33,43 @@ func contains(ss []string, want string) bool {
return false
}
// dingtalk-shared must ship even when --skill narrows the set to a single product.
// dws-shared must ship even when --skill narrows the set to a single product.
func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
src := writeMultiSkillSrc(t, "dingtalk-shared", "dingtalk-aitable", "dingtalk-calendar")
src := writeMultiSkillSrc(t, "dws-shared", "dingtalk-aitable", "dingtalk-calendar")
all, err := listMultiSkillNames(src)
if err != nil {
t.Fatal(err)
}
if !contains(all, "dingtalk-shared") {
t.Fatalf("listMultiSkillNames did not enumerate dingtalk-shared: %v", all)
if !contains(all, "dws-shared") {
t.Fatalf("listMultiSkillNames did not enumerate dws-shared: %v", all)
}
filtered, err := filterMultiSkillNames(all, []string{"aitable"}, nil)
if err != nil {
t.Fatal(err)
}
if contains(filtered, "dingtalk-shared") {
t.Fatalf("precondition: filter should drop dingtalk-shared for -s aitable: %v", filtered)
if contains(filtered, "dws-shared") {
t.Fatalf("precondition: filter should drop dws-shared for -s aitable: %v", filtered)
}
final := ensureMandatorySharedSkill(filtered, all)
if !contains(final, "dingtalk-shared") {
t.Fatalf("ensureMandatorySharedSkill must re-add dingtalk-shared: %v", final)
if !contains(final, "dws-shared") {
t.Fatalf("ensureMandatorySharedSkill must re-add dws-shared: %v", final)
}
// Actually install with the filtered+mandatory set and assert dingtalk-shared landed.
// Actually install with the filtered+mandatory set and assert dws-shared landed.
dest := t.TempDir()
var out, errOut bytes.Buffer
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
t.Fatalf("install: %v (%s)", err, errOut.String())
}
if _, err := os.Stat(filepath.Join(dest, "dingtalk-shared", "SKILL.md")); err != nil {
t.Fatalf("dingtalk-shared not installed with -s aitable: %v", err)
if _, err := os.Stat(filepath.Join(dest, "dws-shared", "SKILL.md")); err != nil {
t.Fatalf("dws-shared not installed with -s aitable: %v", err)
}
if _, err := os.Stat(filepath.Join(dest, "dingtalk-aitable", "SKILL.md")); err != nil {
t.Fatalf("dingtalk-aitable not installed: %v", err)
}
}
// When the source has no dingtalk-shared (older layout), nothing is forced.
// When the source has no dws-shared (older layout), nothing is forced.
func TestP1SharedNoopWhenAbsent(t *testing.T) {
src := writeMultiSkillSrc(t, "dingtalk-aitable")
all, err := listMultiSkillNames(src)
@@ -77,7 +77,7 @@ func TestP1SharedNoopWhenAbsent(t *testing.T) {
t.Fatal(err)
}
final := ensureMandatorySharedSkill([]string{"dingtalk-aitable"}, all)
if contains(final, "dingtalk-shared") {
t.Fatalf("must not invent dingtalk-shared when source lacks it: %v", final)
if contains(final, "dws-shared") {
t.Fatalf("must not invent dws-shared when source lacks it: %v", final)
}
}
+14 -53
View File
@@ -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 := c.paramAliasResponseForTool(tool)
text := 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 (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string {
func paramAliasResponseForTool(tool string) string {
switch tool {
case "list_calendar_events":
return `{"result":{"events":[]}}`
@@ -58,27 +58,6 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
return `{"deptList":[{"deptId":1,"name":"Fixture Dept"}]}`
case "search_groups":
return `{"result":{"items":[{"openConversationId":"fixture-conversation","title":"Fixture Group"}]}}`
case "search_contact_by_key_word":
return `{"result":[{"name":"Fixture User","userId":"fixture-user","openDingTalkId":"D-fixture-user"}]}`
case "list_doc_versions":
return `{"result":{"items":[{"version":3}]}}`
case "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 `{}`
}
@@ -186,24 +165,6 @@ 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
@@ -230,7 +191,7 @@ func executeParamAliasE2E(t *testing.T, caller *paramAliasCaptureCaller, args ..
return ctx, root.Execute()
}
func TestCrossPlatformCoverageBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
func TestBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
tests := []struct {
name string
confirmation []string
@@ -283,7 +244,7 @@ func TestCrossPlatformCoverageBooleanStickyCannotBypassDestructiveConfirmation(t
}
}
func TestCrossPlatformCoverageParamAliasReadCommandFinalPayload(t *testing.T) {
func TestParamAliasReadCommandFinalPayload(t *testing.T) {
caller := &paramAliasCaptureCaller{}
start := "2026-03-10T14:00:00+08:00"
end := "2026-03-10T18:00:00+08:00"
@@ -318,7 +279,7 @@ func TestCrossPlatformCoverageParamAliasReadCommandFinalPayload(t *testing.T) {
}
}
func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
func TestParamAliasWriteCommandFinalPayload(t *testing.T) {
caller := &paramAliasCaptureCaller{}
ctx, err := executeParamAliasE2E(t, caller,
"chat", "message", "send",
@@ -350,7 +311,7 @@ func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
}
}
func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
func TestChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
tests := []struct {
name string
command []string
@@ -429,7 +390,7 @@ func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPaylo
}
}
func TestCrossPlatformCoverageAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
func TestAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
root := NewRootCommand()
engine := newPipelineEngine()
entries, err := cli.ReduceParamAliases(root)
@@ -517,7 +478,7 @@ func TestCrossPlatformCoverageAllGeneratedChatParamAliasesReachRuntimeCobraContr
t.Logf("verified generated chat parameter routes: entries=%d aliases=%d blocked=%d ambiguous=%d", chatEntries, aliasCases, guardCases[pipeline.FlagProtectionBlocked], guardCases[pipeline.FlagProtectionAmbiguous])
}
func TestCrossPlatformCoverageIMUserIDHallucinationRoutes(t *testing.T) {
func TestIMUserIDHallucinationRoutes(t *testing.T) {
tests := []struct {
command string
want string
@@ -570,7 +531,7 @@ func TestCrossPlatformCoverageIMUserIDHallucinationRoutes(t *testing.T) {
}
}
func TestCrossPlatformCoverageHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
func TestHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
const command = "chat message list-direct"
if _, ok := cli.LookupParamAlias(command); ok {
t.Fatalf("hidden command %q unexpectedly entered the public generated alias table", command)
@@ -595,7 +556,7 @@ func TestCrossPlatformCoverageHiddenIMListDirectRemainsOutsideCentralAliasTable(
}
}
func TestCrossPlatformCoverageSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing.T) {
func TestSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing.T) {
tests := []struct {
name string
tool string
@@ -692,7 +653,7 @@ func TestCrossPlatformCoverageSelectedParamAliasesProduceCanonicalEquivalentDryR
}
}
func TestCrossPlatformCoverageParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
func TestParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
caller := &paramAliasCaptureCaller{}
for _, args := range [][]string{
{"calendar", "event", "list", "--date", "2026-03-10", "--start", "2026-03-11"},
@@ -717,7 +678,7 @@ func TestCrossPlatformCoverageParamAliasCanonicalConflictFailsBeforeRunE(t *test
}
}
func TestCrossPlatformCoverageAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
func TestAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
@@ -809,7 +770,7 @@ func TestCrossPlatformCoverageAllReviewedParamAliasGuardsReachRuntimeContract(t
}
}
func TestCrossPlatformCoverageRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testing.T) {
func TestRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testing.T) {
for _, test := range []struct {
path string
emitted string
@@ -856,7 +817,7 @@ func TestCrossPlatformCoverageRepresentativeParamAliasGuardsReachFinalErrorsWith
}
}
func TestCrossPlatformCoverageFlagConflictErrorFormattingIsDeterministic(t *testing.T) {
func TestFlagConflictErrorFormattingIsDeterministic(t *testing.T) {
err := (&pipeline.FlagConflictError{Command: "dws demo", Canonical: "start", Spellings: []string{"start", "date"}}).Error()
want := `conflicting parameter spellings for --start on "dws demo": --date, --start; pass exactly one spelling`
if err != want {
+5 -11
View File
@@ -44,7 +44,7 @@ import (
// - expect=<realFlag> : emitted must reduce to that canonical flag.
// - expect=did-you-mean:blocked : block guard hit; never auto-rewritten.
// - expect=did-you-mean:ambiguous: co-occurrence guard hit; never rewritten.
func TestCrossPlatformCoverageParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
func TestParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
@@ -113,19 +113,13 @@ func TestCrossPlatformCoverageParamAliasFixtureThroughEmbeddedDeliveryPath(t *te
t.Fatalf("reviewed canonical --%s on %q is not a real Cobra flag", c.Expect, c.Command)
}
flagArgs := ctx.Args[len(strings.Fields(c.Command)):]
if len(flagArgs) == 0 {
t.Fatalf("%q on %q lost its flag and value: args=%v", c.Emitted, c.Command, ctx.Args)
}
got := flagArgs[0]
gotBare, inlineValue, hasInlineValue := strings.Cut(strings.TrimPrefix(got, "--"), "=")
switch {
case hasInlineValue && inlineValue != fixtureValue:
t.Fatalf("%q on %q changed its inline value: got %q, want %q (args=%v)", c.Emitted, c.Command, inlineValue, fixtureValue, ctx.Args)
case !hasInlineValue && (len(flagArgs) < 2 || flagArgs[1] != fixtureValue):
if len(flagArgs) < 2 || flagArgs[1] != fixtureValue {
t.Fatalf("%q on %q lost its value: args=%v", c.Emitted, c.Command, ctx.Args)
}
got := flagArgs[0]
gotBare := strings.SplitN(strings.TrimPrefix(got, "--"), "=", 2)[0]
switch {
case gotBare == c.Expect:
case got == "--"+c.Expect:
// (1) rewritten; the embedded table must agree.
if !hasEntry {
t.Fatalf("%q on %q was rewritten without an embedded alias entry", c.Emitted, c.Command)
@@ -29,37 +29,14 @@ var paramAliasCompleteCommands = map[string][]string{
"attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
"chat +chat-add-bot": {"chat", "+chat-add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
"chat +chat-audit-join": {"chat", "+chat-audit-join", "--group", "fixture-conversation", "--record-id", "7", "--applicant", "user-1", "--inviter", "user-2", "--status", "AuditApprove", "--yes"},
"chat +chat-members-get": {"chat", "+chat-members-get", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
"chat +chat-members-list": {"chat", "+chat-members-list", "--conversation-id", "fixture-conversation", "--member-types", "user,bot"},
"chat +chat-mute-member": {"chat", "+chat-mute-member", "--group", "fixture-conversation", "--users", "D-user-1,D-user-2", "--mute-time", "3600000", "--yes"},
"chat +chat-remove-bot": {"chat", "+chat-remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
"chat +chat-role-remove-user": {"chat", "+chat-role-remove-user", "--group", "fixture-conversation", "--user", "D-user-1", "--role-ids", "role-1", "--yes"},
"chat +chat-transfer-owner": {"chat", "+chat-transfer-owner", "--group", "fixture-conversation", "--new-owner", "D-user-1", "--yes"},
"chat +chat-update": {"chat", "+chat-update", "--group", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
"chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"},
"chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
"chat +category-create": {"chat", "+category-create", "--title", "Fixture Cat", "--yes"},
"chat +category-rename": {"chat", "+category-rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"},
"chat +group-members": {"chat", "+group-members", "--group", "Fixture Group"},
"chat +conversation-set-top": {"chat", "+conversation-set-top", "--conversation-id", "fixture-conversation", "--yes"},
"chat +feed-group-query-item": {"chat", "+feed-group-query-item", "--category-id", "7", "--conversation-ids", "fixture-conversation"},
"chat +flag-cancel": {"chat", "+flag-cancel", "--conversation-id", "fixture-conversation", "--message-id", "message-1", "--yes"},
"chat +flag-create": {"chat", "+flag-create", "--conversation-id", "fixture-conversation", "--message-id", "message-1", "--yes"},
"chat +flag-list": {"chat", "+flag-list", "--cursor", "0", "--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"},
"chat +messages-list": {"chat", "+messages-list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
"chat +messages-resource-download": {"chat", "+messages-resource-download", "--resource-id", "resource-1", "--message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--output", "downloads/fixture.bin"},
"chat +messages-set-pin": {"chat", "+messages-set-pin", "--open-conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
"chat +search-msg": {"chat", "+search-msg", "--group", "fixture-conversation", "--query", "fixture", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--no-enrich"},
"chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--text", "hello fixture", "--yes"},
"chat +unread-chats": {"chat", "+unread-chats", "--count", "7", "--exclude-muted"},
"chat bot find": {"chat", "bot", "find", "--query", "fixture", "--limit", "7"},
@@ -102,36 +79,9 @@ var paramAliasCompleteCommands = map[string][]string{
"ding +receiver-status": {"ding", "+receiver-status", "--ding-id", "ding-1"},
"ding message receiver-status": {"ding", "message", "receiver-status", "--ding-id", "ding-1"},
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1", "--yes"},
"doc +comment-create": {"doc", "+comment-create", "--node", "node-1", "--content", "fixture comment", "--yes"},
"doc +comment-list": {"doc", "+comment-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
"doc +comment-reply": {"doc", "+comment-reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--yes"},
"doc +access-grant": {"doc", "+access-grant", "--node", "node-1", "--to", "Fixture User", "--role", "READER", "--workspace", "workspace-1", "--yes"},
"doc +copy": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
"doc +create": {"doc", "+create", "--name", "Fixture Document", "--content", "fixture body", "--doc-format", "markdown"},
"doc +create-from-template": {"doc", "+create-from-template", "--query", "fixture template", "--name", "Fixture From Template", "--folder", "folder-1", "--workspace", "workspace-1"},
"doc +doc-append": {"doc", "+doc-append", "--doc", "node-1", "--text", "fixture appendix", "--yes"},
"doc +export-submit": {"doc", "+export-submit", "--node", "node-1", "--export-format", "docx"},
"doc +fetch": {"doc", "+fetch", "--node", "node-1", "--scope", "section", "--start-block-id", "block-1"},
"doc +find-doc": {"doc", "+find-doc", "--query", "fixture", "--limit", "7"},
"doc +history-revert": {"doc", "+history-revert", "--node", "node-1", "--version", "3", "--yes"},
"doc +inspect": {"doc", "+inspect", "--node", "node-1", "--include-history"},
"doc +list": {"doc", "+list", "--folder", "folder-1", "--cursor", "cursor-1"},
"doc +move": {"doc", "+move", "--node", "node-1", "--folder", "folder-1", "--yes"},
"doc +search": {"doc", "+search", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
"doc +template-list": {"doc", "+template-list", "--source", "MY", "--limit", "7", "--cursor", "cursor-1"},
"doc +template-search": {"doc", "+template-search", "--query", "fixture", "--source", "MY", "--limit", "7"},
"doc +version-list": {"doc", "+version-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
"doc +version-revert": {"doc", "+version-revert", "--node", "node-1", "--version", "3", "--yes"},
"doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"},
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "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"},
"doc comment create-inline": {"doc", "comment", "create-inline", "--node", "node-1", "--block-id", "block-1", "--start", "0", "--end", "7", "--content", "fixture comment", "--yes"},
"doc comment delete": {"doc", "comment", "delete", "--node", "node-1", "--comment-key", "comment-1", "--yes"},
"doc comment reply": {"doc", "comment", "reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--mentioned-open-conversation-id", "cid-1,cid-2", "--yes"},
"doc comment update": {"doc", "comment", "update", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture update", "--yes"},
"doc version revert": {"doc", "version", "revert", "--node", "node-1", "--version", "3", "--yes"},
"drive info": {"drive", "info", "--node", "node-1", "--space-id", "space-1"},
"drive list": {"drive", "list", "--folder", "folder-1", "--limit", "7"},
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"},
@@ -149,13 +99,6 @@ var paramAliasCompleteCommands = map[string][]string{
// that case the shared command template above cannot contain every canonical
// flag at once, so select a fixture-specific complete invocation here.
var paramAliasCompleteCommandVariants = map[string]map[string][]string{
"doc +copy": {
"folder": {"doc", "+copy", "--node", "node-1", "--folder", "folder-1", "--yes"},
"workspace": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
},
"doc block insert": {
"parent-block": {"doc", "block", "insert", "--node", "node-1", "--parent-block", "parent-block-1", "--index", "0", "--text", "fixture paragraph", "--yes"},
},
"chat message list": {
"user": {"chat", "message", "list", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
},
@@ -166,9 +109,6 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--uuid", "param-alias-equivalence-group", "--yes"},
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--uuid", "param-alias-equivalence-file", "--yes"},
},
"chat +conversation-set-top": {
"conversation-ids": {"chat", "+conversation-set-top", "--conversation-ids", "fixture-conversation-1,fixture-conversation-2", "--yes"},
},
}
// paramAliasNewIMCases is the exact set of aliases added by the reviewed IM
@@ -179,7 +119,6 @@ var paramAliasNewIMCases = []struct {
emitted string
canonical string
}{
{command: "chat +chat-messages", emitted: "chat", canonical: "group"},
{command: "chat +bot-find", emitted: "name", canonical: "query"},
{command: "chat bot find", emitted: "name", canonical: "query"},
{command: "chat +bot-search", emitted: "query", canonical: "name"},
@@ -190,7 +129,6 @@ var paramAliasNewIMCases = []struct {
{command: "chat +messages-list-unread-conversations", emitted: "limit", canonical: "count"},
{command: "chat +messages-list-unread-conversations", emitted: "size", canonical: "count"},
{command: "chat +messages-send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
{command: "chat +search-msg", emitted: "chat", canonical: "group"},
{command: "chat +unread-chats", emitted: "limit", canonical: "count"},
{command: "chat +unread-chats", emitted: "size", canonical: "count"},
{command: "chat bot search", emitted: "query", canonical: "name"},
@@ -207,38 +145,6 @@ var paramAliasNewIMCases = []struct {
{command: "chat message send", emitted: "file", canonical: "file-path"},
{command: "chat message send-by-bot", emitted: "at-users", canonical: "at-user-ids"},
{command: "chat message send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
{command: "chat +chat-update", emitted: "chat-id", canonical: "group"},
{command: "chat +chat-update", emitted: "conversation-id", canonical: "group"},
{command: "chat +chat-update", emitted: "open-conversation-id", canonical: "group"},
{command: "chat +chat-update", emitted: "title", canonical: "name"},
{command: "chat +chat-update", emitted: "new-title", canonical: "name"},
{command: "chat +flag-list", emitted: "limit", canonical: "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"},
{command: "chat +conversation-set-top", emitted: "chat-ids", canonical: "conversation-ids"},
{command: "chat +chat-members-get", emitted: "conversation-id", canonical: "id"},
{command: "chat +chat-members-get", emitted: "open-dingtalk-ids", canonical: "users"},
{command: "chat +chat-members-get", emitted: "chat", canonical: "id"},
{command: "chat +messages-list", emitted: "start", canonical: "time"},
{command: "chat +messages-reply", emitted: "msg-id", canonical: "ref-msg-id"},
{command: "chat +messages-reply", emitted: "chat", canonical: "conversation-id"},
{command: "chat +flag-cancel", emitted: "group", canonical: "conversation-id"},
{command: "chat +flag-cancel", emitted: "chat", canonical: "conversation-id"},
{command: "chat +flag-create", emitted: "group", canonical: "conversation-id"},
{command: "chat +chat-add-bot", emitted: "conversation-id", canonical: "id"},
{command: "chat +chat-add-bot", emitted: "robot", canonical: "robot-code"},
{command: "chat +chat-audit-join", emitted: "applicant-user-id", canonical: "applicant"},
{command: "chat +chat-mute-member", emitted: "user-ids", canonical: "users"},
{command: "chat +chat-remove-bot", emitted: "open-bot-id", canonical: "bot-id"},
{command: "chat +chat-role-remove-user", emitted: "open-dingtalk-id", canonical: "user"},
{command: "chat +chat-transfer-owner", emitted: "user-id", canonical: "new-owner"},
{command: "chat +feed-group-query-item", emitted: "chat-ids", canonical: "conversation-ids"},
{command: "chat +messages-combine-forward", emitted: "src-open-cid", canonical: "src-conversation-id"},
{command: "chat +messages-forward", emitted: "source-message-id", canonical: "msg-id"},
{command: "chat +messages-forward-topic", emitted: "src-open-message-id", canonical: "src-msg-id"},
{command: "chat +messages-resource-download", emitted: "conversation-id", canonical: "open-conversation-id"},
{command: "chat +messages-set-pin", emitted: "conversation-id", canonical: "open-conversation-id"},
}
// paramAliasRepresentativePayloadCases keeps final transport coverage across
@@ -253,36 +159,17 @@ var paramAliasNewIMCases = []struct {
// That duplicated command construction was enough to push the pre-existing
// macOS app suite beyond its package-level 10-minute timeout.
var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("aitable +record-query", "base"): true, // concept alias on a shortcut read
paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias
paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias
paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias
paramAliasPayloadCaseKey("contact user profile get", "user-id"): true, // native compatibility flag
paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override
paramAliasPayloadCaseKey("doc +comment-create", "body"): true, // write shortcut content alias
paramAliasPayloadCaseKey("doc +copy", "parent-folder-id"): true, // Doc folder role on a write shortcut
paramAliasPayloadCaseKey("doc +create", "content-format"): true, // shortcut format alias preserves markdown/jsonml enum
paramAliasPayloadCaseKey("doc +create-from-template", "keyword"): true, // template search alias composes with a write workflow
paramAliasPayloadCaseKey("doc +create-from-template", "workspace-id"): true, // template target workspace identifier
paramAliasPayloadCaseKey("doc +create-from-template", "parent-folder-id"): true, // template target folder identifier
paramAliasPayloadCaseKey("doc +export-submit", "doc-id"): true, // Doc node identifier alias
paramAliasPayloadCaseKey("doc +fetch", "start-block"): true, // section boundary role remains exact
paramAliasPayloadCaseKey("doc +history-revert", "version-number"): true, // destructive history version alias keeps confirmation
paramAliasPayloadCaseKey("doc +inspect", "include-versions"): true, // boolean section alias preserves value
paramAliasPayloadCaseKey("doc +template-list", "next-token"): true, // Doc cursor alias on a read shortcut
paramAliasPayloadCaseKey("doc +update", "mode"): true, // write operation selector alias
paramAliasPayloadCaseKey("doc +update", "revision"): true, // optimistic edit revision alias
paramAliasPayloadCaseKey("doc +access-grant", "doc-id"): true, // permission write keeps document identity
paramAliasPayloadCaseKey("doc +version-revert", "version-number"): true, // high-write version role with canonical confirmation
paramAliasPayloadCaseKey("doc block insert", "content"): true, // block write content alias
paramAliasPayloadCaseKey("doc block insert", "parent-block-id"): true, // scoped block-role alias
paramAliasPayloadCaseKey("doc comment delete", "comment-id"): true, // destructive comment-key alias
paramAliasPayloadCaseKey("doc comment reply", "mentioned-open-conversation-ids"): true, // list-valued group mention role
paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
paramAliasPayloadCaseKey("aitable +record-query", "base"): true, // concept alias on a shortcut read
paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias
paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias
paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias
paramAliasPayloadCaseKey("contact user profile get", "user-id"): true, // native compatibility flag
paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override
paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
}
func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
func TestReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
concepts, err := cli.LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
@@ -333,7 +220,6 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
if ctx == nil {
t.Fatal("complete alias command skipped PreParse")
}
normalizeParamAliasVolatileDefaults(fixture.Command, canonicalCaller, aliasCaller)
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
}
@@ -366,7 +252,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
}
}
func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
func TestNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
activeAliases := 0
for _, test := range paramAliasNewIMCases {
test := test
@@ -382,9 +268,8 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
}
canonicalCaller := &paramAliasCaptureCaller{}
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
if canonicalErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, canonicalErr) {
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
if _, err := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...); err != nil {
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", err, canonicalArgs, canonicalCaller.calls)
}
if len(canonicalCaller.calls) == 0 {
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
@@ -400,17 +285,13 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
}
activeAliases++
aliasCaller := &paramAliasCaptureCaller{}
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
if aliasErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, aliasErr) {
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
ctx, err := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
if err != nil {
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", err, aliasArgs, aliasCaller.calls)
}
if ctx == nil {
t.Fatal("complete alias command skipped PreParse")
}
if (canonicalErr == nil) != (aliasErr == nil) || (canonicalErr != nil && canonicalErr.Error() != aliasErr.Error()) {
t.Fatalf("canonical and alias completion errors differ: canonical=%v alias=%v", canonicalErr, aliasErr)
}
normalizeParamAliasVolatileDefaults(test.command, canonicalCaller, aliasCaller)
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
}
@@ -421,33 +302,6 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
}
}
// Resource download deliberately continues from the transport call into a
// local HTTPS download. The generic capture caller returns an empty object, so
// this command's stable post-transport validation error is the expected test
// boundary; canonical and alias calls must still produce the same request and
// the same error.
func paramAliasExpectedCaptureBoundaryError(command string, err error) bool {
return command == "chat +messages-resource-download" && err != nil &&
strings.Contains(err.Error(), "资源下载接口未返回合法的 HTTPS 下载地址")
}
// +chat-messages supplies the current wall-clock time when callers omit
// --time. Alias equivalence concerns the resolved target and transport shape;
// a suite crossing a second boundary must not make that default appear
// alias-dependent.
func normalizeParamAliasVolatileDefaults(command string, callers ...*paramAliasCaptureCaller) {
if command != "chat +chat-messages" {
return
}
for _, caller := range callers {
for i := range caller.calls {
if caller.calls[i].tool == "list_conversation_message_v2" || caller.calls[i].tool == "list_individual_chat_message" {
delete(caller.calls[i].args, "time")
}
}
}
}
func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
complete, ok := paramAliasCompleteCommands[command]
if variants := paramAliasCompleteCommandVariants[command]; variants != nil {
+2 -64
View File
@@ -24,7 +24,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
func TestCrossPlatformCoverageLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
func TestLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
tests := []struct {
name string
args []string
@@ -55,7 +55,7 @@ func TestCrossPlatformCoverageLeadingPersistentFlagVariantsReachTheRealCommand(t
}
}
func TestCrossPlatformCoveragePreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
func TestPreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
root := NewSchemaSourceRootCommand()
args := []string{
"chat", "message", "send",
@@ -98,65 +98,3 @@ func TestCrossPlatformCoveragePreParseConflictHonorsErrorPresentationFlags(t *te
t.Fatalf("--debug details missing from early error:\n%s", rendered)
}
}
func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTree(t *testing.T) {
tests := []struct {
name string
args []string
wantReason string
wantCommand string
}{
{
name: "unknown shortcut",
args: []string{"chat", "+chat-mesages", "--keyword", "x", "--format", "json"},
wantReason: "unknown_shortcut",
wantCommand: "dws chat",
},
{
name: "unknown dev app subcommand",
args: []string{"dev", "app", "search", "--keyword", "x", "--format", "json"},
wantReason: "unknown_subcommand",
wantCommand: "dws dev app",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root := NewSchemaSourceRootCommand()
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
if ctx == nil || ctx.Command != test.wantCommand {
t.Fatalf("Context = %#v, want command %q", ctx, test.wantCommand)
}
err = newPreParseValidationError(err)
var structured *apperrors.Error
if !stderrors.As(err, &structured) {
t.Fatalf("error = %T %v", err, err)
}
if structured.Reason != test.wantReason || structured.ExitCode() != 3 {
t.Fatalf("structured error = %#v", structured)
}
if len(structured.AvailableFlags) != 0 || strings.Contains(structured.Message, "unknown flag") {
t.Fatalf("command error leaked flag classification: %#v", structured)
}
})
}
}
func TestCrossPlatformCoverageResolvedShortcutStillUsesExactLeafFlagError(t *testing.T) {
root := NewSchemaSourceRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
args := []string{"chat", "+chat-messages", "--keyword", "x", "--format", "json"}
if ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args); err != nil {
t.Fatalf("valid shortcut path failed PreParse: %#v, %v", ctx, err)
}
root.SetArgs(args)
err := root.Execute()
var structured *apperrors.Error
if !stderrors.As(err, &structured) {
t.Fatalf("error = %T %v", err, err)
}
if structured.Reason != "unknown_flag" || !strings.Contains(structured.Message, "dws chat +chat-messages --help") {
t.Fatalf("resolved shortcut flag error = %#v", structured)
}
}
+441 -63
View File
@@ -1,118 +1,496 @@
// 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"
"encoding/json"
"fmt"
"net/http"
"os"
"sort"
"strings"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/spf13/cobra"
)
const recoveryUnsupportedMessage = "dws recovery 不再支持:失败快照恢复计划/执行/闭环已下线,请改用 doctor / schema / 对应业务命令排查。"
var (
recoverySavePlan = (*recovery.Store).SavePlan
recoverySaveAnalysis = (*recovery.Store).SaveAnalysis
)
type recoveryCompatNotice struct {
Status string `json:"status"`
Command string `json:"command"`
Message string `json:"message"`
}
func newRecoveryCommand(flags *GlobalFlags) *cobra.Command {
var (
planUseLast bool
planEventID string
executeUseLast bool
executeEventID string
finalEventID string
finalOutcome string
executionFile string
)
runtime := newRecoveryRuntime(flags)
// newRecoveryCommand keeps a visible Deprecated compatibility surface for
// historical argv and Interface Integrity. Behavior is unchanged: every leaf
// returns an explicit unsupported notice. Skills must not teach this path.
func newRecoveryCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "recovery",
Short: "不再支持:错误恢复辅助命令(兼容入口)",
Long: "此命令组仅为历史 argv 兼容保留,不再读取失败快照或生成恢复计划。Skill / Agent 请勿引导此路径。",
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
Short: "错误恢复辅助命令",
Long: "读取失败快照,生成恢复分析,并回写恢复结果。",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return printRecoveryUnsupported(cmd, "dws recovery")
return cmd.Help()
},
}
planCmd := &cobra.Command{
Use: "plan",
Short: "不再支持:基于失败快照生成恢复计划",
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
Short: "基于失败快照生成恢复计划",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return printRecoveryUnsupported(cmd, "dws recovery plan")
store := recovery.NewStore(defaultConfigDir())
last, err := loadRecoverySnapshot(store, planUseLast, planEventID)
if err != nil {
return err
}
planner := recovery.NewPlanner(runtime)
plan := planner.PlanWithOptions(cmd.Context(), last.Context, recovery.PlanOptions{
EventID: last.EventID,
EnableDocSearch: true,
})
recovery.HydratePlanForEvent(last.EventID, last.Context, last.Replay, &plan)
if err := recoverySavePlan(store, last.EventID, plan); err != nil {
return fmt.Errorf("保存恢复计划失败: %w", err)
}
payload := map[string]any{
"event_id": last.EventID,
"context": last.Context,
"plan": plan,
}
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
},
}
planCmd.Flags().Bool("last", false, "旧版兼容参数;recovery 不再支持")
planCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
planCmd.Flags().BoolVar(&planUseLast, "last", false, "读取最近一次失败快照")
planCmd.Flags().StringVar(&planEventID, "event-id", "", "按 event_id 读取失败快照")
executeCmd := &cobra.Command{
Use: "execute",
Short: "不再支持:生成面向 Agent 的恢复分析包",
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
Short: "生成面向 Agent 的恢复分析包",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return printRecoveryUnsupported(cmd, "dws recovery execute")
store := recovery.NewStore(defaultConfigDir())
last, err := loadRecoverySnapshot(store, executeUseLast, executeEventID)
if err != nil {
return err
}
planner := recovery.NewPlanner(runtime)
executor := recovery.NewExecutor(planner, runtime)
bundle := executor.Execute(cmd.Context(), *last)
if err := recoverySaveAnalysis(store, last.EventID, bundle.Plan, bundle); err != nil {
return fmt.Errorf("保存恢复分析失败: %w", err)
}
return output.WriteCommandPayload(cmd, bundle, output.FormatJSON)
},
}
executeCmd.Flags().Bool("last", false, "旧版兼容参数;recovery 不再支持")
executeCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
executeCmd.Flags().BoolVar(&executeUseLast, "last", false, "读取最近一次失败快照")
executeCmd.Flags().StringVar(&executeEventID, "event-id", "", "按 event_id 读取失败快照")
finalizeCmd := &cobra.Command{
Use: "finalize",
Short: "不再支持:回写恢复闭环结果",
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
Short: "回写恢复闭环结果",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return printRecoveryUnsupported(cmd, "dws recovery finalize")
if strings.TrimSpace(finalEventID) == "" {
return fmt.Errorf("必须提供 --event-id")
}
if strings.TrimSpace(finalOutcome) == "" {
return fmt.Errorf("必须提供 --outcome")
}
switch finalOutcome {
case "recovered", "failed", "handoff":
default:
return fmt.Errorf("--outcome 仅支持 recovered|failed|handoff")
}
store := recovery.NewStore(defaultConfigDir())
var execution *recovery.RecoveryExecution
if strings.TrimSpace(executionFile) != "" {
loaded, err := loadRecoveryExecution(executionFile)
if err != nil {
return err
}
execution = &loaded
}
if err := store.Finalize(finalEventID, finalOutcome, execution); err != nil {
return fmt.Errorf("回写恢复结果失败: %w", err)
}
payload := map[string]any{
"event_id": finalEventID,
"outcome": finalOutcome,
"success": true,
}
if execution != nil {
payload["execution_recorded"] = true
}
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
},
}
finalizeCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
finalizeCmd.Flags().String("outcome", "", "旧版兼容参数;recovery 不再支持")
finalizeCmd.Flags().String("execution-file", "", "旧版兼容参数;recovery 不再支持")
finalizeCmd.Flags().StringVar(&finalEventID, "event-id", "", "恢复事件 ID")
finalizeCmd.Flags().StringVar(&finalOutcome, "outcome", "", "恢复结果: recovered|failed|handoff")
finalizeCmd.Flags().StringVar(&executionFile, "execution-file", "", "Agent 执行详情 JSON 文件")
cmd.AddCommand(planCmd, executeCmd, finalizeCmd)
return cmd
}
func printRecoveryUnsupported(cmd *cobra.Command, command string) error {
notice := recoveryCompatNotice{
Status: "unsupported",
Command: command,
Message: recoveryUnsupportedMessage,
func loadRecoverySnapshot(store *recovery.Store, useLast bool, eventID string) (*recovery.LastError, error) {
if useLast && strings.TrimSpace(eventID) != "" {
return nil, fmt.Errorf("--last 和 --event-id 不能同时使用")
}
format, _ := cmd.Root().PersistentFlags().GetString("format")
switch strings.ToLower(strings.TrimSpace(format)) {
case "", "json":
if err := json.NewEncoder(cmd.OutOrStdout()).Encode(notice); err != nil {
return err
switch {
case useLast:
last, err := store.LoadLastError()
if err != nil {
return nil, fmt.Errorf("读取失败快照失败: %w", err)
}
return apperrors.NewValidation(recoveryUnsupportedMessage)
case "pretty":
data, _ := json.MarshalIndent(notice, "", " ")
if _, err := fmt.Fprintln(cmd.OutOrStdout(), string(data)); err != nil {
return err
return last, nil
case strings.TrimSpace(eventID) != "":
last, err := store.LoadErrorByEvent(strings.TrimSpace(eventID))
if err != nil {
return nil, fmt.Errorf("读取失败快照失败: %w", err)
}
return apperrors.NewValidation(recoveryUnsupportedMessage)
return last, nil
default:
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", notice.Command, notice.Message); err != nil {
return err
}
return apperrors.NewValidation(recoveryUnsupportedMessage)
return nil, fmt.Errorf("必须通过 --last 或 --event-id 指定失败快照")
}
}
func loadRecoveryExecution(path string) (recovery.RecoveryExecution, error) {
var execution recovery.RecoveryExecution
data, err := os.ReadFile(path)
if err != nil {
return execution, fmt.Errorf("读取恢复执行详情失败: %w", err)
}
var payload recoveryExecutionPayload
if err := json.Unmarshal(data, &payload); err != nil {
return execution, fmt.Errorf("解析恢复执行详情失败: %w", err)
}
execution.Actions = append([]string(nil), payload.Actions...)
if len(execution.Actions) == 0 && strings.TrimSpace(payload.Action) != "" {
execution.Actions = []string{strings.TrimSpace(payload.Action)}
}
execution.Result = strings.TrimSpace(payload.Result)
execution.ErrorSummary = strings.TrimSpace(payload.ErrorSummary)
if execution.ErrorSummary == "" {
execution.ErrorSummary = strings.TrimSpace(payload.Error)
}
attempts, err := decodeRecoveryAttempts(payload.Attempts, execution.Actions, execution.Result, execution.ErrorSummary)
if err != nil {
return execution, fmt.Errorf("解析恢复执行详情失败: %w", err)
}
if len(attempts) == 0 && payload.Attempt > 0 {
attempts = legacyRecoveryAttempts(payload.Attempt, execution.Actions, execution.Result, execution.ErrorSummary)
}
execution.Attempts = attempts
return execution, nil
}
type recoveryExecutionPayload struct {
Action string `json:"action,omitempty"`
Actions []string `json:"actions,omitempty"`
Attempt int `json:"attempt,omitempty"`
Attempts json.RawMessage `json:"attempts,omitempty"`
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
ErrorSummary string `json:"error_summary,omitempty"`
}
func decodeRecoveryAttempts(raw json.RawMessage, actions []string, result, errorSummary string) ([]recovery.RecoveryAttempt, error) {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" || trimmed == "null" {
return nil, nil
}
if strings.HasPrefix(trimmed, "[") {
var attempts []recovery.RecoveryAttempt
if err := json.Unmarshal(raw, &attempts); err != nil {
return nil, err
}
return attempts, nil
}
var count int
if err := json.Unmarshal(raw, &count); err != nil {
return nil, err
}
return legacyRecoveryAttempts(count, actions, result, errorSummary), nil
}
func legacyRecoveryAttempts(count int, actions []string, result, errorSummary string) []recovery.RecoveryAttempt {
if count <= 0 {
return nil
}
summary := strings.TrimSpace(strings.Join(actions, ", "))
if summary == "" {
summary = "legacy execution attempt"
}
attempts := make([]recovery.RecoveryAttempt, 0, count)
for i := 0; i < count; i++ {
attempts = append(attempts, recovery.RecoveryAttempt{
CommandSummary: summary,
Result: result,
ErrorSummary: errorSummary,
Source: "legacy_execution_file",
})
}
return attempts
}
type recoveryRuntime struct {
transport *transport.Client
flags *GlobalFlags
}
func newRecoveryRuntime(flags *GlobalFlags) *recoveryRuntime {
var httpClient *http.Client
if flags != nil && flags.Timeout > 0 {
httpClient = &http.Client{Timeout: time.Duration(flags.Timeout) * time.Second}
}
client := transport.NewClient(httpClient)
client.ExtraHeaders = resolveIdentityHeaders()
return &recoveryRuntime{
transport: client,
flags: flags,
}
}
func (r *recoveryRuntime) Search(ctx context.Context, query string, rc recovery.RecoveryContext) (recovery.KnowledgeRetrieval, error) {
const (
searchPage = 1
searchSize = 5
)
requestArgs := map[string]any{
"keyword": query,
"page": searchPage,
"size": searchSize,
}
retrieval := recovery.KnowledgeRetrieval{
DocSearch: recovery.DocSearch{
Provider: "open_platform_docs",
Query: query,
Page: searchPage,
Size: searchSize,
Status: "empty",
Request: &recovery.ToolCallRecord{
ServerID: "devdoc",
ToolName: "search_open_platform_docs_rag",
Arguments: cloneRecoveryArgs(requestArgs),
},
},
}
if r == nil || strings.TrimSpace(query) == "" {
retrieval.DocSearch.Status = "skipped"
return retrieval, nil
}
result, err := r.CallToolDirect(ctx, "devdoc", "search_open_platform_docs_rag", requestArgs)
if result != nil {
retrieval.DocSearch.Response = toRecoveryToolResponse(result)
}
if err != nil {
retrieval.DocSearch.Status = "error"
retrieval.DocSearch.Error = err.Error()
return retrieval, err
}
retrieval.DocSearch.Items = parseDocSearchItems(result)
if len(retrieval.DocSearch.Items) > 0 {
retrieval.DocSearch.Status = "success"
retrieval.KBHits = rerankDocSearchHits(query, rc, retrieval.DocSearch.Items)
}
return retrieval, nil
}
func (r *recoveryRuntime) CallToolDirect(ctx context.Context, serverID, toolName string, args map[string]any) (*transport.ToolCallResult, error) {
if r == nil || r.transport == nil {
return nil, fmt.Errorf("recovery runtime not initialized")
}
endpoint, err := r.resolveEndpoint(ctx, serverID, toolName)
if err != nil {
return nil, err
}
authToken, err := resolveRuntimeAuthToken(ctx, recoveryRuntimeToken(r.flags))
if err != nil {
return nil, tokenResolutionError(err)
}
tc := r.transport.WithAuth(authToken, resolveIdentityHeaders())
result, err := tc.CallTool(ctx, endpoint, toolName, args)
if err != nil {
return nil, err
}
if result.IsError {
return &result, apperrors.NewAPI(
extractMCPErrorMessage(result),
apperrors.WithOperation("tools/call"),
apperrors.WithReason("mcp_tool_error"),
apperrors.WithServerKey(serverID),
)
}
return &result, nil
}
func (r *recoveryRuntime) resolveEndpoint(_ context.Context, productID, toolName string) (string, error) {
if endpoint, ok := directRuntimeEndpoint(productID, toolName); ok {
return endpoint, nil
}
return "", endpointNotResolvedError(productID, toolName, "no dynamic endpoint registered for product or tool")
}
func recoveryRuntimeToken(flags *GlobalFlags) string {
if flags == nil {
return ""
}
return strings.TrimSpace(flags.Token)
}
func toRecoveryToolResponse(result *transport.ToolCallResult) *recovery.ToolResponse {
if result == nil {
return nil
}
response := &recovery.ToolResponse{IsError: result.IsError}
if len(result.Blocks) > 0 {
response.Content = make([]recovery.ToolResponseBlock, 0, len(result.Blocks))
for _, block := range result.Blocks {
response.Content = append(response.Content, recovery.ToolResponseBlock{
Type: block.Type,
Text: block.Text,
})
}
}
return response
}
func parseDocSearchItems(result *transport.ToolCallResult) []recovery.DocSearchItem {
if result == nil {
return nil
}
if items := parseDocSearchItemsFromMap(result.Content); len(items) > 0 {
return items
}
for _, block := range result.Blocks {
var payload map[string]any
if err := json.Unmarshal([]byte(block.Text), &payload); err == nil {
if items := parseDocSearchItemsFromMap(payload); len(items) > 0 {
return items
}
}
}
return nil
}
func parseDocSearchItemsFromMap(payload map[string]any) []recovery.DocSearchItem {
if len(payload) == 0 {
return nil
}
if items := toDocSearchItems(payload["items"]); len(items) > 0 {
return items
}
if data, ok := payload["data"].(map[string]any); ok {
if items := toDocSearchItems(data["items"]); len(items) > 0 {
return items
}
}
if result, ok := payload["result"].(map[string]any); ok {
if items := toDocSearchItems(result["items"]); len(items) > 0 {
return items
}
}
return nil
}
func toDocSearchItems(raw any) []recovery.DocSearchItem {
list, ok := raw.([]any)
if !ok {
return nil
}
items := make([]recovery.DocSearchItem, 0, len(list))
for _, entry := range list {
object, ok := entry.(map[string]any)
if !ok {
continue
}
item := recovery.DocSearchItem{}
if title, ok := object["title"].(string); ok {
item.Title = title
}
if url, ok := object["url"].(string); ok {
item.URL = url
}
if desc, ok := object["desc"].(string); ok {
item.Desc = desc
}
if item.Title != "" || item.URL != "" || item.Desc != "" {
items = append(items, item)
}
}
return items
}
func rerankDocSearchHits(query string, rc recovery.RecoveryContext, items []recovery.DocSearchItem) []recovery.KBHit {
if len(items) == 0 {
return nil
}
keywords := strings.Fields(strings.ToLower(strings.TrimSpace(query)))
type scoredHit struct {
hit recovery.KBHit
score float64
}
scored := make([]scoredHit, 0, len(items))
for _, item := range items {
text := strings.ToLower(strings.Join(append([]string{
item.Title,
item.URL,
item.Desc,
rc.ToolName,
}, rc.CommandPath...), " "))
score := 0.0
for _, keyword := range keywords {
if strings.Contains(text, keyword) {
score += 1
}
}
scored = append(scored, scoredHit{
hit: recovery.KBHit{
Source: "open_platform_docs",
Title: item.Title,
URL: item.URL,
Snippet: item.Desc,
Score: score,
},
score: score,
})
}
sort.SliceStable(scored, func(i, j int) bool {
return scored[i].score > scored[j].score
})
limit := len(scored)
if limit > 3 {
limit = 3
}
hits := make([]recovery.KBHit, 0, limit)
for _, item := range scored[:limit] {
hits = append(hits, item.hit)
}
return hits
}
@@ -1,110 +0,0 @@
// 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 (
"bytes"
"errors"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageRecoveryDeprecatedUnsupportedShim(t *testing.T) {
root := NewRootCommand()
group := mustFindCommand(t, root, "recovery")
if group.Hidden || group.Deprecated == "" || !group.Runnable() {
t.Fatalf("recovery group contract: hidden=%v deprecated=%q runnable=%v", group.Hidden, group.Deprecated, group.Runnable())
}
for _, leaf := range []string{"plan", "execute", "finalize"} {
cmd := mustFindCommand(t, root, "recovery", leaf)
if cmd.Hidden || cmd.Deprecated == "" || !cmd.Runnable() {
t.Fatalf("recovery %s contract: hidden=%v deprecated=%q runnable=%v", leaf, cmd.Hidden, cmd.Deprecated, cmd.Runnable())
}
wantFlags := []string{"event-id"}
switch leaf {
case "plan", "execute":
wantFlags = append(wantFlags, "last")
case "finalize":
wantFlags = append(wantFlags, "outcome", "execution-file")
}
for _, flag := range wantFlags {
if cmd.Flags().Lookup(flag) == nil {
t.Fatalf("recovery %s missing --%s", leaf, flag)
}
}
for _, child := range newRecoveryCommand().Commands() {
if child.Name() != leaf {
continue
}
if err := child.RunE(child, nil); err == nil || !strings.Contains(err.Error(), "不再支持") {
t.Fatalf("recovery %s RunE = %v, want 不再支持", leaf, err)
}
}
}
for _, format := range []string{"", "json", "pretty", "table"} {
var out bytes.Buffer
cmd := &cobra.Command{Use: "dws"}
cmd.PersistentFlags().String("format", format, "")
cmd.SetOut(&out)
sub := &cobra.Command{Use: "recovery"}
cmd.AddCommand(sub)
err := printRecoveryUnsupported(sub, "dws recovery plan")
if err == nil {
t.Fatalf("format=%q returned nil error", format)
}
typed, ok := err.(*apperrors.Error)
if !ok || typed.Category != apperrors.CategoryValidation {
t.Fatalf("format=%q error = %T/%v, want validation Error", format, err, err)
}
got := out.String() + err.Error()
if !strings.Contains(got, "不再支持") {
t.Fatalf("format=%q missing 不再支持:\n%s", format, got)
}
if format == "" || format == "json" || format == "pretty" {
if !strings.Contains(got, `"status":"unsupported"`) && !strings.Contains(got, `"status": "unsupported"`) {
t.Fatalf("format=%q missing unsupported JSON status:\n%s", format, got)
}
}
}
for _, format := range []string{"json", "pretty", "table"} {
cmd := &cobra.Command{Use: "dws"}
cmd.PersistentFlags().String("format", format, "")
cmd.SetOut(failWriter{})
sub := &cobra.Command{Use: "recovery"}
cmd.AddCommand(sub)
if err := printRecoveryUnsupported(sub, "dws recovery plan"); err == nil || !strings.Contains(err.Error(), "write failed") {
t.Fatalf("format=%q write failure = %v, want write failed", format, err)
}
}
if err := newRecoveryCommand().RunE(newRecoveryCommand(), nil); err == nil || !strings.Contains(err.Error(), "不再支持") {
t.Fatalf("recovery parent RunE = %v, want 不再支持", err)
}
captureRuntimeFailure(executor.Invocation{}, nil, nil)
}
type failWriter struct{}
func (failWriter) Write([]byte) (int, error) {
return 0, errWriteFailed
}
var errWriteFailed = errors.New("write failed")
@@ -0,0 +1,151 @@
package app
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func recoveryCoverageRun(cmdArgs ...string) (string, error) {
cmd := newRecoveryCommand(&GlobalFlags{})
out := &strings.Builder{}
cmd.SetOut(out)
cmd.SetErr(io.Discard)
cmd.SetArgs(cmdArgs)
err := cmd.Execute()
return out.String(), err
}
func TestCrossPlatformCoverageRecoveryCommandRemainingCoverage(t *testing.T) {
oldSavePlan, oldSaveAnalysis := recoverySavePlan, recoverySaveAnalysis
t.Cleanup(func() {
recoverySavePlan, recoverySaveAnalysis = oldSavePlan, oldSaveAnalysis
})
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
store := recovery.NewStore(configDir)
last, err := store.Capture(recovery.RecoveryContext{ServerID: "doc", ToolName: "get"})
if err != nil {
t.Fatal(err)
}
recoverySavePlan = func(*recovery.Store, string, recovery.RecoveryPlan) error { return errors.New("save plan") }
if _, err := recoveryCoverageRun("plan", "--last"); err == nil {
t.Fatal("injected plan save failure succeeded")
}
recoverySavePlan = oldSavePlan
recoverySaveAnalysis = func(*recovery.Store, string, recovery.RecoveryPlan, recovery.RecoveryBundle) error {
return errors.New("save analysis")
}
if _, err := recoveryCoverageRun("execute", "--last"); err == nil {
t.Fatal("injected analysis save failure succeeded")
}
recoverySaveAnalysis = oldSaveAnalysis
parent := newRecoveryCommand(nil)
parent.SetOut(io.Discard)
if err := parent.RunE(parent, nil); err != nil {
t.Fatal(err)
}
if out, err := recoveryCoverageRun("plan", "--last"); err != nil || !strings.Contains(out, last.EventID) {
t.Fatalf("recovery plan = %q, %v", out, err)
}
if out, err := recoveryCoverageRun("execute", "--event-id", last.EventID); err != nil || out == "" {
t.Fatalf("recovery execute = %q, %v", out, err)
}
for _, args := range [][]string{
{"finalize"},
{"finalize", "--event-id", last.EventID},
{"finalize", "--event-id", last.EventID, "--outcome", "unknown"},
{"finalize", "--event-id", last.EventID, "--outcome", "recovered", "--execution-file", "missing"},
} {
if _, err := recoveryCoverageRun(args...); err == nil {
t.Fatalf("recovery finalize %#v should fail", args)
}
}
executionPath := filepath.Join(t.TempDir(), "execution.json")
if err := os.WriteFile(executionPath, []byte(`{"action":"retry","attempt":1,"result":"ok"}`), 0o600); err != nil {
t.Fatal(err)
}
if out, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "handoff", "--execution-file", executionPath); err != nil || !strings.Contains(out, "execution_recorded") {
t.Fatalf("recovery finalize = %q, %v", out, err)
}
if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "failed"); err != nil {
t.Fatal(err)
}
if _, err := loadRecoverySnapshot(store, true, last.EventID); err == nil {
t.Fatal("conflicting snapshot selectors should fail")
}
if _, err := loadRecoverySnapshot(store, false, "missing"); err == nil {
t.Fatal("missing event snapshot should fail")
}
if _, err := loadRecoverySnapshot(store, false, ""); err == nil {
t.Fatal("empty snapshot selector should fail")
}
missingStore := recovery.NewStore(t.TempDir())
if _, err := loadRecoverySnapshot(missingStore, true, ""); err == nil {
t.Fatal("missing latest snapshot should fail")
}
eventsPath := filepath.Join(configDir, "recovery", "recovery_events.jsonl")
if err := os.Remove(eventsPath); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(eventsPath, 0o700); err != nil {
t.Fatal(err)
}
if _, err := recoveryCoverageRun("plan", "--last"); err == nil {
t.Fatal("recovery plan save should fail")
}
if _, err := recoveryCoverageRun("execute", "--last"); err == nil {
t.Fatal("recovery analysis save should fail")
}
if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "recovered"); err == nil {
t.Fatal("recovery finalization save should fail")
}
}
func TestCrossPlatformCoverageRecoveryExecutionAndRuntimeRemainingCoverage(t *testing.T) {
t.Setenv("DINGTALK_DEVDOC_MCP_URL", "http://127.0.0.1:1")
path := filepath.Join(t.TempDir(), "execution.json")
if err := os.WriteFile(path, []byte(`{"attempts":{}}`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := loadRecoveryExecution(path); err == nil {
t.Fatal("invalid attempts should fail")
}
if _, err := decodeRecoveryAttempts([]byte(`[{}`), nil, "", ""); err == nil {
t.Fatal("invalid attempt array should fail")
}
SetDynamicServers(nil)
runtime := &recoveryRuntime{
transport: transport.NewClient(nil),
flags: &GlobalFlags{Token: "token"},
}
if _, err := runtime.CallToolDirect(context.Background(), "missing", "tool", nil); err == nil || !strings.Contains(err.Error(), `endpoint not resolved for product "missing" (tool "tool")`) {
t.Fatalf("direct resolution error = %v", err)
}
if got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{}); err == nil || got.DocSearch.Status != "error" {
t.Fatalf("search error = %#v, %v", got, err)
}
if got := parseDocSearchItems(&transport.ToolCallResult{Content: map[string]any{}, Blocks: []transport.ContentBlock{{Text: "not-json"}}}); got != nil {
t.Fatalf("empty doc search items = %#v", got)
}
for _, payload := range []map[string]any{
{"data": map[string]any{}},
{"result": map[string]any{}},
} {
if got := parseDocSearchItemsFromMap(payload); got != nil {
t.Fatalf("empty nested doc search items = %#v", got)
}
}
}
+88 -4
View File
@@ -1,10 +1,94 @@
package app
import (
"os"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
)
// captureRuntimeFailure previously persisted a recovery snapshot for
// `dws recovery`. The recovery package is gone; keep a no-op seam so runner
// failure paths stay stable while the visible Deprecated shim remains.
func captureRuntimeFailure(_ executor.Invocation, _, _ error) {}
func captureRuntimeFailure(invocation executor.Invocation, rawErr, wrappedErr error) {
if rawErr == nil && wrappedErr == nil {
return
}
store := recovery.NewStore(defaultConfigDir())
if store == nil || !store.Enabled() {
return
}
input := recovery.CaptureInput{
CommandPath: runtimeCommandPath(invocation),
ServerID: strings.TrimSpace(invocation.CanonicalProduct),
ToolName: strings.TrimSpace(invocation.Tool),
Args: cloneRecoveryArgs(invocation.Params),
Argv: append([]string(nil), os.Args[1:]...),
RawErr: rawErr,
WrappedErr: wrappedErr,
}
_, _ = store.Capture(recovery.BuildContext(input), recovery.BuildReplay(input))
}
func runtimeCommandPath(invocation executor.Invocation) []string {
if path := currentCommandPath(); len(path) > 0 {
return path
}
if legacy := strings.Fields(strings.TrimSpace(invocation.LegacyPath)); len(legacy) > 0 {
return legacy
}
if product := strings.TrimSpace(invocation.CanonicalProduct); product != "" {
if tool := strings.TrimSpace(invocation.Tool); tool != "" {
return []string{product, tool}
}
return []string{product}
}
return nil
}
func currentCommandPath() []string {
boolFlags := map[string]struct{}{
"--verbose": {},
"-v": {},
"--debug": {},
"--mock": {},
"--dry-run": {},
"--yes": {},
"-y": {},
"--help": {},
"-h": {},
"--json": {},
}
path := make([]string, 0, len(os.Args))
skipNext := false
for _, arg := range os.Args[1:] {
if skipNext {
skipNext = false
continue
}
if arg == "--" {
break
}
if strings.HasPrefix(arg, "-") {
if strings.Contains(arg, "=") {
continue
}
if _, ok := boolFlags[arg]; ok {
continue
}
skipNext = true
continue
}
path = append(path, arg)
}
return path
}
func cloneRecoveryArgs(args map[string]any) map[string]any {
if len(args) == 0 {
return nil
}
out := make(map[string]any, len(args))
for key, value := range args {
out[key] = value
}
return out
}
+12 -18
View File
@@ -39,6 +39,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
@@ -50,11 +51,15 @@ import (
type outputFileContextKey struct{}
const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID="
var (
rootNormalizeProcessProfileArgs = normalizeProcessProfileArgs
rootExecuteCommand = (*cobra.Command).ExecuteC
rootNewRootCommandWithEngine = NewRootCommandWithEngine
rootRunPreParse = pipeline.RunPreParse
rootLatestRecoveryCapture = recovery.LatestCapture
rootResetRecoveryState = recovery.ResetRuntimeState
rootStopAllStdioClients = StopAllStdioClients
rootLoadPlugins = loadPlugins
rootMkdirAll = os.MkdirAll
@@ -102,6 +107,7 @@ func Execute() (exitCode int) {
ctx = WithTimingCollector(ctx, timing)
initStart := time.Now()
rootResetRecoveryState()
engine := newPipelineEngine()
root := rootNewRootCommandWithEngine(ctx, engine)
timing.Record("cmd_init", time.Since(initStart))
@@ -127,6 +133,9 @@ func Execute() (exitCode int) {
_, _ = fmt.Fprintln(os.Stderr)
}
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
if last := rootLatestRecoveryCapture(); last != nil && last.EventID != "" {
_, _ = fmt.Fprintf(os.Stderr, "%s%s\n", recoveryEventStderrPrefix, last.EventID)
}
return apperrors.ExitCode(err)
}
return 0
@@ -135,9 +144,6 @@ func Execute() (exitCode int) {
// newPreParseValidationError keeps pipeline handler identity in internal logs
// while exposing only the underlying parameter-domain error to CLI users.
func newPreParseValidationError(err error) error {
if structured, ok := err.(*apperrors.Error); ok {
return structured
}
userErr := err
var handlerErr *pipeline.HandlerError
if stderrors.As(err, &handlerErr) && handlerErr.Unwrap() != nil {
@@ -471,10 +477,10 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
newCatalogCommand(),
newConfigCommand(),
newDoctorCommand(),
newRecoveryCommand(),
newEventCommand(),
newAuditCommand(),
newCompletionCommand(root),
newRecoveryCommand(flags),
newUpgradeCommand(),
newVersionCommand(),
newPluginCommand(),
@@ -718,8 +724,8 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
var builtinCommandNames = map[string]bool{
"auth": true, "api": true, "audit": true, "cache": true, "config": true,
"doctor": true, "event": true, "completion": true, "skill": true,
"plugin": true, "profile": true, "recovery": true, "version": true, "help": true,
"schema": true, "mcp": true, "upgrade": true,
"plugin": true, "profile": true, "version": true, "help": true,
"recovery": true, "schema": true, "mcp": true, "upgrade": true,
}
// commandNameSet returns a new set containing every name in base plus extras.
@@ -1327,18 +1333,6 @@ func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
// - PostResponse: after transport returns, before stdout (canonical RunE)
func newPipelineEngine() *pipeline.Engine {
engine := pipeline.NewEngine()
engine.SetCommandPathFallbackLookup(func(path string) (pipeline.CommandPathFallback, bool) {
entry, ok := cli.LookupCommandPathFallback(path)
if !ok {
return pipeline.CommandPathFallback{}, false
}
return pipeline.CommandPathFallback{
From: entry.From,
Mode: string(entry.Mode),
To: entry.To,
Candidates: append([]string(nil), entry.Candidates...),
}, true
})
engine.RegisterAll(
// Register handler runs during command tree building.
handlers.RegisterHandler{},
+8
View File
@@ -12,6 +12,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
@@ -23,6 +24,8 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
oldExecute := rootExecuteCommand
oldNewRoot := rootNewRootCommandWithEngine
oldPreParse := rootRunPreParse
oldLatest := rootLatestRecoveryCapture
oldReset := rootResetRecoveryState
oldStop := rootStopAllStdioClients
oldArgs := os.Args
t.Cleanup(func() {
@@ -30,16 +33,20 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
rootExecuteCommand = oldExecute
rootNewRootCommandWithEngine = oldNewRoot
rootRunPreParse = oldPreParse
rootLatestRecoveryCapture = oldLatest
rootResetRecoveryState = oldReset
rootStopAllStdioClients = oldStop
os.Args = oldArgs
})
os.Args = []string{"dws"}
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
rootResetRecoveryState = func() {}
rootStopAllStdioClients = func() {}
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
}
rootLatestRecoveryCapture = func() *recovery.LastError { return nil }
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil }
if code := Execute(); code != 0 {
t.Fatalf("successful Execute code = %d", code)
@@ -52,6 +59,7 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
wantErr := errors.New("unknown command missing")
rootLatestRecoveryCapture = func() *recovery.LastError { return &recovery.LastError{EventID: "evt-test"} }
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr }
if code := Execute(); code == 0 {
t.Fatal("failed Execute returned zero")
+13 -40
View File
@@ -72,39 +72,6 @@ func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) {
func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
root := NewRootCommand()
for _, path := range []string{
"chat send",
"chat history",
"im send",
"im history",
} {
command, remaining, err := root.Find(strings.Fields(path))
if err != nil {
t.Fatalf("find %s: %v", path, err)
}
if len(remaining) != 0 || !command.Hidden || !command.Runnable() {
t.Fatalf("%s compatibility contract: remaining=%v hidden=%v runnable=%v", path, remaining, command.Hidden, command.Runnable())
}
}
for _, tc := range []struct {
args []string
hint string
}{
{args: []string{"chat", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{args: []string{"im", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
} {
command := NewRootCommand()
command.SilenceErrors = true
command.SilenceUsage = true
command.SetArgs(tc.args)
err := command.Execute()
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
t.Fatalf("dws %s error = %v, want migration hint %q", strings.Join(tc.args, " "), err, tc.hint)
}
}
listDirect := mustFindCommand(t, root, "chat", "message", "list-direct")
for _, flag := range []string{"user", "open-dingtalk-id", "time", "forward", "limit"} {
if listDirect.Flags().Lookup(flag) == nil {
@@ -419,14 +386,20 @@ func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
}
}
func TestCacheCommandDeprecatedCompatStub(t *testing.T) {
root := NewRootCommand()
cmd, _, err := root.Find([]string{"cache", "refresh"})
if err != nil || cmd == nil || cmd == root {
t.Fatalf("dws cache refresh compatibility stub missing: %v", err)
func TestCacheRefreshCompatibilityStub(t *testing.T) {
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"cache", "refresh", "--format", "json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("cache refresh compatibility stub: %v\n%s", err, out.String())
}
if cmd.Hidden || cmd.Deprecated == "" {
t.Fatalf("cache refresh must be visible Deprecated: hidden=%v deprecated=%q", cmd.Hidden, cmd.Deprecated)
got := out.String()
for _, want := range []string{`"status":"deprecated"`, `"command":"dws cache refresh"`, "服务发现已下线"} {
if !strings.Contains(got, want) {
t.Fatalf("cache refresh output missing %q:\n%s", want, got)
}
}
}
+14 -41
View File
@@ -413,33 +413,6 @@ func multiProfileErrorPayload(err error) map[string]any {
if typed.Operation != "" {
payload["operation"] = typed.Operation
}
if typed.Origin != "" {
payload["origin"] = typed.Origin
}
if typed.FailureStage != "" {
payload["stage"] = typed.FailureStage
}
if typed.ExecutionStarted != nil {
payload["execution_started"] = *typed.ExecutionStarted
}
if typed.RetryableSet {
payload["retryable"] = typed.Retryable
}
if typed.Hint != "" {
payload["hint"] = typed.Hint
}
if len(typed.Actions) > 0 {
payload["actions"] = append([]string(nil), typed.Actions...)
}
if len(typed.Details) > 0 {
payload["details"] = typed.Details
}
if typed.ServerDiag.TraceID != "" {
payload["trace_id"] = typed.ServerDiag.TraceID
}
if typed.ServerDiag.ServerErrorCode != "" {
payload["server_error_code"] = typed.ServerDiag.ServerErrorCode
}
if code := typed.ExitCode(); code != 0 {
payload["exitCode"] = code
}
@@ -705,6 +678,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
if callResult.IsError {
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
logBusinessError(r.transport.FileLogger, "mcp_tool_error", invocation, callResult.Content, diag)
// ClassifyToolResult hook: let the overlay intercept known error
// patterns (PAT permission, gateway-auth) before generic handling.
@@ -721,14 +695,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
}
}
mcpErr := newServerFailureAPIError(
mcpErr := apperrors.NewAPI(
extractMCPErrorMessage(callResult),
"mcp_tool_error",
"MCP tool returned a business error; check tool parameters and refer to skill documentation.",
invocation.CanonicalProduct,
diag,
apperrors.WithOperation("tools/call"),
apperrors.WithReason("mcp_tool_error"),
apperrors.WithServerKey(invocation.CanonicalProduct),
apperrors.WithHint("MCP tool returned a business error; check tool parameters and refer to skill documentation."),
apperrors.WithServerDiag(diag),
)
logBusinessError(r.transport.FileLogger, serverFailureReason(mcpErr, "mcp_tool_error"), invocation, callResult.Content, diag)
// PAT scope error in business response: offer human-readable output and retry
if isPatScopeError(mcpErr) {
scopeErr := extractPatScopeError(mcpErr)
@@ -746,15 +720,14 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
if bizErr := detectBusinessError(callResult.Content); bizErr != "" {
diag := transport.ExtractServerDiagnosticsFromMap(callResult.Content)
classifiedErr := newServerFailureAPIError(
bizErr,
"business_error",
"The API returned a business-level error. Check required parameters and values.",
invocation.CanonicalProduct,
diag,
logBusinessError(r.transport.FileLogger, "business_error", invocation, callResult.Content, diag)
return executor.Result{}, apperrors.NewAPI(bizErr,
apperrors.WithOperation("tools/call"),
apperrors.WithReason("business_error"),
apperrors.WithServerKey(invocation.CanonicalProduct),
apperrors.WithHint("The API returned a business-level error. Check required parameters and values."),
apperrors.WithServerDiag(diag),
)
logBusinessError(r.transport.FileLogger, serverFailureReason(classifiedErr, "business_error"), invocation, callResult.Content, diag)
return executor.Result{}, classifiedErr
}
invocation.Implemented = true
@@ -296,14 +296,7 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles {
t.Fatalf("write dry-run fixture %s: %v", path, err)
}
}
return agentExampleFiles{
root: root,
markdown: "./" + filepath.Base(markdown),
json: "./" + filepath.Base(jsonFile),
batch: "./" + filepath.Base(batch),
binary: "./" + filepath.Base(binary),
image: "./" + filepath.Base(image),
}
return agentExampleFiles{root: root, markdown: markdown, json: jsonFile, batch: batch, binary: binary, image: image}
}
func materializeAgentExampleArgv(argv []string, files agentExampleFiles) []string {
@@ -34,12 +34,7 @@ func TestReviewedRoutedInterfacesReachFinalSchema(t *testing.T) {
{
canonical: "sheet.range_batch_set_style",
mode: "composite",
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.",
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.",
},
{
canonical: "sheet.range_read",
@@ -26,14 +26,6 @@ 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},
+17 -94
View File
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 357
publicShortcutCount = 266
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including hidden leaves such as minutes.shortcut_minutes_search.
schemaPublishedShortcutCount = 358
schemaPublishedShortcutCount = 216
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 357
publiclyDeliveredShortcutCount = 215
)
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)), 187; got != want {
if got, want := int(product["count"].(float64)), 129; got != want {
t.Fatalf("schema chat count = %d, want %d", got, want)
}
summaries := schemaContractObjectSlice(productPayload["tools"])
@@ -124,77 +124,8 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
shortcutCount++
}
}
if shortcutCount != 98 {
t.Fatalf("schema chat shortcut summaries = %d, want 98", shortcutCount)
}
}
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)
if shortcutCount != 47 {
t.Fatalf("schema chat shortcut summaries = %d, want 47", shortcutCount)
}
}
@@ -263,9 +194,17 @@ func assertDeliveryShortcutSafetyAndInterface(
canonical string,
) {
t.Helper()
safety := shortcut.EffectiveSafety(declared)
wantEffect, wantRisk := safety.Effect, safety.Risk
wantConfirmation, wantIdempotency := safety.Confirmation, safety.Idempotency
risk := declared.Risk
if risk == "" {
risk = shortcut.RiskRead
}
wantEffect, wantRisk, wantConfirmation, wantIdempotency := "read", "low", "not_required", "idempotent"
switch risk {
case shortcut.RiskWrite:
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "write", "medium", "user_required", "unknown"
case shortcut.RiskHighWrite:
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "destructive", "high", "user_required", "unknown"
}
for field, want := range map[string]string{
"effect": wantEffect,
"risk": wantRisk,
@@ -295,15 +234,6 @@ func assertDeliveryShortcutParameters(
for _, flag := range declared.Flags {
if !flag.Hidden {
publicFlags = append(publicFlags, flag)
if flag.AliasesVisible {
for _, alias := range flag.Aliases {
aliasFlag := flag
aliasFlag.Name = alias
aliasFlag.Default = ""
aliasFlag.Aliases = nil
publicFlags = append(publicFlags, aliasFlag)
}
}
}
}
if got, want := len(parameters), len(publicFlags); got != want {
@@ -369,13 +299,6 @@ func shortcutSchemaRequired(declared shortcut.Shortcut, flagName string) bool {
if flag.Name == flagName && flag.Required {
return true
}
if flag.Required && flag.AliasesVisible {
for _, alias := range flag.Aliases {
if alias == flagName {
return true
}
}
}
}
public := make(map[string]bool, len(declared.Flags))
for _, flag := range declared.Flags {
-106
View File
@@ -1,106 +0,0 @@
// 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 (
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
type serverFailureClass struct {
message string
reason string
origin string
stage string
hint string
actions []string
}
func classifyServerFailure(message string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
code := strings.ToUpper(strings.TrimSpace(diag.ServerErrorCode))
detail := strings.ToLower(strings.TrimSpace(diag.TechnicalDetail))
text := strings.ToLower(strings.TrimSpace(message))
if code == "NETWORK_ERROR" ||
strings.Contains(detail, "statuscode.unavailable") ||
strings.Contains(detail, "connection refused") {
classified := serverFailureClass{
message: "MCP 后端依赖暂时不可用",
reason: "backend_dependency_unavailable",
origin: "mcp_gateway",
stage: "backend_dependency",
hint: "请求参数无需修改;请使用相同参数稍后重试。持续失败时请提供 Trace ID 排查 MCP 服务。",
actions: []string{
"使用相同参数重试一次",
"持续失败时保留 Trace ID 并排查 MCP 后端依赖",
},
}
if strings.Contains(detail, "querytoolmeta") {
classified.message = "MCP 后端元数据服务暂时不可用"
classified.stage = "tool_metadata_lookup"
}
return classified, true
}
if code == "PARAM_ERROR" ||
strings.Contains(text, "opencid or cid is required") ||
strings.Contains(text, "openconversationid") && strings.Contains(text, "required") {
return serverFailureClass{
message: message,
reason: "invalid_request",
origin: "dingtalk_api",
stage: "tool_validation",
hint: "请求未通过后端参数校验;请核对当前 leaf Help/Schema 和稳定 ID 类型后重试。",
}, true
}
return serverFailureClass{}, false
}
func newServerFailureAPIError(
message string,
fallbackReason string,
fallbackHint string,
serverKey string,
diag apperrors.ServerDiagnostics,
) error {
opts := []apperrors.Option{
apperrors.WithOperation("tools/call"),
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 {
message = classified.message
opts = append(opts,
apperrors.WithReason(classified.reason),
apperrors.WithOrigin(classified.origin),
apperrors.WithFailureStage(classified.stage),
apperrors.WithHint(classified.hint),
apperrors.WithActions(classified.actions...),
)
}
return apperrors.NewAPI(message, opts...)
}
func serverFailureReason(err error, fallback string) string {
typed, ok := err.(*apperrors.Error)
if ok && strings.TrimSpace(typed.Reason) != "" {
return typed.Reason
}
return fallback
}
@@ -1,226 +0,0 @@
// 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"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func TestCrossPlatformCoverageServerFailureClassifierBackendMetadataUnavailable(t *testing.T) {
retryable := true
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
apperrors.ServerDiagnostics{
TraceID: "trace-local",
ServerErrorCode: "NETWORK_ERROR",
TechnicalDetail: "调用 McpService.queryToolMeta 失败: status = StatusCode.UNAVAILABLE; connect: Connection refused (111)",
ServerRetryable: &retryable,
},
)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
if typed.Reason != "backend_dependency_unavailable" || typed.Origin != "mcp_gateway" || typed.FailureStage != "tool_metadata_lookup" {
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
}
if typed.ExecutionStarted != nil {
t.Fatalf("execution_started = %v, want unknown until the backend publishes it", typed.ExecutionStarted)
}
if !typed.RetryableSet || !typed.Retryable {
t.Fatalf("retryability = (%v, %v), want explicit true", typed.RetryableSet, typed.Retryable)
}
if strings.Contains(strings.ToLower(typed.Hint), "parameter") || strings.Contains(typed.Hint, "认证") {
t.Fatalf("misleading hint = %q", typed.Hint)
}
}
func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *testing.T) {
err := newServerFailureAPIError(
"openCid or cid is required",
"business_error",
"check parameters",
"chat",
apperrors.ServerDiagnostics{ServerErrorCode: "1001"},
)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
if typed.Reason != "invalid_request" || typed.FailureStage != "tool_validation" {
t.Fatalf("classification = reason %q stage %q", typed.Reason, typed.FailureStage)
}
if typed.ExecutionStarted != nil {
t.Fatalf("execution_started = %v, want unknown until the backend publishes it", typed.ExecutionStarted)
}
}
func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing.T) {
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
apperrors.ServerDiagnostics{},
)
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
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) {
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
apperrors.ServerDiagnostics{ServerErrorCode: "NETWORK_ERROR"},
)
if got := serverFailureReason(err, "business_error"); got != "backend_dependency_unavailable" {
t.Fatalf("reason = %q", got)
}
if got := serverFailureReason(errors.New("plain"), "fallback"); got != "fallback" {
t.Fatalf("fallback reason = %q", got)
}
}
func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesFailureSemantics(t *testing.T) {
retryable := true
err := newServerFailureAPIError(
"business error: success=false",
"business_error",
"check parameters",
"im",
apperrors.ServerDiagnostics{
TraceID: "trace-multi",
ServerErrorCode: "NETWORK_ERROR",
TechnicalDetail: "McpService.queryToolMeta: StatusCode.UNAVAILABLE",
ServerRetryable: &retryable,
},
)
payload := multiProfileErrorPayload(err)
for key, want := range map[string]any{
"reason": "backend_dependency_unavailable",
"origin": "mcp_gateway",
"stage": "tool_metadata_lookup",
"retryable": true,
"trace_id": "trace-multi",
"server_error_code": "NETWORK_ERROR",
} {
if got := payload[key]; got != want {
t.Errorf("payload[%q] = %#v, want %#v", key, got, want)
}
}
if _, ok := payload["execution_started"]; ok {
t.Fatalf("payload must not invent execution_started: %#v", payload)
}
}
func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesResolutionDetails(t *testing.T) {
err := apperrors.NewValidation(
"群目标不唯一",
apperrors.WithReason("resolution_ambiguous"),
apperrors.WithOrigin("client"),
apperrors.WithFailureStage("target_resolution"),
apperrors.WithExecutionStarted(false),
apperrors.WithHint("请选择候选"),
apperrors.WithActions("使用稳定 ID 重试"),
apperrors.WithDetails(map[string]any{
"type": "resolution",
"candidates": []string{"cid-1", "cid-2"},
}),
)
payload := multiProfileErrorPayload(err)
details, ok := payload["details"].(map[string]any)
if !ok || details["type"] != "resolution" {
t.Fatalf("details = %#v", payload["details"])
}
if payload["execution_started"] != false || payload["origin"] != "client" || payload["stage"] != "target_resolution" {
t.Fatalf("payload = %#v", payload)
}
if actions, ok := payload["actions"].([]string); !ok || len(actions) != 1 {
t.Fatalf("actions = %#v", payload["actions"])
}
}
func TestCrossPlatformCoverageExecuteInvocationClassifiesObservedMCPMetadataFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request struct {
ID int `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode request: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": request.ID,
"result": map[string]any{
"structuredContent": map[string]any{
"success": false,
"code": "NETWORK_ERROR",
"trace_id": "trace-replay",
"technical_detail": "调用 McpService.queryToolMeta 失败: status = StatusCode.UNAVAILABLE; connect: Connection refused (111)",
"retryable": true,
},
},
})
}))
defer server.Close()
client := transport.NewClient(server.Client())
client.TrustedDomains = []string{strings.TrimPrefix(server.URL, "http://")}
runner := &runtimeRunner{
transport: client,
globalFlags: &GlobalFlags{Token: "local-test-token"},
}
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
CanonicalProduct: "im",
Tool: "list_conversations",
Params: map[string]any{"pageSize": 100},
})
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("executeInvocation() error = %T %v, want typed API error", err, err)
}
if typed.Reason != "backend_dependency_unavailable" || typed.Origin != "mcp_gateway" || typed.FailureStage != "tool_metadata_lookup" {
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
}
if typed.ServerDiag.TraceID != "trace-replay" || !typed.RetryableSet || !typed.Retryable {
t.Fatalf("diagnostics = %#v retryable=(%v,%v)", typed.ServerDiag, typed.RetryableSet, typed.Retryable)
}
if typed.ExecutionStarted != nil {
t.Fatalf("execution_started must remain unknown: %v", typed.ExecutionStarted)
}
}
+21 -584
View File
@@ -51,8 +51,6 @@ var (
skillSetupInstallMono = installSkillToHomes
skillSetupInstallMulti = installMultiSkillToHomes
skillSetupCopyDir = copyDir
skillSetupMkdirTemp = os.MkdirTemp
skillSetupRename = os.Rename
skillSetupRunForm = (*huh.Form).Run
skillSetupInteractive = isInteractiveTerminal
skillSetupReadDir = os.ReadDir
@@ -77,8 +75,10 @@ func newSkillSetupCommand() *cobra.Command {
Long: `安装 dws 自身 skill 文档到 AI Agent 目录(如 ~/.claude/skills/、~/.cursor/skills/ 等)。
支持两种模式:
mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
multi 多 skill—— 按产品拆 N 个独立 skill
mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
multi 🧪 EXPERIMENTAL 多 skill(试验版 / Preview)—— 按产品拆 N 个独立 skill
尚未达到 stable 标准,接口、命名与跨 skill 引用可能变动;
生产前请评估,问题请提 issue 反馈
multi 模式支持按产品挑选:
-s/--skill 只装指定子 skill(可重复,短名 aitable 或全名 dingtalk-aitable 均可)
@@ -138,9 +138,6 @@ 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 {
@@ -153,33 +150,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
if filterErr != nil {
return filterErr
}
// dingtalk-shared carries the global rules every product skill declares as a
// dws-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:仅预览将安装的内容与目标目录,不写入任何文件、不弹确认。
@@ -191,15 +164,11 @@ 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
@@ -208,6 +177,8 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
fmt.Fprintln(out, "已取消。")
return nil
}
} else if mode == skillSetupModeMulti {
fmt.Fprintln(errOut, "🧪 multi 模式当前为 EXPERIMENTAL(试验版 / Preview)—— 接口与布局可能变动,稳定版请用 --mode mono")
}
var installed, skipped int
@@ -215,14 +186,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
case skillSetupModeMono:
installed, skipped, err = skillSetupInstallMono(skillSrc, dests, out, errOut)
case skillSetupModeMulti:
installed, skipped, err = installMultiSkillsWithEventMigration(
skillSrc,
multiSkillNames,
dests,
migrateEventMiscTargets,
out,
errOut,
)
installed, skipped, err = skillSetupInstallMulti(skillSrc, multiSkillNames, dests, out, errOut)
default:
return fmt.Errorf("内部错误:未知 mode %q", mode)
}
@@ -231,7 +195,6 @@ 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
}
@@ -242,228 +205,8 @@ const multiSkillPrefix = "dingtalk-"
// multiSharedSkill is the shared, non-product skill that every per-product
// skill declares as a PREREQUISITE. It must always be installed in multi mode
// regardless of --skill / --exclude, otherwise the product skills reference a
// 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
}
// dws-shared that was never installed.
const multiSharedSkill = "dws-shared"
// ensureMandatorySharedSkill guarantees the shared dependency skill is included
// whenever it exists in the source, even if --skill / --exclude narrowed it out.
@@ -622,10 +365,10 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
huh.NewGroup(
huh.NewSelect[string]().
Title("选择 dws skill 安装模式").
Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分的独立 skill").
Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分(🧪 EXPERIMENTAL / 试验版,未达 stable,接口可能变动)").
Options(
huh.NewOption("mono — 单 skill(稳定 / 推荐)", skillSetupModeMono),
huh.NewOption("multi — 多 skill(按产品拆分)", skillSetupModeMulti),
huh.NewOption("multi — 多 skill(🧪 EXPERIMENTAL · 试验版)", skillSetupModeMulti),
).
Value(&choice),
),
@@ -783,6 +526,15 @@ func detectExistingAgentHomes(home, mode string) []string {
}
func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSkillNames []string) (bool, error) {
if mode == skillSetupModeMulti {
fmt.Fprintln(out, "\n🧪 ─────────────────────────────────────────────────────────────")
fmt.Fprintln(out, " multi 模式当前为 EXPERIMENTAL(试验版 / Preview)")
fmt.Fprintf(out, " · 当前选择的 %d 个独立 skill 均跑过 verifier,可用但未达 stable\n", len(multiSkillNames))
fmt.Fprintln(out, " · 跨 skill 引用、bundle 命名、目录布局后续可能调整")
fmt.Fprintln(out, " · 不建议在生产 / 共享环境直接落地;问题请提 issue 反馈")
fmt.Fprintln(out, " 稳定版请用 --mode mono")
fmt.Fprintln(out, "🧪 ─────────────────────────────────────────────────────────────")
}
fmt.Fprintf(out, "\n📦 将安装 skill:\n mode: %s\n source: %s\n", mode, src)
if mode == skillSetupModeMulti {
fmt.Fprintf(out, " 将装 %d 个独立 skill(按子目录平铺到 <agent-home>/<skill-name>/):\n", len(multiSkillNames))
@@ -868,21 +620,6 @@ 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 {
@@ -910,299 +647,6 @@ 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.
@@ -1218,7 +662,6 @@ 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)
@@ -1234,12 +677,6 @@ 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
+5 -13
View File
@@ -58,9 +58,8 @@ func TestMaterializeEmbeddedSkillSourceMono(t *testing.T) {
}
// TestMaterializeEmbeddedSkillSourceMulti verifies that the peer multi bundle
// 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.
// contains both the shared routing skill and the PAT product skill. 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 {
@@ -72,21 +71,14 @@ 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"),
filepath.Join("dws-shared", "SKILL.md"),
filepath.Join("dingtalk-pat", "SKILL.md"),
filepath.Join("dingtalk-pat", "references", "pat.md"),
} {
if _, err := os.Stat(filepath.Join(dir, rel)); err != nil {
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) {
+1 -337
View File
@@ -7,11 +7,9 @@ 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"
)
@@ -75,7 +73,7 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
if err := cmd.RunE(cmd, nil); err == nil {
t.Fatal("empty multi source should fail")
}
skillSetupListMulti = func(string) ([]string, error) { return []string{"dingtalk-shared", "dingtalk-doc"}, nil }
skillSetupListMulti = func(string) ([]string, error) { return []string{"dws-shared", "dingtalk-doc"}, nil }
skillSetupFilterMulti = func([]string, []string, []string) ([]string, error) { return nil, fail }
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
if err := cmd.RunE(cmd, nil); err == nil {
@@ -122,112 +120,12 @@ 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
@@ -235,7 +133,6 @@ 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")
@@ -413,236 +310,3 @@ 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)
}
})
}
+1 -796
View File
@@ -2,9 +2,6 @@ package app
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
@@ -187,20 +184,12 @@ func writeMultiSkillSource(t *testing.T, names []string) string {
if err := os.MkdirAll(filepath.Join(sub, "references"), 0o755); err != nil {
t.Fatal(err)
}
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 {
if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte("# "+n), 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
}
@@ -560,787 +549,3 @@ 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")
}
@@ -1,633 +0,0 @@
{
"source_report": "param_hallucination_20260728_120943",
"source_file": "raw_dashscope_qwen3_7-max_20260728_120943.json",
"source_dws_commit": "a8e83e5",
"model": "dashscope/qwen3.7-max",
"expected_count": 52,
"badcases": [
{
"id": "dws_im_v3_0003-run10-command0",
"case_id": "dws_im_v3_0003",
"run": 10,
"command_index": 0,
"turn": 1,
"source_path": "chat +members",
"expected_outcome": "rewrite",
"raw": "dws chat +members --group \"dws测试群01\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0003-run10-command1",
"case_id": "dws_im_v3_0003",
"run": 10,
"command_index": 1,
"turn": 1,
"source_path": "chat +members",
"expected_outcome": "rewrite",
"raw": "dws chat +members --group-name \"dws测试群01\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0003-run10-command2",
"case_id": "dws_im_v3_0003",
"run": 10,
"command_index": 2,
"turn": 1,
"source_path": "chat +members",
"expected_outcome": "rewrite",
"raw": "dws chat +members \"dws测试群01\" --format json",
"original_exit_code": 5,
"is_help": false
},
{
"id": "dws_im_v3_0010-run1-command1",
"case_id": "dws_im_v3_0010",
"run": 1,
"command_index": 1,
"turn": 1,
"source_path": "chat +send-dm",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-dm --open-dingtalk-id DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS --content \"评测任务更新了,稍后同步\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0010-run1-command2",
"case_id": "dws_im_v3_0010",
"run": 1,
"command_index": 2,
"turn": 1,
"source_path": "chat +send-dm",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-dm --dingtalk-id DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS --content \"评测任务更新了,稍后同步\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0010-run8-command4",
"case_id": "dws_im_v3_0010",
"run": 8,
"command_index": 4,
"turn": 1,
"source_path": "chat +send-single",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-single --user-id \"DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS\" --content \"评测任务更新了,稍后同步\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0010-run10-command0",
"case_id": "dws_im_v3_0010",
"run": 10,
"command_index": 0,
"turn": 1,
"source_path": "chat +send-by-bot",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-by-bot --open-dingtalk-id \"段旭彬\" --message \"评测任务更新了,稍后同步\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0010-run10-command1",
"case_id": "dws_im_v3_0010",
"run": 10,
"command_index": 1,
"turn": 1,
"source_path": "chat +send-by-bot",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-by-bot --open-id \"段旭彬\" --msg \"评测任务更新了,稍后同步\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0010-run10-command2",
"case_id": "dws_im_v3_0010",
"run": 10,
"command_index": 2,
"turn": 1,
"source_path": "chat +send-by-bot",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-by-bot \"段旭彬\" \"评测任务更新了,稍后同步\" --format json",
"original_exit_code": 5,
"is_help": false
},
{
"id": "dws_im_v3_0011-run1-command3",
"case_id": "dws_im_v3_0011",
"run": 1,
"command_index": 3,
"turn": 1,
"source_path": "chat +send-media",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-media --user-id \"489149\" --file-path \"建筑.jpeg\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0011-run1-command4",
"case_id": "dws_im_v3_0011",
"run": 1,
"command_index": 4,
"turn": 1,
"source_path": "chat +send-media",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-media --receiver \"489149\" --file-path \"建筑.jpeg\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0011-run9-command1",
"case_id": "dws_im_v3_0011",
"run": 9,
"command_index": 1,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --user-id 489149 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case__ysfhvr3/建筑.jpeg\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0011-run9-command2",
"case_id": "dws_im_v3_0011",
"run": 9,
"command_index": 2,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --dry-run --format json 2>&1 | head -50",
"original_exit_code": 0,
"is_help": false
},
{
"id": "dws_im_v3_0012-run1-command2",
"case_id": "dws_im_v3_0012",
"run": 1,
"command_index": 2,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_bc6azkyi/streamlit.docx\" --to \"符咏畅\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run1-command3",
"case_id": "dws_im_v3_0012",
"run": 1,
"command_index": 3,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_bc6azkyi/streamlit.docx\" --user \"符咏畅\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run2-command3",
"case_id": "dws_im_v3_0012",
"run": 2,
"command_index": 3,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --receiver 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_3er98f5m/streamlit.docx\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run2-command8",
"case_id": "dws_im_v3_0012",
"run": 2,
"command_index": 8,
"turn": 1,
"source_path": "chat +send",
"expected_outcome": "ambiguous",
"raw": "dws chat +send --user 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_3er98f5m/streamlit.docx\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run3-command1",
"case_id": "dws_im_v3_0012",
"run": 3,
"command_index": 1,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --user-id 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_cljycf8p/streamlit.docx\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run3-command2",
"case_id": "dws_im_v3_0012",
"run": 3,
"command_index": 2,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --receiver 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_cljycf8p/streamlit.docx\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run4-command3",
"case_id": "dws_im_v3_0012",
"run": 4,
"command_index": 3,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --help",
"original_exit_code": 64,
"is_help": true
},
{
"id": "dws_im_v3_0012-run6-command4",
"case_id": "dws_im_v3_0012",
"run": 6,
"command_index": 4,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --user 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_8ef9dsey/streamlit.docx\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run6-command5",
"case_id": "dws_im_v3_0012",
"run": 6,
"command_index": 5,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --to \"519483\" --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_8ef9dsey/streamlit.docx\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run6-command6",
"case_id": "dws_im_v3_0012",
"run": 6,
"command_index": 6,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --name \"符咏畅\" --path \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_8ef9dsey/streamlit.docx\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run7-command1",
"case_id": "dws_im_v3_0012",
"run": 7,
"command_index": 1,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --user-id \"519483\" --file-path \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_fjcfinmi/streamlit.docx\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0012-run7-command2",
"case_id": "dws_im_v3_0012",
"run": 7,
"command_index": 2,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --format json --userid \"519483\" --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_fjcfinmi/streamlit.docx\"",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0016-run1-command7",
"case_id": "dws_im_v3_0016",
"run": 1,
"command_index": 7,
"turn": 3,
"source_path": "chat +rename-group",
"expected_outcome": "rewrite",
"raw": "dws chat +rename-group --id \"cideWy3UCPNZ4k8Mr4BL/8KRQ==\" --name \"测试废物群-20260713\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0017-run2-command6",
"case_id": "dws_im_v3_0017",
"run": 2,
"command_index": 6,
"turn": 2,
"source_path": "chat +send-message",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-message --group \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --content \"@陈邦杰 请确认下评测环境\" --mention \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0017-run2-command7",
"case_id": "dws_im_v3_0017",
"run": 2,
"command_index": 7,
"turn": 2,
"source_path": "chat +send-message",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-message --open-conversation-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --content \"@陈邦杰 请确认下评测环境\" --mention \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0017-run2-command9",
"case_id": "dws_im_v3_0017",
"run": 2,
"command_index": 9,
"turn": 2,
"source_path": "chat +send-message",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-message --format json 2>&1",
"original_exit_code": 5,
"is_help": false
},
{
"id": "dws_im_v3_0017-run5-command0",
"case_id": "dws_im_v3_0017",
"run": 5,
"command_index": 0,
"turn": 1,
"source_path": "chat +group-member-list",
"expected_outcome": "rewrite",
"raw": "dws chat +group-member-list --group-name \"dws测试群01\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0017-run6-command6",
"case_id": "dws_im_v3_0017",
"run": 6,
"command_index": 6,
"turn": 2,
"source_path": "chat +send-text",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-text --open-conversation-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --text \"@陈邦杰 请确认下评测环境\" --at-open-dingtalk-ids \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0017-run6-command7",
"case_id": "dws_im_v3_0017",
"run": 6,
"command_index": 7,
"turn": 2,
"source_path": "chat +send-text",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-text --conversation-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --content \"@陈邦杰 请确认下评测环境\" --at-user-ids \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0017-run6-command8",
"case_id": "dws_im_v3_0017",
"run": 6,
"command_index": 8,
"turn": 2,
"source_path": "chat +group-send-text",
"expected_outcome": "ambiguous",
"raw": "dws chat +group-send-text --group-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --text \"@陈邦杰 请确认下评测环境\" --at-open-dingtalk-ids \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0018-run10-command5",
"case_id": "dws_im_v3_0018",
"run": 10,
"command_index": 5,
"turn": 1,
"source_path": "chat +send-image",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-image --group \"cidyfbDkUpmy4ReyI7vfZy+zw==\" --file-path \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_z7y6p6_0/dog.png\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0019-run4-command1",
"case_id": "dws_im_v3_0019",
"run": 4,
"command_index": 1,
"turn": 1,
"source_path": "chat +send-image",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-image --user-id 489149 --image /private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_wsppmjwr/landscape.jpg --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0019-run4-command2",
"case_id": "dws_im_v3_0019",
"run": 4,
"command_index": 2,
"turn": 1,
"source_path": "chat +send-image",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-image --to 489149 --file /private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_wsppmjwr/landscape.jpg --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0019-run6-command3",
"case_id": "dws_im_v3_0019",
"run": 6,
"command_index": 3,
"turn": 1,
"source_path": "chat +send",
"expected_outcome": "ambiguous",
"raw": "dws chat +send --receiver-id 489149 --image \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_09930iej/landscape.jpg\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0019-run8-command2",
"case_id": "dws_im_v3_0019",
"run": 8,
"command_index": 2,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --user-id 489149 --file \"landscape.jpg\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0019-run8-command3",
"case_id": "dws_im_v3_0019",
"run": 8,
"command_index": 3,
"turn": 1,
"source_path": "chat +send-file",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-file --receiver-id 489149 --file \"landscape.jpg\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0019-run8-command6",
"case_id": "dws_im_v3_0019",
"run": 8,
"command_index": 6,
"turn": 1,
"source_path": "chat +send",
"expected_outcome": "ambiguous",
"raw": "dws chat +send --to 489149 --file \"landscape.jpg\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0019-run8-command9",
"case_id": "dws_im_v3_0019",
"run": 8,
"command_index": 9,
"turn": 1,
"source_path": "chat +send-to",
"expected_outcome": "ambiguous",
"raw": "dws chat +send-to --uid 489149 --file \"landscape.jpg\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0019-run9-command0",
"case_id": "dws_im_v3_0019",
"run": 9,
"command_index": 0,
"turn": 1,
"source_path": "chat +send",
"expected_outcome": "ambiguous",
"raw": "dws chat +send --receiver 489149 --file-path \"./landscape.jpg\" --format json 2>&1",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0021-run4-command3",
"case_id": "dws_im_v3_0021",
"run": 4,
"command_index": 3,
"turn": 1,
"source_path": "chat +read-single",
"expected_outcome": "ambiguous",
"raw": "dws chat +read-single --user-id 550582 --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0021-run4-command4",
"case_id": "dws_im_v3_0021",
"run": 4,
"command_index": 4,
"turn": 1,
"source_path": "chat +read-single",
"expected_outcome": "ambiguous",
"raw": "dws chat +read-single --uid 550582 --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0021-run4-command5",
"case_id": "dws_im_v3_0021",
"run": 4,
"command_index": 5,
"turn": 1,
"source_path": "chat +read-single",
"expected_outcome": "ambiguous",
"raw": "dws chat +read-single --staff-id 550582 --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0021-run4-command6",
"case_id": "dws_im_v3_0021",
"run": 4,
"command_index": 6,
"turn": 1,
"source_path": "chat +read-single",
"expected_outcome": "ambiguous",
"raw": "dws chat +read-single --open-dingtalk-id \"DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0021-run4-command7",
"case_id": "dws_im_v3_0021",
"run": 4,
"command_index": 7,
"turn": 1,
"source_path": "chat +read-single",
"expected_outcome": "ambiguous",
"raw": "dws chat +read-single --target 550582 --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0021-run4-command8",
"case_id": "dws_im_v3_0021",
"run": 4,
"command_index": 8,
"turn": 1,
"source_path": "chat +read-single",
"expected_outcome": "ambiguous",
"raw": "dws chat +read-single 550582 --format json",
"original_exit_code": 5,
"is_help": false
},
{
"id": "dws_im_v3_0021-run5-command1",
"case_id": "dws_im_v3_0021",
"run": 5,
"command_index": 1,
"turn": 1,
"source_path": "chat +message-list",
"expected_outcome": "ambiguous",
"raw": "dws chat +message-list --format json 2>&1",
"original_exit_code": 5,
"is_help": false
},
{
"id": "dws_im_v3_0028-run4-command5",
"case_id": "dws_im_v3_0028",
"run": 4,
"command_index": 5,
"turn": 1,
"source_path": "chat +list-robot",
"expected_outcome": "rewrite",
"raw": "dws chat +list-robot \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --format json",
"original_exit_code": 5,
"is_help": false
},
{
"id": "dws_im_v3_0028-run5-command15",
"case_id": "dws_im_v3_0028",
"run": 5,
"command_index": 15,
"turn": 1,
"source_path": "chat +list-group-bots",
"expected_outcome": "rewrite",
"raw": "dws chat +list-group-bots --id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --format json",
"original_exit_code": 3,
"is_help": false
},
{
"id": "dws_im_v3_0028-run9-command9",
"case_id": "dws_im_v3_0028",
"run": 9,
"command_index": 9,
"turn": 1,
"source_path": "chat +list-robots",
"expected_outcome": "rewrite",
"raw": "dws chat +list-robots --cid \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --format json",
"original_exit_code": 3,
"is_help": false
}
]
}
+12 -3
View File
@@ -23,11 +23,11 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/profilectx"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
@@ -117,14 +117,23 @@ type Profile struct {
UpdatedAt string `json:"updatedAt,omitempty"`
}
var (
runtimeProfileMu sync.RWMutex
runtimeProfile string
)
// SetRuntimeProfile sets a process-local one-shot profile override.
func SetRuntimeProfile(profile string) {
profilectx.Set(profile)
runtimeProfileMu.Lock()
defer runtimeProfileMu.Unlock()
runtimeProfile = strings.TrimSpace(profile)
}
// RuntimeProfile returns the process-local one-shot profile override.
func RuntimeProfile() string {
return profilectx.Get()
runtimeProfileMu.RLock()
defer runtimeProfileMu.RUnlock()
return runtimeProfile
}
// ProfilesPath returns the profile metadata path for a config dir.
+2 -2
View File
@@ -71,7 +71,7 @@ func NewSchemaCommand() *cobra.Command {
Short: "渐进查看命令 Schema (产品 / 分组 / 工具参数)",
Long: `查看当前可运行命令的 Schema 元数据。
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。普通 Agent 查询应使用 --compact:它按稳定字段白名单输出选参、约束和安全语义。省略 --compact 的 full leaf 保留参数映射、接口绑定和 provenance,仅用于定向审计;--all 输出全部工具的完整 leaf Schema,用于审计/CI。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。--all 输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)。--compact 去除 provenance / debug 字段,仅保留 Agent 选参所需信息(适合 Agent 上下文)。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -113,7 +113,7 @@ func NewSchemaCommand() *cobra.Command {
},
}
cmd.Flags().Bool("all", false, "输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)")
cmd.Flags().Bool("compact", false, "按稳定字段白名单输出 Agent 选参、约束和安全语义")
cmd.Flags().Bool("compact", false, "去除 provenance/debug 字段,仅保留 Agent 选参所需信息")
cmd.Flags().String("cli-path", "", "按 CLI 命令路径查询")
return cmd
}
-289
View File
@@ -1,289 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package cli
import (
"bytes"
_ "embed"
"encoding/json"
"fmt"
"io"
"regexp"
"sort"
"strings"
"sync"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
const commandPathFallbacksSchemaRef = "./command_path_fallbacks.schema.json"
// command_path_fallbacks.json is a reviewed recovery policy for invalid paths
// observed in evaluation. It is deliberately not a command identity source:
// Help, Skill, Schema, and normal navigation continue to publish canonical
// paths from the CommandRegistry and the real Cobra tree only.
//go:embed command_path_fallbacks.json
var embeddedCommandPathFallbacksJSON []byte
//go:embed command_path_fallbacks.schema.json
var embeddedCommandPathFallbacksSchemaJSON []byte
var commandFallbackPathPattern = regexp.MustCompile(`^[A-Za-z0-9+][A-Za-z0-9._:+-]*(?: [A-Za-z0-9+][A-Za-z0-9._:+-]*)*$`)
// CommandPathFallbackMode determines whether an invalid reviewed path can be
// normalized exactly or must stop and surface reviewed candidates.
type CommandPathFallbackMode string
const (
CommandPathFallbackRewrite CommandPathFallbackMode = "rewrite"
CommandPathFallbackAmbiguous CommandPathFallbackMode = "ambiguous"
)
// CommandPathFallback is one validated, generated runtime recovery record.
// From is never advertised as a stable alias. To and Candidates are canonical
// real Cobra paths validated at generation time.
type CommandPathFallback struct {
From string `json:"from"`
Mode CommandPathFallbackMode `json:"mode"`
To string `json:"to,omitempty"`
Candidates []string `json:"candidates,omitempty"`
Reviewed bool `json:"reviewed"`
ReviewReason string `json:"review_reason"`
}
type commandPathFallbackSnapshot struct {
Schema string `json:"$schema"`
Version int `json:"version"`
Entries []commandPathFallbackEntrySpec `json:"entries"`
}
type commandPathFallbackEntrySpec struct {
From string `json:"from"`
Mode CommandPathFallbackMode `json:"mode"`
To string `json:"to,omitempty"`
Candidates []string `json:"candidates,omitempty"`
Reviewed bool `json:"reviewed"`
ReviewReason string `json:"review_reason"`
}
var (
embeddedCommandPathFallbacksOnce sync.Once
embeddedCommandPathFallbacksData []CommandPathFallback
embeddedCommandPathFallbacksErr error
loadReviewedCommandPathFallbacks = loadEmbeddedCommandPathFallbacks
)
// LoadCommandPathFallbacks decodes and validates the authored recovery table.
// Callers receive a clone so generation and tests cannot mutate shared data.
func LoadCommandPathFallbacks() ([]CommandPathFallback, error) {
return loadReviewedCommandPathFallbacks()
}
func loadEmbeddedCommandPathFallbacks() ([]CommandPathFallback, error) {
embeddedCommandPathFallbacksOnce.Do(func() {
embeddedCommandPathFallbacksData, embeddedCommandPathFallbacksErr = decodeCommandPathFallbacks(embeddedCommandPathFallbacksJSON)
})
return cloneCommandPathFallbacks(embeddedCommandPathFallbacksData), embeddedCommandPathFallbacksErr
}
func decodeCommandPathFallbacks(data []byte) ([]CommandPathFallback, error) {
var snapshot commandPathFallbackSnapshot
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&snapshot); err != nil {
return nil, fmt.Errorf("decode reviewed command path fallbacks: %w", err)
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
err = fmt.Errorf("multiple JSON values")
}
return nil, fmt.Errorf("decode reviewed command path fallbacks: %w", err)
}
if snapshot.Version != 1 {
return nil, fmt.Errorf("unsupported command path fallbacks version %d", snapshot.Version)
}
if strings.TrimSpace(snapshot.Schema) != commandPathFallbacksSchemaRef {
return nil, fmt.Errorf("command path fallbacks must declare $schema=%q", commandPathFallbacksSchemaRef)
}
if len(snapshot.Entries) == 0 {
return nil, fmt.Errorf("command path fallbacks declares no entries")
}
entries := make([]CommandPathFallback, 0, len(snapshot.Entries))
seenFrom := make(map[string]bool, len(snapshot.Entries))
for index, spec := range snapshot.Entries {
from, err := validateAuthoredCommandFallbackPath(spec.From)
if err != nil {
return nil, fmt.Errorf("command path fallback entry %d from: %w", index, err)
}
if seenFrom[from] {
return nil, fmt.Errorf("command path fallbacks contains duplicate from path %q", from)
}
seenFrom[from] = true
reason := strings.TrimSpace(spec.ReviewReason)
if !spec.Reviewed || reason == "" {
return nil, fmt.Errorf("command path fallback %q requires reviewed=true and non-empty review_reason", from)
}
entry := CommandPathFallback{
From: from,
Mode: spec.Mode,
Reviewed: true,
ReviewReason: reason,
}
switch spec.Mode {
case CommandPathFallbackRewrite:
if len(spec.Candidates) > 0 {
return nil, fmt.Errorf("rewrite command path fallback %q must not declare candidates", from)
}
to, pathErr := validateAuthoredCommandFallbackPath(spec.To)
if pathErr != nil {
return nil, fmt.Errorf("rewrite command path fallback %q to: %w", from, pathErr)
}
entry.To = to
case CommandPathFallbackAmbiguous:
if strings.TrimSpace(spec.To) != "" {
return nil, fmt.Errorf("ambiguous command path fallback %q must not declare to", from)
}
if len(spec.Candidates) < 2 {
return nil, fmt.Errorf("ambiguous command path fallback %q requires at least two candidates", from)
}
seenCandidates := make(map[string]bool, len(spec.Candidates))
for _, rawCandidate := range spec.Candidates {
candidate, pathErr := validateAuthoredCommandFallbackPath(rawCandidate)
if pathErr != nil {
return nil, fmt.Errorf("ambiguous command path fallback %q candidate: %w", from, pathErr)
}
if seenCandidates[candidate] {
return nil, fmt.Errorf("ambiguous command path fallback %q repeats candidate %q", from, candidate)
}
seenCandidates[candidate] = true
entry.Candidates = append(entry.Candidates, candidate)
}
default:
return nil, fmt.Errorf("command path fallback %q has invalid mode %q", from, spec.Mode)
}
entries = append(entries, entry)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].From < entries[j].From })
return entries, nil
}
func validateAuthoredCommandFallbackPath(raw string) (string, error) {
path := strings.TrimSpace(raw)
if path == "" {
return "", fmt.Errorf("path is empty")
}
if path != raw || normalizeSchemaCLIPath(path) != path || !commandFallbackPathPattern.MatchString(path) {
return "", fmt.Errorf("path %q is not a normalized command path without leading dws or flags", raw)
}
return path, nil
}
// ReduceCommandPathFallbacks validates the reviewed recovery table against the
// live distribution-owned Cobra tree. It refuses to turn an existing command
// or stable alias into a hidden rewrite. The sole exception is an explicitly
// annotated hint-only compatibility node, which contains no business action.
func ReduceCommandPathFallbacks(root *cobra.Command) ([]CommandPathFallback, error) {
if root == nil {
return nil, fmt.Errorf("command path fallback source root is nil")
}
entries, err := LoadCommandPathFallbacks()
if err != nil {
return nil, err
}
byFrom := make(map[string]CommandPathFallback, len(entries))
for _, entry := range entries {
byFrom[entry.From] = entry
}
var problems []string
for _, entry := range entries {
match, resolveErr := resolveExactCobraPath(root, entry.From)
if resolveErr != nil {
problems = append(problems, fmt.Sprintf("from %q cannot be resolved safely: %v", entry.From, resolveErr))
} else if match.Command != nil && !cmdutil.IsHintOnlyCommand(match.Command) {
problems = append(problems, fmt.Sprintf("from %q collides with a real Cobra command or alias", entry.From))
}
switch entry.Mode {
case CommandPathFallbackRewrite:
if _, chained := byFrom[entry.To]; chained {
problems = append(problems, fmt.Sprintf("rewrite %q targets fallback source %q; chained fallbacks are forbidden", entry.From, entry.To))
}
problems = append(problems, validateCommandFallbackTarget(root, entry.From, entry.To, true)...)
case CommandPathFallbackAmbiguous:
for _, candidate := range entry.Candidates {
if _, chained := byFrom[candidate]; chained {
problems = append(problems, fmt.Sprintf("ambiguous fallback %q candidate %q is another fallback source", entry.From, candidate))
}
// Ambiguous recovery never dispatches. It may therefore point the
// caller at both canonical shortcuts and native leaves, while exact
// rewrites keep the stricter +shortcut identity boundary.
problems = append(problems, validateCommandFallbackTarget(root, entry.From, candidate, false)...)
}
default:
problems = append(problems, fmt.Sprintf("fallback %q has unsupported mode %q", entry.From, entry.Mode))
}
}
if len(problems) > 0 {
sort.Strings(problems)
return nil, fmt.Errorf("command path fallback reduction failed:\n - %s", strings.Join(problems, "\n - "))
}
return cloneCommandPathFallbacks(entries), nil
}
func validateCommandFallbackTarget(root *cobra.Command, from, target string, requireShortcutParity bool) []string {
var problems []string
if commandFallbackService(from) != commandFallbackService(target) {
problems = append(problems, fmt.Sprintf("fallback %q crosses service boundary to %q", from, target))
}
if requireShortcutParity && commandFallbackHasShortcut(from) != commandFallbackHasShortcut(target) {
problems = append(problems, fmt.Sprintf("fallback %q and target %q disagree on +shortcut identity", from, target))
}
match, err := resolveExactCobraPath(root, target)
if err != nil {
return append(problems, fmt.Sprintf("fallback %q target %q cannot be resolved safely: %v", from, target, err))
}
if match.Command == nil {
return append(problems, fmt.Sprintf("fallback %q target %q does not exist", from, target))
}
if match.UsedAlias {
problems = append(problems, fmt.Sprintf("fallback %q target %q must use canonical Cobra names, not an alias", from, target))
}
if !runnableSchemaLeaf(match.Command) || !match.Command.IsAvailableCommand() {
problems = append(problems, fmt.Sprintf("fallback %q target %q is not a public runnable Cobra leaf", from, target))
}
return problems
}
func commandFallbackService(path string) string {
parts := strings.Fields(path)
if len(parts) == 0 {
return ""
}
return parts[0]
}
func commandFallbackHasShortcut(path string) bool {
for _, part := range strings.Fields(path) {
if strings.HasPrefix(part, "+") {
return true
}
}
return false
}
func cloneCommandPathFallbacks(entries []CommandPathFallback) []CommandPathFallback {
cloned := make([]CommandPathFallback, len(entries))
for index, entry := range entries {
cloned[index] = entry
cloned[index].Candidates = append([]string(nil), entry.Candidates...)
}
return cloned
}
-311
View File
@@ -1,311 +0,0 @@
{
"$schema": "./command_path_fallbacks.schema.json",
"version": 1,
"entries": [
{
"from": "chat +group-search",
"mode": "rewrite",
"to": "chat +chat-search",
"reviewed": true,
"review_reason": "0803 evaluation badcase: the model emitted +group-search with --query; +chat-search provides the same group-name search operation."
},
{
"from": "chat +members",
"mode": "rewrite",
"to": "chat +group-members",
"reviewed": true,
"review_reason": "20260728 evaluation badcases emitted +members three times for listing members of a group selected by name; +group-members is the unique reviewed read-only shortcut for that intent."
},
{
"from": "chat +group-member-list",
"mode": "rewrite",
"to": "chat +group-members",
"reviewed": true,
"review_reason": "20260728 evaluation emitted +group-member-list for a group-name member lookup; +group-members is the unique reviewed read-only shortcut and canonical parameter validation remains authoritative."
},
{
"from": "chat +list-group-bots",
"mode": "rewrite",
"to": "chat +chat-bots",
"reviewed": true,
"review_reason": "20260728 evaluation emitted +list-group-bots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs."
},
{
"from": "chat +list-robot",
"mode": "rewrite",
"to": "chat +chat-bots",
"reviewed": true,
"review_reason": "20260728 evaluation emitted singular +list-robot for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs."
},
{
"from": "chat +list-robots",
"mode": "rewrite",
"to": "chat +chat-bots",
"reviewed": true,
"review_reason": "20260728 evaluation emitted +list-robots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs."
},
{
"from": "chat +message-list",
"mode": "ambiguous",
"candidates": [
"chat +chat-messages",
"chat +messages-list-direct",
"chat +search-msg",
"chat +unread-chats"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +message-list without identifying group versus direct history, conversation history versus cross-chat search, or ordinary versus unread conversations; no candidate may be selected automatically."
},
{
"from": "chat +read-single",
"mode": "ambiguous",
"candidates": [
"chat +messages-list-direct",
"chat +chat-messages"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +read-single six times for direct-message history, but the invented name does not choose between the focused direct-history shortcut and the broader group/direct history workflow; command recovery must stop before parameter validation or dispatch."
},
{
"from": "chat +rename-group",
"mode": "rewrite",
"to": "chat +chat-update",
"reviewed": true,
"review_reason": "20260728 evaluation emitted +rename-group for the unique group-name update intent; +chat-update is the reviewed shortcut with that exact command-level operation. Parameter compatibility remains the canonical target's responsibility."
},
{
"from": "chat +send",
"mode": "ambiguous",
"candidates": [
"chat +messages-send",
"chat +dm",
"chat +send-to-group"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow."
},
{
"from": "chat +send-message",
"mode": "ambiguous",
"candidates": [
"chat +messages-send",
"chat +dm",
"chat +send-to-group"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send-message without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow."
},
{
"from": "chat +send-text",
"mode": "ambiguous",
"candidates": [
"chat +messages-send",
"chat +dm",
"chat +send-to-group"
],
"reviewed": true,
"review_reason": "20260728 evaluation identified text content but not a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow."
},
{
"from": "chat +send-to",
"mode": "ambiguous",
"candidates": [
"chat +messages-send",
"chat +dm",
"chat +send-to-group"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send-to without proving whether the recipient denotes a user, group, or low-level identifier; the write operation must not choose a target workflow automatically."
},
{
"from": "chat +send-dm",
"mode": "ambiguous",
"candidates": [
"chat +dm",
"chat +messages-send"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send-dm twice, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically."
},
{
"from": "chat +send-single",
"mode": "ambiguous",
"candidates": [
"chat +dm",
"chat +messages-send"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send-single for a direct message, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically."
},
{
"from": "chat +send-by-bot",
"mode": "ambiguous",
"candidates": [
"chat +messages-send",
"chat message send-by-bot"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send-by-bot three times without a complete sending-identity contract; stop and present the unified identity-aware shortcut and the exact native bot sender instead of selecting a write path."
},
{
"from": "chat +group-send-text",
"mode": "ambiguous",
"candidates": [
"chat +send-to-group",
"chat +messages-send"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +group-send-text for a group text operation, but the invented name does not choose between name-resolved group text and the unified identifier-aware workflow; the write operation must not dispatch automatically."
},
{
"from": "chat +send-file",
"mode": "ambiguous",
"candidates": [
"chat +messages-send",
"chat message send"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send-file fifteen times with incompatible target and file parameter spellings; stop before dispatch and let the caller choose the unified shortcut or native current-user file workflow."
},
{
"from": "chat +send-image",
"mode": "ambiguous",
"candidates": [
"chat +messages-send",
"chat message send"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send-image without proving whether the input is an existing mediaId or a local file; stop before dispatch and let the caller choose the reviewed unified or native workflow."
},
{
"from": "chat +send-media",
"mode": "ambiguous",
"candidates": [
"chat +messages-send",
"chat message send"
],
"reviewed": true,
"review_reason": "20260728 evaluation emitted +send-media without a concrete media type, sending identity, or compatible parameter contract; stop before dispatch and let the caller choose the reviewed unified or native workflow."
},
{
"from": "oa +list-processes",
"mode": "ambiguous",
"candidates": [
"oa +list-forms",
"oa +my-initiated",
"oa approval list-initiated"
],
"reviewed": true,
"review_reason": "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf."
},
{
"from": "chat +conversation-detail",
"mode": "rewrite",
"to": "chat +conversation-info",
"reviewed": true,
"review_reason": "20260804 multi-im badcase requested one conversation's details. +conversation-info is the unique current read-only shortcut for that operation. The rewrite changes only the command path and preserves every flag/value for target validation."
},
{
"from": "chat +bot-list",
"mode": "rewrite",
"to": "chat +chat-bots",
"reviewed": true,
"review_reason": "20260804 multi-im badcase requested the robot list for one group. +chat-bots is the unique current read-only shortcut. The fallback must not reinterpret the accompanying group flag."
},
{
"from": "chat +conversation-category-list",
"mode": "ambiguous",
"candidates": [
"chat +category-list",
"chat +category-list-conversations"
],
"reviewed": true,
"review_reason": "The invented name can mean listing the user's categories or listing conversations inside one category. No candidate may execute before the caller chooses the intended object level."
},
{
"from": "chat +conversation-group-list",
"mode": "ambiguous",
"candidates": [
"chat +category-list-conversations",
"chat +conversation-list"
],
"reviewed": true,
"review_reason": "The invented name can mean conversations in a custom category or the general conversation list. The command name alone does not identify the requested collection."
},
{
"from": "chat +list-my-groups",
"mode": "ambiguous",
"candidates": [
"chat +my-groups",
"chat +chat-list-mine",
"chat +chat-list"
],
"reviewed": true,
"review_reason": "The invented name does not choose between the established resolver shortcut, the legacy personal-group list and the current Schema-complete chat list. Recovery must stop instead of silently changing pagination or output semantics."
},
{
"from": "doc +list-templates",
"mode": "rewrite",
"to": "doc +template-list",
"reviewed": true,
"review_reason": "202608 Doc experiments emitted +list-templates while discovering available templates. +template-list is the unique current public read-only shortcut for that operation; the rewrite changes only the command path and preserves flags for target validation."
},
{
"from": "doc +search-template",
"mode": "rewrite",
"to": "doc +template-search",
"reviewed": true,
"review_reason": "202608 Doc experiments emitted the verb/object inversion +search-template. +template-search is the unique current public read-only template-name search shortcut; the rewrite preserves every flag and value."
},
{
"from": "doc +template",
"mode": "ambiguous",
"candidates": [
"doc +template-list",
"doc +template-search",
"doc +create-from-template"
],
"reviewed": true,
"review_reason": "202608 Doc experiments emitted the family-like +template without selecting browse, search, or create-from-template. Command recovery must stop before parameters or execution and present the three public workflows."
},
{
"from": "doc +version",
"mode": "ambiguous",
"candidates": [
"doc +history-list",
"doc +history-save",
"doc +history-revert"
],
"reviewed": true,
"review_reason": "202608 Doc experiments emitted the family-like +version without choosing list, save, or revert. The write and high-risk revert operations cannot be selected from an umbrella name; present the canonical history shortcuts and stop."
},
{
"from": "doc +create-version",
"mode": "rewrite",
"to": "doc +history-save",
"reviewed": true,
"review_reason": "202608 supplemental Doc badcase emitted +create-version while explicitly trying to save the current document as a recoverable history snapshot. +history-save is the unique current canonical shortcut for that operation and accepts the same --node value without parameter transformation."
},
{
"from": "doc +save-version",
"mode": "rewrite",
"to": "doc +history-save",
"reviewed": true,
"review_reason": "202608 supplemental Doc badcase emitted the verb/object inversion +save-version for a current-version snapshot. +history-save is the unique current canonical shortcut with the same document-node contract; the legacy +version-save path is not selected as the fallback target."
},
{
"from": "doc +snapshot",
"mode": "rewrite",
"to": "doc +history-save",
"reviewed": true,
"review_reason": "202608 supplemental Doc badcase emitted +snapshot during an explicit save-current-version workflow. The current +history-save selection contract names this exact recoverable history-snapshot operation, so the reviewed rewrite preserves the --node value and does not infer an update or export workflow."
},
{
"from": "doc +version-create",
"mode": "rewrite",
"to": "doc +history-save",
"reviewed": true,
"review_reason": "202608 supplemental Doc badcase emitted +version-create while saving the current document version. +history-save is the unique current canonical snapshot shortcut and the path-only rewrite leaves its real execution and safety contract authoritative."
}
]
}
@@ -1,122 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/internal/cli/command_path_fallbacks.schema.json",
"title": "DWS Reviewed Command Path Fallbacks",
"description": "Human-reviewed recovery-only normalization for invalid model-emitted command paths. These entries are not stable command identity, navigation, Help, Skill, or Schema aliases.",
"type": "object",
"additionalProperties": false,
"required": [
"$schema",
"version",
"entries"
],
"properties": {
"$schema": {
"const": "./command_path_fallbacks.schema.json"
},
"version": {
"const": 1
},
"entries": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/entry"
}
}
},
"$defs": {
"commandPath": {
"type": "string",
"pattern": "^[A-Za-z0-9+][A-Za-z0-9._:+-]*(?: [A-Za-z0-9+][A-Za-z0-9._:+-]*)*$",
"description": "Exact CLI path without a leading dws or flags."
},
"entry": {
"type": "object",
"additionalProperties": false,
"required": [
"from",
"mode",
"reviewed",
"review_reason"
],
"properties": {
"from": {
"$ref": "#/$defs/commandPath"
},
"mode": {
"type": "string",
"enum": [
"rewrite",
"ambiguous"
]
},
"to": {
"$ref": "#/$defs/commandPath"
},
"candidates": {
"type": "array",
"minItems": 2,
"uniqueItems": true,
"description": "Canonical public runnable paths offered for an ambiguous source. Because ambiguous recovery never dispatches, candidates may mix reviewed shortcuts and native leaves within the same service.",
"items": {
"$ref": "#/$defs/commandPath"
}
},
"reviewed": {
"const": true
},
"review_reason": {
"type": "string",
"minLength": 1
}
},
"allOf": [
{
"if": {
"properties": {
"mode": {
"const": "rewrite"
}
},
"required": [
"mode"
]
},
"then": {
"required": [
"to"
],
"not": {
"required": [
"candidates"
]
}
}
},
{
"if": {
"properties": {
"mode": {
"const": "ambiguous"
}
},
"required": [
"mode"
]
},
"then": {
"required": [
"candidates"
],
"not": {
"required": [
"to"
]
}
}
}
]
}
}
}
@@ -1,253 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Code generated by cmd_command_path_fallbacks; DO NOT EDIT.
// Source: internal/cli/command_path_fallbacks.json validated against the live Cobra tree.
// Regenerate with `make generate-schema` (or `go generate ./internal/cli`).
package cli
var generatedCommandPathFallbacks = []CommandPathFallback{
{
From: "chat +bot-list",
Mode: "rewrite",
To: "chat +chat-bots",
Reviewed: true,
ReviewReason: "20260804 multi-im badcase requested the robot list for one group. +chat-bots is the unique current read-only shortcut. The fallback must not reinterpret the accompanying group flag.",
},
{
From: "chat +conversation-category-list",
Mode: "ambiguous",
Candidates: []string{"chat +category-list", "chat +category-list-conversations"},
Reviewed: true,
ReviewReason: "The invented name can mean listing the user's categories or listing conversations inside one category. No candidate may execute before the caller chooses the intended object level.",
},
{
From: "chat +conversation-detail",
Mode: "rewrite",
To: "chat +conversation-info",
Reviewed: true,
ReviewReason: "20260804 multi-im badcase requested one conversation's details. +conversation-info is the unique current read-only shortcut for that operation. The rewrite changes only the command path and preserves every flag/value for target validation.",
},
{
From: "chat +conversation-group-list",
Mode: "ambiguous",
Candidates: []string{"chat +category-list-conversations", "chat +conversation-list"},
Reviewed: true,
ReviewReason: "The invented name can mean conversations in a custom category or the general conversation list. The command name alone does not identify the requested collection.",
},
{
From: "chat +group-member-list",
Mode: "rewrite",
To: "chat +group-members",
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +group-member-list for a group-name member lookup; +group-members is the unique reviewed read-only shortcut and canonical parameter validation remains authoritative.",
},
{
From: "chat +group-search",
Mode: "rewrite",
To: "chat +chat-search",
Reviewed: true,
ReviewReason: "0803 evaluation badcase: the model emitted +group-search with --query; +chat-search provides the same group-name search operation.",
},
{
From: "chat +group-send-text",
Mode: "ambiguous",
Candidates: []string{"chat +send-to-group", "chat +messages-send"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +group-send-text for a group text operation, but the invented name does not choose between name-resolved group text and the unified identifier-aware workflow; the write operation must not dispatch automatically.",
},
{
From: "chat +list-group-bots",
Mode: "rewrite",
To: "chat +chat-bots",
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +list-group-bots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs.",
},
{
From: "chat +list-my-groups",
Mode: "ambiguous",
Candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"},
Reviewed: true,
ReviewReason: "The invented name does not choose between the established resolver shortcut, the legacy personal-group list and the current Schema-complete chat list. Recovery must stop instead of silently changing pagination or output semantics.",
},
{
From: "chat +list-robot",
Mode: "rewrite",
To: "chat +chat-bots",
Reviewed: true,
ReviewReason: "20260728 evaluation emitted singular +list-robot for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs.",
},
{
From: "chat +list-robots",
Mode: "rewrite",
To: "chat +chat-bots",
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +list-robots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs.",
},
{
From: "chat +members",
Mode: "rewrite",
To: "chat +group-members",
Reviewed: true,
ReviewReason: "20260728 evaluation badcases emitted +members three times for listing members of a group selected by name; +group-members is the unique reviewed read-only shortcut for that intent.",
},
{
From: "chat +message-list",
Mode: "ambiguous",
Candidates: []string{"chat +chat-messages", "chat +messages-list-direct", "chat +search-msg", "chat +unread-chats"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +message-list without identifying group versus direct history, conversation history versus cross-chat search, or ordinary versus unread conversations; no candidate may be selected automatically.",
},
{
From: "chat +read-single",
Mode: "ambiguous",
Candidates: []string{"chat +messages-list-direct", "chat +chat-messages"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +read-single six times for direct-message history, but the invented name does not choose between the focused direct-history shortcut and the broader group/direct history workflow; command recovery must stop before parameter validation or dispatch.",
},
{
From: "chat +rename-group",
Mode: "rewrite",
To: "chat +chat-update",
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +rename-group for the unique group-name update intent; +chat-update is the reviewed shortcut with that exact command-level operation. Parameter compatibility remains the canonical target's responsibility.",
},
{
From: "chat +send",
Mode: "ambiguous",
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow.",
},
{
From: "chat +send-by-bot",
Mode: "ambiguous",
Candidates: []string{"chat +messages-send", "chat message send-by-bot"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send-by-bot three times without a complete sending-identity contract; stop and present the unified identity-aware shortcut and the exact native bot sender instead of selecting a write path.",
},
{
From: "chat +send-dm",
Mode: "ambiguous",
Candidates: []string{"chat +dm", "chat +messages-send"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send-dm twice, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically.",
},
{
From: "chat +send-file",
Mode: "ambiguous",
Candidates: []string{"chat +messages-send", "chat message send"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send-file fifteen times with incompatible target and file parameter spellings; stop before dispatch and let the caller choose the unified shortcut or native current-user file workflow.",
},
{
From: "chat +send-image",
Mode: "ambiguous",
Candidates: []string{"chat +messages-send", "chat message send"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send-image without proving whether the input is an existing mediaId or a local file; stop before dispatch and let the caller choose the reviewed unified or native workflow.",
},
{
From: "chat +send-media",
Mode: "ambiguous",
Candidates: []string{"chat +messages-send", "chat message send"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send-media without a concrete media type, sending identity, or compatible parameter contract; stop before dispatch and let the caller choose the reviewed unified or native workflow.",
},
{
From: "chat +send-message",
Mode: "ambiguous",
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send-message without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow.",
},
{
From: "chat +send-single",
Mode: "ambiguous",
Candidates: []string{"chat +dm", "chat +messages-send"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send-single for a direct message, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically.",
},
{
From: "chat +send-text",
Mode: "ambiguous",
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
Reviewed: true,
ReviewReason: "20260728 evaluation identified text content but not a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow.",
},
{
From: "chat +send-to",
Mode: "ambiguous",
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
Reviewed: true,
ReviewReason: "20260728 evaluation emitted +send-to without proving whether the recipient denotes a user, group, or low-level identifier; the write operation must not choose a target workflow automatically.",
},
{
From: "doc +create-version",
Mode: "rewrite",
To: "doc +history-save",
Reviewed: true,
ReviewReason: "202608 supplemental Doc badcase emitted +create-version while explicitly trying to save the current document as a recoverable history snapshot. +history-save is the unique current canonical shortcut for that operation and accepts the same --node value without parameter transformation.",
},
{
From: "doc +list-templates",
Mode: "rewrite",
To: "doc +template-list",
Reviewed: true,
ReviewReason: "202608 Doc experiments emitted +list-templates while discovering available templates. +template-list is the unique current public read-only shortcut for that operation; the rewrite changes only the command path and preserves flags for target validation.",
},
{
From: "doc +save-version",
Mode: "rewrite",
To: "doc +history-save",
Reviewed: true,
ReviewReason: "202608 supplemental Doc badcase emitted the verb/object inversion +save-version for a current-version snapshot. +history-save is the unique current canonical shortcut with the same document-node contract; the legacy +version-save path is not selected as the fallback target.",
},
{
From: "doc +search-template",
Mode: "rewrite",
To: "doc +template-search",
Reviewed: true,
ReviewReason: "202608 Doc experiments emitted the verb/object inversion +search-template. +template-search is the unique current public read-only template-name search shortcut; the rewrite preserves every flag and value.",
},
{
From: "doc +snapshot",
Mode: "rewrite",
To: "doc +history-save",
Reviewed: true,
ReviewReason: "202608 supplemental Doc badcase emitted +snapshot during an explicit save-current-version workflow. The current +history-save selection contract names this exact recoverable history-snapshot operation, so the reviewed rewrite preserves the --node value and does not infer an update or export workflow.",
},
{
From: "doc +template",
Mode: "ambiguous",
Candidates: []string{"doc +template-list", "doc +template-search", "doc +create-from-template"},
Reviewed: true,
ReviewReason: "202608 Doc experiments emitted the family-like +template without selecting browse, search, or create-from-template. Command recovery must stop before parameters or execution and present the three public workflows.",
},
{
From: "doc +version",
Mode: "ambiguous",
Candidates: []string{"doc +history-list", "doc +history-save", "doc +history-revert"},
Reviewed: true,
ReviewReason: "202608 Doc experiments emitted the family-like +version without choosing list, save, or revert. The write and high-risk revert operations cannot be selected from an umbrella name; present the canonical history shortcuts and stop.",
},
{
From: "doc +version-create",
Mode: "rewrite",
To: "doc +history-save",
Reviewed: true,
ReviewReason: "202608 supplemental Doc badcase emitted +version-create while saving the current document version. +history-save is the unique current canonical snapshot shortcut and the path-only rewrite leaves its real execution and safety contract authoritative.",
},
{
From: "oa +list-processes",
Mode: "ambiguous",
Candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
Reviewed: true,
ReviewReason: "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf.",
},
}
func loadGeneratedCommandPathFallbacks() []CommandPathFallback {
return generatedCommandPathFallbacks
}
@@ -1,32 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package cli
import "sync"
var (
commandPathFallbackIndexOnce sync.Once
commandPathFallbackIndex map[string]CommandPathFallback
)
func buildCommandPathFallbackIndex() {
entries := loadGeneratedCommandPathFallbacks()
commandPathFallbackIndex = make(map[string]CommandPathFallback, len(entries))
for _, entry := range entries {
commandPathFallbackIndex[entry.From] = entry
}
}
// LookupCommandPathFallback performs an exact O(1) lookup of a reviewed
// recovery-only path. Normalization only removes a leading dws and folds
// whitespace; it does not apply prefix, typo, or semantic matching.
func LookupCommandPathFallback(rawPath string) (CommandPathFallback, bool) {
commandPathFallbackIndexOnce.Do(buildCommandPathFallbackIndex)
entry, ok := commandPathFallbackIndex[normalizeSchemaCLIPath(rawPath)]
if !ok {
return CommandPathFallback{}, false
}
entry.Candidates = append([]string(nil), entry.Candidates...)
return entry, true
}
-372
View File
@@ -1,372 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package cli
import (
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
func commandFallbackJSON(entries string) []byte {
return []byte(`{"$schema":"./command_path_fallbacks.schema.json","version":1,"entries":` + entries + `}`)
}
func validCommandFallbackEntry(from, to string) string {
return `{"from":"` + from + `","mode":"rewrite","to":"` + to + `","reviewed":true,"review_reason":"reviewed fixture"}`
}
func TestCrossPlatformCoverageDecodeCommandPathFallbacksStrictContract(t *testing.T) {
valid := commandFallbackJSON(`[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]`)
entries, err := decodeCommandPathFallbacks(valid)
if err != nil || len(entries) != 1 || entries[0].From != "chat +bad" || entries[0].To != "chat +good" {
t.Fatalf("decode valid = %#v, %v", entries, err)
}
entries[0].Candidates = append(entries[0].Candidates, "mutated")
cloned := cloneCommandPathFallbacks(entries)
cloned[0].Candidates[0] = "clone"
if entries[0].Candidates[0] != "mutated" {
t.Fatal("cloneCommandPathFallbacks aliases candidate storage")
}
tests := map[string]struct {
data []byte
want string
}{
"unknown field": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":true,"review_reason":"x","extra":true}]`),
want: "unknown field",
},
"wrong schema": {
data: []byte(`{"$schema":"wrong","version":1,"entries":[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]}`),
want: "$schema",
},
"wrong version": {
data: []byte(`{"$schema":"./command_path_fallbacks.schema.json","version":2,"entries":[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]}`),
want: "unsupported",
},
"empty entries": {data: commandFallbackJSON(`[]`), want: "no entries"},
"unnormalized path": {
data: commandFallbackJSON(`[` + validCommandFallbackEntry(` chat +bad `, `chat +good`) + `]`),
want: "not a normalized",
},
"unreviewed": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":false,"review_reason":"x"}]`),
want: "reviewed=true",
},
"empty reason": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":true,"review_reason":" "}]`),
want: "reviewed=true",
},
"invalid mode": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"guess","reviewed":true,"review_reason":"x"}]`),
want: "invalid mode",
},
"rewrite candidates": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","candidates":["chat +one","chat +two"],"reviewed":true,"review_reason":"x"}]`),
want: "must not declare candidates",
},
"empty rewrite target": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","reviewed":true,"review_reason":"x"}]`),
want: "path is empty",
},
"ambiguous target": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","to":"chat +good","candidates":["chat +one","chat +two"],"reviewed":true,"review_reason":"x"}]`),
want: "must not declare to",
},
"ambiguous too small": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one"],"reviewed":true,"review_reason":"x"}]`),
want: "at least two",
},
"empty ambiguous candidate": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one",""],"reviewed":true,"review_reason":"x"}]`),
want: "path is empty",
},
"duplicate candidate": {
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one","chat +one"],"reviewed":true,"review_reason":"x"}]`),
want: "repeats candidate",
},
"duplicate from": {
data: commandFallbackJSON(`[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `,` + validCommandFallbackEntry(`chat +bad`, `chat +other`) + `]`),
want: "duplicate from",
},
"multiple documents": {
data: append(valid, []byte(` {}`)...),
want: "multiple JSON values",
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
if _, err := decodeCommandPathFallbacks(test.data); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("decode error = %v, want containing %q", err, test.want)
}
})
}
}
func TestCrossPlatformCoverageCommandPathFallbackSchemaAndEmbeddedSource(t *testing.T) {
var schema map[string]any
if err := json.Unmarshal(embeddedCommandPathFallbacksSchemaJSON, &schema); err != nil {
t.Fatalf("decode embedded schema: %v", err)
}
if schema["$schema"] != "https://json-schema.org/draft/2020-12/schema" || schema["additionalProperties"] != false {
t.Fatalf("schema header = %#v", schema)
}
entries, err := LoadCommandPathFallbacks()
if err != nil || len(entries) != 34 {
t.Fatalf("LoadCommandPathFallbacks() = %#v, %v", entries, err)
}
if got, ok := LookupCommandPathFallback("dws chat +group-search"); !ok || got.To != "chat +chat-search" {
t.Fatalf("generated lookup = %#v, %v", got, ok)
}
if _, ok := LookupCommandPathFallback("chat +missing"); ok {
t.Fatal("generated lookup accepted unknown path")
}
}
func TestCrossPlatformCoverageCommandPathFallbackAuditCoverage(t *testing.T) {
rewrites := map[string]string{
"chat +bot-list": "chat +chat-bots",
"chat +conversation-detail": "chat +conversation-info",
"chat +group-search": "chat +chat-search",
"chat +members": "chat +group-members",
"chat +group-member-list": "chat +group-members",
"chat +list-group-bots": "chat +chat-bots",
"chat +list-robot": "chat +chat-bots",
"chat +list-robots": "chat +chat-bots",
"chat +rename-group": "chat +chat-update",
"doc +create-version": "doc +history-save",
"doc +list-templates": "doc +template-list",
"doc +save-version": "doc +history-save",
"doc +search-template": "doc +template-search",
"doc +snapshot": "doc +history-save",
"doc +version-create": "doc +history-save",
}
for from, to := range rewrites {
entry, ok := LookupCommandPathFallback(from)
if !ok || entry.Mode != CommandPathFallbackRewrite || entry.To != to {
t.Errorf("rewrite %q = %#v, %v; want %q", from, entry, ok, to)
}
}
ambiguous := map[string][]string{
"chat +conversation-category-list": {"chat +category-list", "chat +category-list-conversations"},
"chat +conversation-group-list": {"chat +category-list-conversations", "chat +conversation-list"},
"chat +group-send-text": {"chat +send-to-group", "chat +messages-send"},
"chat +list-my-groups": {"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"},
"chat +message-list": {"chat +chat-messages", "chat +messages-list-direct", "chat +search-msg", "chat +unread-chats"},
"chat +read-single": {"chat +messages-list-direct", "chat +chat-messages"},
"chat +send": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
"chat +send-by-bot": {"chat +messages-send", "chat message send-by-bot"},
"chat +send-dm": {"chat +dm", "chat +messages-send"},
"chat +send-message": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
"chat +send-single": {"chat +dm", "chat +messages-send"},
"chat +send-text": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
"chat +send-to": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
"chat +send-file": {"chat +messages-send", "chat message send"},
"chat +send-image": {"chat +messages-send", "chat message send"},
"chat +send-media": {"chat +messages-send", "chat message send"},
"oa +list-processes": {"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
"doc +template": {"doc +template-list", "doc +template-search", "doc +create-from-template"},
"doc +version": {"doc +history-list", "doc +history-save", "doc +history-revert"},
}
for from, candidates := range ambiguous {
entry, ok := LookupCommandPathFallback(from)
if !ok || entry.Mode != CommandPathFallbackAmbiguous || !reflect.DeepEqual(entry.Candidates, candidates) {
t.Errorf("ambiguous %q = %#v, %v; want %v", from, entry, ok, candidates)
}
}
if entry, ok := LookupCommandPathFallback("chat +definitely-unknown"); ok {
t.Errorf("unreviewed path unexpectedly falls back: %#v", entry)
}
}
func TestCrossPlatformCoverageReduceCommandPathFallbacksValidatesLiveTree(t *testing.T) {
root := commandFallbackTestRoot()
entries := []CommandPathFallback{
{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good", Reviewed: true, ReviewReason: "fixture"},
{From: "chat group search", Mode: CommandPathFallbackRewrite, To: "chat search", Reviewed: true, ReviewReason: "fixture"},
{From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +other"}, Reviewed: true, ReviewReason: "fixture"},
{From: "chat +mixed", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat search"}, Reviewed: true, ReviewReason: "fixture"},
}
withCommandFallbackSource(t, entries)
got, err := ReduceCommandPathFallbacks(root)
if err != nil || len(got) != len(entries) {
t.Fatalf("ReduceCommandPathFallbacks() = %#v, %v", got, err)
}
got[2].Candidates[0] = "mutated"
again, err := ReduceCommandPathFallbacks(root)
if err != nil || again[2].Candidates[0] == "mutated" {
t.Fatalf("reduction did not clone candidates: %#v, %v", again, err)
}
}
func TestCrossPlatformCoverageReduceCommandPathFallbacksRejectsUnsafeMappings(t *testing.T) {
baseRoot := commandFallbackTestRoot
tests := map[string]struct {
entries []CommandPathFallback
mutate func(*cobra.Command)
want string
}{
"missing target": {
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +missing"}},
want: "does not exist",
},
"real source collision": {
entries: []CommandPathFallback{{From: "chat +other", Mode: CommandPathFallbackRewrite, To: "chat +good"}},
want: "collides with a real Cobra command or alias",
},
"cobra alias collision": {
entries: []CommandPathFallback{{From: "chat +official-alias", Mode: CommandPathFallbackRewrite, To: "chat +other"}},
want: "collides with a real Cobra command or alias",
},
"cross service": {
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "mail +good"}},
want: "crosses service boundary",
},
"shortcut mismatch": {
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat search"}},
want: "disagree on +shortcut identity",
},
"chained rewrite": {
entries: []CommandPathFallback{
{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +next"},
{From: "chat +next", Mode: CommandPathFallbackRewrite, To: "chat +good"},
},
want: "chained fallbacks are forbidden",
},
"chained ambiguous candidate": {
entries: []CommandPathFallback{
{From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +next"}},
{From: "chat +next", Mode: CommandPathFallbackRewrite, To: "chat +other"},
},
want: "is another fallback source",
},
"missing ambiguous candidate": {
entries: []CommandPathFallback{{From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +missing"}}},
want: "does not exist",
},
"hidden target": {
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good"}},
mutate: func(root *cobra.Command) {
exactSchemaCommand(root, "chat +good").Hidden = true
},
want: "not a public runnable",
},
"target is alias": {
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +official-alias"}},
want: "must use canonical Cobra names",
},
"ambiguous source command": {
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good"}},
mutate: func(root *cobra.Command) {
chat := exactSchemaCommand(root, "chat")
chat.AddCommand(
&cobra.Command{Use: "+bad", Run: func(*cobra.Command, []string) {}},
&cobra.Command{Use: "+bad", Run: func(*cobra.Command, []string) {}},
)
},
want: "cannot be resolved safely",
},
"ambiguous target command": {
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +duplicate"}},
mutate: func(root *cobra.Command) {
chat := exactSchemaCommand(root, "chat")
chat.AddCommand(
&cobra.Command{Use: "+duplicate", Run: func(*cobra.Command, []string) {}},
&cobra.Command{Use: "+duplicate", Run: func(*cobra.Command, []string) {}},
)
},
want: "target \"chat +duplicate\" cannot be resolved safely",
},
"unsupported reduced mode": {
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackMode("invalid")}},
want: "unsupported mode",
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
root := baseRoot()
if test.mutate != nil {
test.mutate(root)
}
for index := range test.entries {
test.entries[index].Reviewed = true
test.entries[index].ReviewReason = "fixture"
}
withCommandFallbackSource(t, test.entries)
if _, err := ReduceCommandPathFallbacks(root); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("reduction error = %v, want containing %q", err, test.want)
}
})
}
withCommandFallbackLoadError(t, errors.New("fixture load"))
if _, err := ReduceCommandPathFallbacks(baseRoot()); err == nil || !strings.Contains(err.Error(), "fixture load") {
t.Fatalf("load error = %v", err)
}
if _, err := ReduceCommandPathFallbacks(nil); err == nil || !strings.Contains(err.Error(), "root is nil") {
t.Fatalf("nil root error = %v", err)
}
if got := commandFallbackService(""); got != "" {
t.Fatalf("commandFallbackService(empty) = %q", got)
}
}
func commandFallbackTestRoot() *cobra.Command {
root := &cobra.Command{Use: "dws"}
chat := &cobra.Command{Use: "chat"}
good := &cobra.Command{Use: "+good", Aliases: []string{"+official-alias"}, Run: func(*cobra.Command, []string) {}}
chat.AddCommand(
good,
&cobra.Command{Use: "+other", Run: func(*cobra.Command, []string) {}},
&cobra.Command{Use: "search", Run: func(*cobra.Command, []string) {}},
)
group := &cobra.Command{Use: "group"}
group.AddCommand(cmdutil.HintSubCmd("search", "use chat search"))
chat.AddCommand(group)
mail := &cobra.Command{Use: "mail"}
mail.AddCommand(&cobra.Command{Use: "+good", Run: func(*cobra.Command, []string) {}})
root.AddCommand(chat, mail)
return root
}
func withCommandFallbackSource(t *testing.T, entries []CommandPathFallback) {
t.Helper()
old := loadReviewedCommandPathFallbacks
loadReviewedCommandPathFallbacks = func() ([]CommandPathFallback, error) {
return cloneCommandPathFallbacks(entries), nil
}
t.Cleanup(func() { loadReviewedCommandPathFallbacks = old })
}
func withCommandFallbackLoadError(t *testing.T, want error) {
t.Helper()
old := loadReviewedCommandPathFallbacks
loadReviewedCommandPathFallbacks = func() ([]CommandPathFallback, error) { return nil, want }
t.Cleanup(func() { loadReviewedCommandPathFallbacks = old })
}
func TestCrossPlatformCoverageCommandPathFallbackGeneratedTableWellFormed(t *testing.T) {
entries := loadGeneratedCommandPathFallbacks()
if len(entries) == 0 {
t.Fatal("generated command path fallback table is empty")
}
seen := map[string]bool{}
for _, entry := range entries {
if seen[entry.From] || !entry.Reviewed || strings.TrimSpace(entry.ReviewReason) == "" {
t.Fatalf("invalid generated entry: %#v", entry)
}
seen[entry.From] = true
}
if !reflect.DeepEqual(entries, cloneCommandPathFallbacks(entries)) {
t.Fatal("generated table clone changed values")
}
}
+34 -1
View File
@@ -24,6 +24,39 @@ import (
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalMCPMetadataLookup(t *testing.T) {
cmd := &cobra.Command{Use: "reply"}
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
cmd.Flags().String("text", "", "text")
runtimeannotate.AnnotateRuntimeFlag(cmd, "text", "text", "string", false)
contractfinal.RegisterRuntimeContractFinal(cmd, contract.ContractFinalPayload{
Identity: &contract.ToolIdentitySpec{
ProductID: "chat", Name: "reply_personal_message", CanonicalPath: "chat.reply_personal_message",
CLIPath: "chat reply", PrimaryCLIPath: "chat reply",
},
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeMCP,
Availability: contract.InterfaceAvailable,
Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "send_personal_message"},
},
})
entry := runtimeSchemaEntry{
ProductID: "chat", ToolName: "reply_personal_message", Command: cmd,
CLIPath: "chat reply", PrimaryCLIPath: "chat reply",
}
metadata := runtimeSchemaMetadataSources{
MCP: embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{
"chat.send_personal_message": {Parameters: map[string]embeddedMCPParamMeta{
"text": {Type: "string"},
}},
}},
}
if _, err := runtimeToolSpecFromContractFinal(entry, mustFinal(t, cmd), metadata); err != nil {
t.Fatalf("runtimeToolSpecFromContractFinal with MCP metadata = %v", err)
}
}
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *testing.T) {
cmd := &cobra.Command{Use: "create", Short: "s", Long: "l"}
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
@@ -285,7 +318,7 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalSafetyAnnotationFa
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalParameterResolutionError(t *testing.T) {
oldParameters := resolveRuntimeParameters
t.Cleanup(func() { resolveRuntimeParameters = oldParameters })
resolveRuntimeParameters = func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
resolveRuntimeParameters = func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
return nil, errors.New("parameters failed")
}
entry := runtimeSchemaEntry{
+1 -5
View File
@@ -27,9 +27,7 @@
// 3. schema_parameter_mapping_ledger.go mapping exclusions / removals (Go)
// (active bindings retired; ParamDecl.Property owns delivery)
// 4. param_concepts.json + schema reviewed parameter synonym policy
// 5. command_path_fallbacks.json + schema
// reviewed recovery-only invalid paths
// 6. cobra command tree (Go runtime) flags/usage/required (reflected)
// 5. cobra command tree (Go runtime) flags/usage/required (reflected)
//
// schema_hints/, schema_agent_metadata/, schema_command_registry/,
// schema_mcp_metadata.json, and schema_mcp_service_review(.json|/ledger) are
@@ -37,9 +35,7 @@
//
// Remaining generated output from this file:
// - param_aliases_generated.go per-command parameter normalization
// - command_path_fallbacks_generated.go invalid-path recovery normalization
package cli
//go:generate go run ../generator/cmd_param_aliases -root ../.. -output param_aliases_generated.go
//go:generate go run ../generator/cmd_command_path_fallbacks -root ../.. -output command_path_fallbacks_generated.go
@@ -21,7 +21,7 @@ import (
"testing"
)
func TestCrossPlatformCoverageGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
func TestGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
entries, err := os.ReadDir(".")
if err != nil {
t.Fatalf("read internal/cli: %v", err)
@@ -50,7 +50,6 @@ func TestCrossPlatformCoverageGoGenerateDirectivesStayInUnifiedEntryPoint(t *tes
for _, generator := range []string{
"cmd_schema_catalog",
"cmd_param_aliases",
"cmd_command_path_fallbacks",
} {
if !bytes.Contains(content, []byte("//go:generate go run")) || !bytes.Contains(content, []byte(generator)) {
t.Errorf("gen.go does not register %s", generator)
File diff suppressed because it is too large Load Diff
+18 -192
View File
@@ -10,41 +10,35 @@
},
"concepts": {
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +create-from-template", "doc +find-doc", "doc +search", "doc +template-search", "doc template search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "doc +comment-list", "doc +find-doc", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list", "mail thread list", "oa +list-executed"], "risk": "green"},
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +template-search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "mail thread list", "oa +list-executed"], "risk": "green"},
"page_number": {"denotes": "one-based page number", "canonical_hint": "page", "members": ["page", "page-no", "current-page", "page-num"], "excludes": ["cursor", "page-index", "page-size", "page-token"], "commands": ["devdoc article search"], "risk": "green"},
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list", "doc +comment-list", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list"], "risk": "green"},
"content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc +checkpoint-update", "doc +comment-create", "doc +comment-reply", "doc +comment-update", "doc +create", "doc +doc-append", "doc block insert", "doc block update", "doc comment create", "doc comment create-inline", "doc comment reply", "doc comment update", "doc create"], "risk": "green"},
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list"], "risk": "green"},
"content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc block insert", "doc block update"], "risk": "green"},
"time_start": {"denotes": "start time point with unchanged value format and unit", "canonical_hint": "start", "members": ["start", "start-time", "start-date", "from", "from-date", "begin", "since", "time-min", "min-time"], "excludes": ["date", "time", "end"], "commands": ["calendar event list", "chat message list-all", "report list"], "risk": "yellow"},
"time_end": {"denotes": "end time point with unchanged value format and unit", "canonical_hint": "end", "members": ["end", "end-time", "end-date", "time-max", "max-time"], "excludes": ["date", "time", "start"], "commands": ["calendar event list"], "risk": "yellow"},
"base_id": {"denotes": "multi-dimensional table Base id", "canonical_hint": "base-id", "members": ["base", "base-id", "base-token"], "excludes": [], "commands": ["aitable +field-get", "aitable +list-tables", "aitable +record-query", "aitable +record-share-url", "aitable +table-get"], "risk": "green"},
"dept_id": {"denotes": "single department id", "canonical_hint": "dept", "members": ["dept", "dept-id", "department", "department-id", "parent", "parent-id"], "excludes": ["depts", "dept-ids", "department-ids", "name", "query"], "commands": ["contact +list-sub-depts", "contact dept list-children"], "risk": "yellow"},
"dept_ids": {"denotes": "department id list", "canonical_hint": "dept-ids", "members": ["depts", "dept-ids", "department-ids"], "excludes": ["dept", "dept-id", "department-id", "name", "query"], "commands": ["contact +list-dept-members"], "risk": "yellow"},
"group_id": {"denotes": "single DingTalk numeric groupId", "canonical_hint": "group-id", "members": ["group-id"], "excludes": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "commands": ["chat +chat-get-by-id", "chat group get-by-group-id"], "risk": "yellow"},
"open_conversation_id": {"denotes": "single DingTalk openConversationId with unchanged value", "canonical_hint": "conversation-id", "members": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id"], "excludes": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids", "group-name", "name", "id", "source", "target", "src-conversation-id", "dest-conversation-id"], "commands": ["chat +category-add-conversation", "chat +category-remove-conversation", "chat +chat-add-bot", "chat +chat-audit-join", "chat +chat-bots", "chat +chat-dismiss", "chat +chat-invite-url", "chat +chat-members-get", "chat +chat-mute", "chat +chat-mute-member", "chat +chat-quit", "chat +chat-remove-bot", "chat +chat-role-add", "chat +chat-role-list", "chat +chat-role-query-user", "chat +chat-role-remove", "chat +chat-role-remove-user", "chat +chat-role-set-user", "chat +chat-role-update", "chat +chat-set-admin", "chat +chat-set-history", "chat +chat-transfer-owner", "chat +chat-update-alias", "chat +chat-update-icon", "chat +chat-update-nick", "chat +chat-update-settings", "chat +conversation-clear-messages", "chat +conversation-clear-red-point", "chat +conversation-hide", "chat +conversation-info", "chat +conversation-mark-read", "chat +conversation-mark-unread", "chat +conversation-mute", "chat +flag-cancel", "chat +flag-create", "chat +messages-add-emoji", "chat +messages-add-text-emotion", "chat +messages-list-pin", "chat +messages-read-status", "chat +messages-recall-by-bot", "chat +messages-remove-emoji", "chat +messages-remove-text-emotion", "chat +messages-reply", "chat +messages-resource-download", "chat +messages-resource-url", "chat +messages-send-by-bot", "chat +messages-set-pin", "chat +messages-set-top", "chat +messages-unset-pin", "chat +messages-unset-top", "chat category add-conv", "chat category remove-conv", "chat chmod", "chat clear-messages", "chat clear-red-point", "chat conversation-info", "chat group audit-join-validation", "chat group bots", "chat group dismiss", "chat group invite-url", "chat group members", "chat group members add", "chat group members add-bot", "chat group members list-by-ids", "chat group members remove", "chat group members remove-bot", "chat group notice create", "chat group notice edit", "chat group notice get", "chat group notice list", "chat group quit", "chat group rename", "chat group set-admin", "chat group set-history", "chat group transfer-owner", "chat group update-alias", "chat group update-icon", "chat group update-nick", "chat group update-settings", "chat group-mute", "chat group-mute-member", "chat group-role add", "chat group-role list", "chat group-role query-user", "chat group-role remove", "chat group-role remove-user", "chat group-role set-user", "chat group-role update", "chat hide", "chat mark-read", "chat mark-unread", "chat message add-favorite", "chat message download-media", "chat message list", "chat message list-mentions", "chat message list-pin-msg", "chat message list-topic-replies", "chat message read-status", "chat message recall", "chat message recall-by-bot", "chat message remove-favorite", "chat message reply", "chat message search", "chat message send", "chat message send-by-bot", "chat message send-card", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg", "chat mute-at-all", "chat mute-red-envelope", "chat set-top"], "risk": "yellow"},
"group_id": {"denotes": "single DingTalk numeric groupId", "canonical_hint": "group-id", "members": ["group-id"], "excludes": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "commands": ["chat group get-by-group-id"], "risk": "yellow"},
"open_conversation_id": {"denotes": "single DingTalk openConversationId with unchanged value", "canonical_hint": "conversation-id", "members": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id"], "excludes": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids", "group-name", "name", "id", "source", "target", "src-conversation-id", "dest-conversation-id"], "commands": ["chat +chat-bots", "chat +chat-dismiss", "chat +chat-invite-url", "chat +chat-mute", "chat +chat-role-add", "chat +chat-role-list", "chat +chat-role-query-user", "chat +chat-role-set-user", "chat +chat-role-update", "chat +chat-set-admin", "chat +chat-set-history", "chat +chat-update-alias", "chat +chat-update-nick", "chat +conversation-info", "chat +messages-list-pin", "chat +messages-read-status", "chat category add-conv", "chat category remove-conv", "chat chmod", "chat clear-messages", "chat clear-red-point", "chat conversation-info", "chat group audit-join-validation", "chat group bots", "chat group dismiss", "chat group invite-url", "chat group members", "chat group members add", "chat group members add-bot", "chat group members list-by-ids", "chat group members remove", "chat group members remove-bot", "chat group notice create", "chat group notice edit", "chat group notice get", "chat group notice list", "chat group quit", "chat group rename", "chat group set-admin", "chat group set-history", "chat group transfer-owner", "chat group update-alias", "chat group update-icon", "chat group update-nick", "chat group update-settings", "chat group-mute", "chat group-mute-member", "chat group-role add", "chat group-role list", "chat group-role query-user", "chat group-role remove", "chat group-role remove-user", "chat group-role set-user", "chat group-role update", "chat hide", "chat mark-read", "chat mark-unread", "chat message add-favorite", "chat message download-media", "chat message list", "chat message list-mentions", "chat message list-pin-msg", "chat message list-topic-replies", "chat message read-status", "chat message recall", "chat message recall-by-bot", "chat message remove-favorite", "chat message reply", "chat message search", "chat message send", "chat message send-by-bot", "chat message send-card", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg", "chat mute-at-all", "chat mute-red-envelope", "chat set-top"], "risk": "yellow"},
"open_conversation_ids": {"denotes": "DingTalk openConversationId list with unchanged element values", "canonical_hint": "conversation-ids", "members": ["conversation-ids", "open-conversation-ids", "groups"], "excludes": ["group-id", "group-ids", "conversation-id", "open-conversation-id", "chat-id"], "commands": ["chat message search-advanced"], "risk": "yellow"},
"group_name": {"denotes": "group-name search keyword, not a group identifier", "canonical_hint": "group-name", "members": ["group-name"], "excludes": ["group-id", "conversation-id", "open-conversation-id", "chat-id", "id"], "commands": ["chat +group-members", "chat +send-to-group"], "risk": "yellow"},
"open_message_id": {"denotes": "single DingTalk openMessageId with unchanged value", "canonical_hint": "open-message-id", "members": ["msg-id", "message-id", "open-message-id"], "excludes": ["msg-ids", "message-ids", "open-message-ids", "ref-msg-id", "src-msg-id", "open-task-id", "topic-id", "resource-id"], "commands": ["chat +conversation-mark-read", "chat +flag-cancel", "chat +flag-create", "chat +messages-add-emoji", "chat +messages-add-text-emotion", "chat +messages-forward", "chat +messages-read-status", "chat +messages-remove-emoji", "chat +messages-remove-text-emotion", "chat +messages-resource-download", "chat +messages-set-pin", "chat +messages-set-top", "chat +messages-unset-pin", "chat +messages-unset-top", "chat mark-read", "chat message add-emoji", "chat message add-favorite", "chat message add-text-emotion", "chat message download-media", "chat message forward", "chat message read-status", "chat message recall", "chat message remove-emoji", "chat message remove-favorite", "chat message remove-text-emotion", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg"], "risk": "yellow"},
"open_message_ids": {"denotes": "DingTalk openMessageId list with unchanged element values", "canonical_hint": "msg-ids", "members": ["msg-ids", "message-ids", "open-message-ids"], "excludes": ["msg-id", "message-id", "open-message-id", "ref-msg-id", "src-msg-id"], "commands": ["chat +flag-cancel", "chat +flag-create", "chat +messages-combine-forward", "chat +messages-mget", "chat message combine-forward", "chat message list-by-ids", "chat message list-emotion-replies"], "risk": "yellow"},
"referenced_open_message_id": {"denotes": "referenced DingTalk openMessageId in a reply", "canonical_hint": "ref-msg-id", "members": ["ref-msg-id", "ref-message-id"], "excludes": ["msg-id", "message-id", "open-message-id", "msg-ids", "src-msg-id"], "commands": ["chat +messages-reply", "chat message reply"], "risk": "yellow"},
"open_message_id": {"denotes": "single DingTalk openMessageId with unchanged value", "canonical_hint": "open-message-id", "members": ["msg-id", "message-id", "open-message-id"], "excludes": ["msg-ids", "message-ids", "open-message-ids", "ref-msg-id", "src-msg-id", "open-task-id", "topic-id", "resource-id"], "commands": ["chat +messages-read-status", "chat mark-read", "chat message add-emoji", "chat message add-favorite", "chat message add-text-emotion", "chat message download-media", "chat message forward", "chat message read-status", "chat message recall", "chat message remove-emoji", "chat message remove-favorite", "chat message remove-text-emotion", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg"], "risk": "yellow"},
"open_message_ids": {"denotes": "DingTalk openMessageId list with unchanged element values", "canonical_hint": "msg-ids", "members": ["msg-ids", "message-ids", "open-message-ids"], "excludes": ["msg-id", "message-id", "open-message-id", "ref-msg-id", "src-msg-id"], "commands": ["chat +messages-mget", "chat message combine-forward", "chat message list-by-ids", "chat message list-emotion-replies"], "risk": "yellow"},
"referenced_open_message_id": {"denotes": "referenced DingTalk openMessageId in a reply", "canonical_hint": "ref-msg-id", "members": ["ref-msg-id", "ref-message-id"], "excludes": ["msg-id", "message-id", "open-message-id", "msg-ids", "src-msg-id"], "commands": ["chat message reply"], "risk": "yellow"},
"user_id": {"denotes": "single user id", "canonical_hint": "user-id", "members": ["user", "user-id", "userid", "uid", "staff-id"], "excludes": ["at-user-ids", "to-user", "users", "user-ids", "name"], "commands": ["chat +chat-role-query-user", "chat +chat-role-set-user", "chat +messages-list-direct", "chat chmod", "chat conversation-info", "chat group transfer-owner", "chat group-role query-user", "chat group-role remove-user", "chat group-role set-user", "chat message list", "chat message send", "contact user profile get"], "risk": "yellow"},
"user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat +messages-batch-send-by-bot", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"},
"open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat +chat-members-get", "chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"},
"user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"},
"open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"},
"ding_id": {"denotes": "DING id", "canonical_hint": "ding-id", "members": ["ding-id", "open-ding-id"], "excludes": ["id"], "commands": ["ding message receiver-status"], "risk": "yellow"},
"folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive list", "mail folder update"], "risk": "green"},
"space_id": {"denotes": "drive/wiki/Doc workspace id with unchanged value", "canonical_hint": "space-id", "members": ["space-id", "space", "workspace", "workspace-id"], "excludes": ["folder", "node"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +copy", "doc +create", "doc +create-from-template", "doc +grant-and-share", "doc +import", "doc +list", "doc +move", "doc create", "doc file create", "doc import", "doc template apply", "drive info"], "risk": "yellow"},
"space_id": {"denotes": "drive/wiki space id", "canonical_hint": "space-id", "members": ["space-id", "space", "workspace", "workspace-id"], "excludes": ["folder", "node"], "commands": ["drive info"], "risk": "yellow"},
"app_id": {"denotes": "application id", "canonical_hint": "unified-app-id", "members": ["app-id", "unified-app-id", "application-id"], "excludes": ["app-key", "app-secret", "agent-id"], "commands": ["dev app get"], "risk": "yellow"},
"robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id", "bot-id", "open-bot-id", "bot-code"], "commands": ["chat +chat-add-bot", "chat +messages-batch-recall-by-bot", "chat +messages-batch-send-by-bot", "chat +messages-recall-by-bot", "chat +messages-send-by-bot", "chat group members add-bot", "chat message recall-by-bot", "chat message send-by-bot", "ding message send"], "risk": "yellow"},
"open_bot_id": {"denotes": "single DingTalk openBotId with unchanged value", "canonical_hint": "bot-id", "members": ["bot-id", "open-bot-id"], "excludes": ["robot-code", "robot", "robot-id", "bot-code"], "commands": ["chat +chat-remove-bot", "chat group members remove-bot"], "risk": "yellow"},
"doc_node_id": {"denotes": "single DingTalk document nodeId or accepted document URL/token with unchanged value", "canonical_hint": "node", "members": ["node", "node-id", "doc", "doc-id", "file-id", "document-id", "url"], "excludes": ["id", "folder", "folder-id", "parent-id", "workspace", "workspace-id", "block-id", "comment-id", "comment-key", "job-id", "task-id", "template-id", "version", "revision"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +background-delete", "doc +background-update", "doc +checkpoint-update", "doc +comment-create", "doc +comment-delete", "doc +comment-list", "doc +comment-reply", "doc +comment-update", "doc +copy", "doc +doc-append", "doc +export", "doc +export-submit", "doc +fetch", "doc +history-list", "doc +history-revert", "doc +history-save", "doc +inspect", "doc +move", "doc +review", "doc +version-list", "doc +version-revert", "doc +version-save", "doc block delete", "doc block insert", "doc block list", "doc block update", "doc comment create", "doc comment create-inline", "doc comment delete", "doc comment list", "doc comment reply", "doc comment update", "doc export", "doc info", "doc media download", "doc media insert", "doc media upload", "doc read", "doc style background clear", "doc style background set", "doc style cover clear", "doc style cover set", "doc style get", "doc update", "doc version list", "doc version revert", "doc version save", "doc whiteboard insert"], "risk": "yellow"},
"doc_comment_key": {"denotes": "single DingTalk document commentKey with unchanged value", "canonical_hint": "comment-key", "members": ["comment-key", "comment-id"], "excludes": ["id", "node", "node-id", "doc-id", "block-id"], "commands": ["doc +comment-delete", "doc +comment-reply", "doc +comment-update", "doc comment delete", "doc comment reply", "doc comment update"], "risk": "yellow"},
"doc_version_number": {"denotes": "single DingTalk document historical version number with unchanged integer value", "canonical_hint": "version", "members": ["version", "version-number", "version-no"], "excludes": ["revision", "id", "node", "node-id", "doc-id"], "commands": ["doc +history-revert", "doc +version-revert", "doc version revert"], "risk": "yellow"},
"doc_content_format": {"denotes": "DingTalk document body format with unchanged markdown/jsonml value", "canonical_hint": "content-format", "members": ["content-format", "doc-format"], "excludes": ["format", "export-format", "mime-type"], "commands": ["doc +create", "doc +update", "doc create", "doc update"], "risk": "green"},
"doc_edit_revision": {"denotes": "single optimistic-concurrency revision for a document edit", "canonical_hint": "revision", "members": ["revision", "expected-revision"], "excludes": ["version", "version-number", "version-no"], "commands": ["doc +update", "doc update"], "risk": "yellow"}
"robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id"], "commands": ["chat group members add-bot", "chat message recall-by-bot", "chat message send-by-bot", "ding message send"], "risk": "yellow"},
"open_bot_id": {"denotes": "single DingTalk openBotId with unchanged value", "canonical_hint": "bot-id", "members": ["bot-id", "open-bot-id"], "excludes": ["robot-code", "robot", "robot-id", "bot-code"], "commands": ["chat group members remove-bot"], "risk": "yellow"}
},
"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."},
@@ -63,8 +57,6 @@
"mail message search": {"scoped_aliases": {"subject": "query"}, "scope_strict": true, "note": "never globalize: mail template create has a real and different --subject"},
"calendar event list": {"scoped_aliases": {"date": "start"}, "note": "reviewed against ParseISOTimeToMillis and final list_calendar_events payload; --date is normalized centrally while the command's existing hidden compatibility flags remain native fallbacks"},
"chat +bot-find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search shortcut, --name and --query denote the same search keyword; --name must not become a global search alias."},
"chat +chat-messages": {"scoped_aliases": {"chat": "group"}, "scope_strict": true, "note": "Evaluation compatibility: --chat carries one stable openConversationId and normalizes to the existing --group identifier route."},
"chat +search-msg": {"scoped_aliases": {"chat": "group"}, "scope_strict": true, "note": "Evaluation compatibility: scalar --chat carries one stable openConversationId and normalizes to the existing scalar --group filter."},
"chat bot find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search command, --name and --query denote the same search keyword; --name must not become a global search alias."},
"chat +bot-search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."},
"chat bot search": {"scoped_aliases": {"query": "name", "current-page": "page"}, "block": ["cursor"], "scope_strict": true, "note": "Only the reviewed bot keyword and page-number spellings are accepted; cursor pagination cannot be converted to a page number."},
@@ -97,24 +89,10 @@
"chat group-role set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."},
"chat +messages-send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact shortcut."},
"chat message send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact command."},
"doc block insert": {"block": ["before-block-id"], "note": "Parent and reference roles remain distinct. --before-block-id needs both --ref-block and --where before, while role-free --block-id cannot choose parent versus reference.", "scoped_aliases": {"parent-block-id": "parent-block", "ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "ambiguous": ["block-id"], "scope_strict": true},
"doc block insert": {"block": ["before-block-id"], "note": "requires a two-parameter conversion to --ref-block plus --where before; name-only normalization would silently default to after"},
"chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain."},
"doc +export-get": {"block": ["doc-id", "document-id", "file-id", "node", "node-id", "task-id", "url"], "note": "This command queries one export jobId. Document node identifiers and import taskId spellings are different entities and are rejected.", "scoped_aliases": {"export-job-id": "job-id"}, "scope_strict": true},
"doc +export-get": {"block": ["node"], "note": "node is a document node id, a different entity from job-id"},
"doc block delete": {"block": ["index"], "note": "index (position) vs node (node id) are different"},
"doc +copy": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
"doc +list": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
"doc +move": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
"doc comment create": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
"doc comment reply": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
"doc comment update": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
"doc +comment-create": {"block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids", "mentioned-open-conversation-id", "mentioned-open-conversation-ids", "open-conversation-id", "open-conversation-ids"], "note": "This shortcut has no group-mention flag or payload field. Group-conversation spellings are blocked instead of inventing unsupported capability; route to the stable doc comment command when group mentions are required."},
"doc +comment-reply": {"block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids", "mentioned-open-conversation-id", "mentioned-open-conversation-ids", "open-conversation-id", "open-conversation-ids"], "note": "This shortcut has no group-mention flag or payload field. Group-conversation spellings are blocked instead of inventing unsupported capability; route to the stable doc comment command when group mentions are required."},
"doc media insert": {"scoped_aliases": {"ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "block": ["before-block-id", "parent-block", "parent-block-id"], "ambiguous": ["block-id"], "scope_strict": true, "note": "Media insertion supports a reference block but no parent-block role. --before-block-id additionally needs --where before; role-free --block-id is left ambiguous."},
"doc read": {"block": ["before-block-id", "parent-block-id", "ref-block-id", "reference-block-id"], "ambiguous": ["block-id"], "note": "A section read requires --scope section plus a start/end boundary. A role-free --block-id cannot be reduced to one flag without inventing the missing scope/boundary role."},
"doc export get": {"scoped_aliases": {"export-job-id": "job-id"}, "block": ["doc-id", "document-id", "file-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one export jobId. Document node identifiers are rejected; native hidden --task-id remains the command's reviewed add-only compatibility alias for --job-id."},
"doc import get": {"scoped_aliases": {"import-task-id": "task-id"}, "block": ["doc-id", "document-id", "file-id", "job-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one import taskId. Document node identifiers and export jobId spellings are different entities and are rejected."},
"doc +share-doc": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a document nodeId into a URL, so identifier spellings are rejected with guidance to provide --url."},
"doc update": {"block": ["version", "version-no", "version-number"], "note": "--revision is an optimistic-concurrency revision, not a historical document version number. Version spellings must not reduce to --revision."},
"report outbox list": {"block": ["template-type"], "note": "type vs name are different fields"},
"chat group members add-bot": {"bind": {"id": "open_conversation_id"}},
"chat group members list-by-ids": {"bind": {"id": "open_conversation_id", "users": "open_dingtalk_ids"}, "block": ["user-id", "user-ids"], "note": "This command's --id carries openConversationId, while --users carries an openDingTalkId list."},
@@ -131,48 +109,7 @@
"chat category create-smart": {"bind": {"members": "open_dingtalk_ids"}, "scoped_aliases": {"title": "name"}, "note": "The real --members is an openDingTalkId list; the reviewed title/name alias is exact to the category display name."},
"chat group audit-join-validation": {"ambiguous": ["user", "user-id", "userid", "uid", "staff-id"], "note": "A role-free user identifier cannot choose between the required --applicant and --inviter roles."},
"chat message reply": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The required --ref-sender is a role-specific openDingTalkId and must not accept generic userId spellings."},
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."},
"chat +category-add-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
"chat +category-list-conversations": {"block": ["category-ids"], "note": "The real --category-id is singular; list cardinality is not reduced automatically."},
"chat +category-remove-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
"chat +chat-add-bot": {"bind": {"id": "open_conversation_id"}, "note": "The real --id is the group's openConversationId; robotCode and openBotId remain different domains."},
"chat +chat-audit-join": {"scoped_aliases": {"applicant-user-id": "applicant", "inviter-user-id": "inviter"}, "ambiguous": ["user", "user-id", "userid", "uid", "staff-id"], "scope_strict": true, "note": "A role-free user identifier cannot choose between applicant and inviter."},
"chat +chat-create": {"block": ["user-id", "open-dingtalk-id"], "note": "The real --users field is a list and may contain mixed identifier domains; a singular value is not promoted."},
"chat +chat-get-by-id": {"block": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "note": "The real --group-id is numeric groupId; no CID or group-name spelling can be value-preservingly converted."},
"chat +chat-members-get": {"bind": {"id": "open_conversation_id", "users": "open_dingtalk_ids"}, "block": ["group", "group-name", "user-id", "user-ids"], "note": "The real --id is openConversationId and --users is an openDingTalkId list. The observed --group spelling carried a natural group name and is blocked; explicit CID spellings and --chat remain value-preserving aliases."},
"chat +chat-members-list": {"scoped_aliases": {"chat-id": "conversation-id", "id": "conversation-id"}, "block": ["query", "keyword", "group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "scope_strict": true, "note": "Native --chat/--open-conversation-id stay native; member filtering by query is unsupported and group-name resolution stays on --group/--chat-query."},
"chat +chat-mute-member": {"scoped_aliases": {"user-ids": "users", "open-dingtalk-ids": "users"}, "block": ["user", "user-id", "open-dingtalk-id"], "scope_strict": true, "note": "The target accepts a mixed identifier list; list spellings preserve values, but singular inputs are not promoted."},
"chat +chat-remove-bot": {"bind": {"id": "open_conversation_id"}, "note": "The real --id is openConversationId; --bot-id is separately governed by open_bot_id."},
"chat +chat-role-remove": {"block": ["role-ids"], "note": "The command removes one role ID; list cardinality is not reduced."},
"chat +chat-role-remove-user": {"scoped_aliases": {"user-id": "user", "open-dingtalk-id": "user"}, "block": ["role-id"], "scope_strict": true, "note": "The single --user accepts either identifier domain; --role-ids remains a list."},
"chat +chat-transfer-owner": {"scoped_aliases": {"user-id": "new-owner", "open-dingtalk-id": "new-owner"}, "scope_strict": true, "note": "The only user role is the new owner, and the target accepts either userId or openDingTalkId without changing the value."},
"chat +chat-update": {"scoped_aliases": {"conversation-id": "group", "open-conversation-id": "group", "chat-id": "group", "title": "name", "new-title": "name"}, "block": ["id", "group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "scope_strict": true, "note": "--group accepts a name or CID, so only explicit CID spellings are mapped; generic --id is blocked."},
"chat +conversation-set-top": {"scoped_aliases": {"open-conversation-id": "conversation-id", "chat-id": "conversation-id", "open-conversation-ids": "conversation-ids", "chat-ids": "conversation-ids"}, "block": ["group", "groups", "group-id", "group-ids", "top", "set-top"], "scope_strict": true, "note": "Singular/list cardinality stays explicit; top/set-top cannot be rewritten to the inverse --off switch."},
"chat +feed-group-query-item": {"scoped_aliases": {"open-conversation-ids": "conversation-ids", "chat-ids": "conversation-ids"}, "block": ["group", "groups", "group-id", "group-ids", "conversation-id", "open-conversation-id"], "scope_strict": true, "note": "The real field is an openConversationId list; group names and singular IDs are not converted."},
"chat +flag-list": {"scoped_aliases": {"limit": "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."},
"chat +messages-forward-topic": {"scoped_aliases": {"src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id", "src-open-message-id": "src-msg-id", "source-message-id": "src-msg-id"}, "block": ["group-id", "group-ids", "msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "Source and destination conversation roles and the source-message role remain explicit; role-free message IDs and list cardinality are not inferred."},
"chat +messages-list": {"scoped_aliases": {"start": "time"}, "block": ["before", "before-time", "end", "direction", "page-all", "count", "max", "max-results", "max-size", "page-size"], "scope_strict": true, "note": "start preserves the same boundary value; before/direction require multi-parameter or value transforms and page-all requires iteration. Native --conversation-id/--id/--size remain native."},
"chat +messages-recall-by-bot": {"block": ["msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "note": "--keys carries processQueryKey values, not openMessageId values."},
"chat +messages-reply": {"scoped_aliases": {"msg-id": "ref-msg-id", "open-message-id": "ref-msg-id"}, "block": ["group", "msg-ids", "message-ids", "open-message-ids"], "scope_strict": true, "note": "The observed --group spelling carried a natural group name and is blocked. The only message role is the referenced message; plural IDs are not accepted, while --chat remains a CID alias and native --message-id stays native."},
"chat +messages-resource-download": {"block": ["download-dir"], "note": "--output may be a file or directory under workspace safety rules; a download directory cannot be assumed equivalent."},
"doc +create": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["content-file", "parent-id"], "scope_strict": true, "note": "--content-format is value-preservingly normalized to --doc-format. A raw --content-file path cannot become --content without adding the required @file transform, so it is blocked with guidance to use @relative-path or stable doc create."},
"doc +create-from-template": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
"doc +import": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
"doc +update": {"scoped_aliases": {"mode": "command", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "url": "node"}, "block": ["content-file"], "scope_strict": true, "note": "--mode append/overwrite is the same operation selector subset as --command and preserves its value. --content-file is blocked because +update requires @relative-path or stdin and central aliases cannot read/transform a file value."},
"doc +inspect": {"scoped_aliases": {"include-versions": "include-history"}, "block": ["include", "include-info"], "scope_strict": true, "note": "Historical versions and history are the same optional section on this exact shortcut. Generic --include needs value-dependent flag expansion, while base document info is always returned, so those spellings are rejected with precise guidance."},
"doc +fetch": {"scoped_aliases": {"start-block": "start-block-id", "end-block": "end-block-id"}, "ambiguous": ["block-id"], "scope_strict": true, "note": "Start/end block roles are preserved. A role-free --block-id cannot choose a range/section boundary and must stop before execution."},
"doc +media-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose between document and media identities."},
"doc +media-insert": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and local-media roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."},
"doc +media-list": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "The command lists media inside one document, so only strong document spellings map to --node. File and URL spellings remain role-ambiguous."},
"doc +media-preview": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."},
"doc +resource-delete": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command removes a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."},
"doc +resource-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command downloads a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."},
"doc +resource-update": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has document-node, local-file, and HTTPS image URL roles. Only strong document spellings map to --node; --file-id and --url must stop as ambiguous."},
"doc +share": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a node identifier into a URL, so identifier spellings are rejected with guidance to provide --url."},
"doc +grant-and-share": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node"}, "scope_strict": true, "note": "This workflow has two different real URL roles: --node selects the document for access control and --url is the shareable link sent to recipients. Explicit document-ID spellings map only to --node; --url remains native."}
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."}
},
"validation_fixture": {
@@ -318,118 +255,7 @@
{"command": "chat +unread-chats", "emitted": "limit", "expect": "count", "via": "override:scoped"},
{"command": "chat +unread-chats", "emitted": "size", "expect": "count", "via": "override:scoped"},
{"command": "chat category rename", "emitted": "name", "expect": "title", "via": "override:scoped"},
{"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"},
{"command": "doc +comment-create", "emitted": "node-id", "expect": "node", "via": "concept:doc_node_id"},
{"command": "doc +doc-append", "emitted": "node", "expect": "doc", "via": "concept:doc_node_id"},
{"command": "doc +find-doc", "emitted": "keyword", "expect": "query", "via": "concept:search_query"},
{"command": "doc +search", "emitted": "q", "expect": "query", "via": "concept:search_query"},
{"command": "doc +comment-list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size"},
{"command": "doc +list", "emitted": "page-token", "expect": "cursor", "via": "concept:page_cursor"},
{"command": "doc +copy", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"},
{"command": "doc +copy", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
{"command": "doc +copy", "emitted": "parent-id", "expect": "did-you-mean:blocked", "via": "guard:doc-folder-value-domain"},
{"command": "doc +comment-reply", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
{"command": "doc comment reply", "emitted": "mentioned-open-conversation-ids", "expect": "mentioned-open-conversation-id", "via": "override:scoped-role-list"},
{"command": "doc comment reply", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:numeric-group-id-vs-open-conversation-id"},
{"command": "doc +comment-create", "emitted": "mentioned-open-conversation-id", "expect": "did-you-mean:blocked", "via": "guard:shortcut-missing-capability"},
{"command": "doc block insert", "emitted": "parent-block-id", "expect": "parent-block", "via": "override:scoped-block-role"},
{"command": "doc block insert", "emitted": "block-id", "expect": "did-you-mean:ambiguous", "via": "guard:parent-vs-reference-block-role"},
{"command": "doc media insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-ref-block-plus-where"},
{"command": "doc read", "emitted": "block-id", "expect": "did-you-mean:ambiguous", "via": "guard:requires-scope-and-boundary-role"},
{"command": "doc export get", "emitted": "node", "expect": "did-you-mean:blocked", "via": "guard:document-node-vs-export-job"},
{"command": "doc import get", "emitted": "job-id", "expect": "did-you-mean:blocked", "via": "guard:export-job-vs-import-task"},
{"command": "doc +version-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
{"command": "doc +version-revert", "emitted": "revision", "expect": "did-you-mean:blocked", "via": "concept:doc_version_number+exclude"},
{"command": "doc update", "emitted": "version", "expect": "did-you-mean:blocked", "via": "guard:historical-version-vs-edit-revision"},
{"command": "doc +share-doc", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"},
{"command": "doc +comment-create", "emitted": "body", "expect": "content", "via": "concept:content_text"},
{"command": "doc +doc-append", "emitted": "content", "expect": "text", "via": "concept:content_text"},
{"command": "doc +export-submit", "emitted": "doc-id", "expect": "node", "via": "concept:doc_node_id"},
{"command": "doc +move", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
{"command": "doc +template-list", "emitted": "next-token", "expect": "cursor", "via": "concept:page_cursor"},
{"command": "doc +version-list", "emitted": "page-size", "expect": "limit", "via": "concept:pagination_size"},
{"command": "doc +version-save", "emitted": "file-id", "expect": "node", "via": "concept:doc_node_id"},
{"command": "doc comment create", "emitted": "text", "expect": "content", "via": "concept:content_text"},
{"command": "doc comment create-inline", "emitted": "body", "expect": "content", "via": "concept:content_text"},
{"command": "doc comment delete", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
{"command": "doc comment update", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
{"command": "doc version revert", "emitted": "version-no", "expect": "version", "via": "concept:doc_version_number"},
{"command": "chat +chat-messages", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"},
{"command": "chat +search-msg", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"},
{"command": "chat +chat-update", "emitted": "chat-id", "expect": "group", "via": "override:scoped-explicit-cid"},
{"command": "chat +chat-update", "emitted": "conversation-id", "expect": "group", "via": "override:scoped-explicit-cid"},
{"command": "chat +chat-update", "emitted": "open-conversation-id", "expect": "group", "via": "override:scoped-explicit-cid"},
{"command": "chat +chat-update", "emitted": "id", "expect": "did-you-mean:blocked", "via": "guard:generic-id-value-domain"},
{"command": "chat +chat-update", "emitted": "title", "expect": "name", "via": "override:scoped-group-title"},
{"command": "chat +chat-update", "emitted": "new-title", "expect": "name", "via": "override:scoped-group-title"},
{"command": "chat +flag-list", "emitted": "limit", "expect": "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"},
{"command": "chat +chat-members-list", "emitted": "chat-id", "expect": "conversation-id", "via": "override:scoped-explicit-cid"},
{"command": "chat +chat-members-list", "emitted": "id", "expect": "conversation-id", "via": "override:scoped-explicit-cid"},
{"command": "chat +chat-members-list", "emitted": "query", "expect": "did-you-mean:blocked", "via": "guard:unsupported-member-filter"},
{"command": "chat +conversation-set-top", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "override:scoped-explicit-cid"},
{"command": "chat +conversation-set-top", "emitted": "chat-ids", "expect": "conversation-ids", "via": "override:scoped-explicit-cid-list"},
{"command": "chat +conversation-set-top", "emitted": "groups", "expect": "did-you-mean:blocked", "via": "guard:group-name-or-list-ambiguity"},
{"command": "chat +conversation-set-top", "emitted": "top", "expect": "did-you-mean:blocked", "via": "guard:inverse-boolean-semantics"},
{"command": "chat +chat-members-get", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
{"command": "chat +chat-members-get", "emitted": "open-dingtalk-ids", "expect": "users", "via": "concept:open_dingtalk_ids+bind"},
{"command": "chat +chat-members-get", "emitted": "group", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"},
{"command": "chat +chat-members-get", "emitted": "chat", "expect": "id", "via": "concept:open_conversation_id+bind"},
{"command": "chat +chat-get-by-id", "emitted": "group", "expect": "did-you-mean:blocked", "via": "guard:open-conversation-id-vs-numeric-group-id"},
{"command": "chat +messages-list", "emitted": "start", "expect": "time", "via": "override:scoped-time-boundary"},
{"command": "chat +messages-list", "emitted": "count", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
{"command": "chat +messages-list", "emitted": "max-results", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
{"command": "chat +messages-list", "emitted": "page-all", "expect": "did-you-mean:blocked", "via": "guard:requires-pagination-loop"},
{"command": "chat +messages-reply", "emitted": "msg-id", "expect": "ref-msg-id", "via": "override:scoped-reference-message"},
{"command": "chat +messages-reply", "emitted": "group", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"},
{"command": "chat +messages-reply", "emitted": "chat", "expect": "conversation-id", "via": "concept:open_conversation_id"},
{"command": "chat +flag-cancel", "emitted": "group", "expect": "conversation-id", "via": "concept:open_conversation_id"},
{"command": "chat +flag-cancel", "emitted": "chat", "expect": "conversation-id", "via": "concept:open_conversation_id"},
{"command": "chat +flag-create", "emitted": "group", "expect": "conversation-id", "via": "concept:open_conversation_id"},
{"command": "chat +chat-add-bot", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
{"command": "chat +chat-add-bot", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code"},
{"command": "chat +chat-audit-join", "emitted": "applicant-user-id", "expect": "applicant", "via": "override:scoped-user-role"},
{"command": "chat +chat-audit-join", "emitted": "user-id", "expect": "did-you-mean:ambiguous", "via": "guard:applicant-vs-inviter-role"},
{"command": "chat +chat-create", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
{"command": "chat +chat-mute-member", "emitted": "user-ids", "expect": "users", "via": "override:scoped-mixed-id-list"},
{"command": "chat +chat-mute-member", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
{"command": "chat +chat-remove-bot", "emitted": "open-bot-id", "expect": "bot-id", "via": "concept:open_bot_id"},
{"command": "chat +chat-role-remove", "emitted": "role-ids", "expect": "did-you-mean:blocked", "via": "guard:list-vs-single"},
{"command": "chat +chat-role-remove-user", "emitted": "open-dingtalk-id", "expect": "user", "via": "override:scoped-mixed-id"},
{"command": "chat +chat-transfer-owner", "emitted": "user-id", "expect": "new-owner", "via": "override:scoped-owner-role"},
{"command": "chat +feed-group-query-item", "emitted": "chat-ids", "expect": "conversation-ids", "via": "override:scoped-explicit-cid-list"},
{"command": "chat +feed-group-query-item", "emitted": "conversation-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
{"command": "chat +messages-batch-recall-by-bot", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:process-query-key-vs-open-message-id"},
{"command": "chat +messages-combine-forward", "emitted": "src-open-cid", "expect": "src-conversation-id", "via": "override:scoped-source-role"},
{"command": "chat +messages-combine-forward", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-destination-role"},
{"command": "chat +messages-forward", "emitted": "source-message-id", "expect": "msg-id", "via": "override:scoped-source-role"},
{"command": "chat +messages-forward", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-destination-role"},
{"command": "chat +messages-forward-topic", "emitted": "src-open-message-id", "expect": "src-msg-id", "via": "override:scoped-source-role"},
{"command": "chat +messages-forward-topic", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:message-source-role"},
{"command": "chat +messages-recall-by-bot", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:process-query-key-vs-open-message-id"},
{"command": "chat +messages-resource-download", "emitted": "conversation-id", "expect": "open-conversation-id", "via": "concept:open_conversation_id"},
{"command": "chat +messages-resource-download", "emitted": "download-dir", "expect": "did-you-mean:blocked", "via": "guard:output-file-or-directory-contract"},
{"command": "chat +messages-set-pin", "emitted": "conversation-id", "expect": "open-conversation-id", "via": "concept:open_conversation_id"},
{"command": "doc +create", "emitted": "content-format", "expect": "doc-format", "via": "concept:doc_content_format"},
{"command": "doc +create", "emitted": "content-file", "expect": "did-you-mean:blocked", "via": "guard:requires-file-read-transform"},
{"command": "doc +inspect", "emitted": "include-versions", "expect": "include-history", "via": "override:scoped-section"},
{"command": "doc +inspect", "emitted": "include", "expect": "did-you-mean:blocked", "via": "guard:requires-value-dependent-flag-expansion"},
{"command": "doc +inspect", "emitted": "include-info", "expect": "did-you-mean:blocked", "via": "guard:base-info-always-returned"},
{"command": "doc +update", "emitted": "mode", "expect": "command", "via": "override:scoped-operation"},
{"command": "doc +update", "emitted": "revision", "expect": "expected-revision", "via": "concept:doc_edit_revision"},
{"command": "doc +update", "emitted": "version", "expect": "did-you-mean:blocked", "via": "guard:historical-version-vs-edit-revision"},
{"command": "doc +fetch", "emitted": "start-block", "expect": "start-block-id", "via": "override:scoped-boundary-role"},
{"command": "doc +fetch", "emitted": "block-id", "expect": "did-you-mean:ambiguous", "via": "guard:start-vs-end-boundary-role"},
{"command": "doc +access-grant", "emitted": "doc-id", "expect": "node", "via": "concept:doc_node_id"},
{"command": "doc +history-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
{"command": "doc +create-from-template", "emitted": "keyword", "expect": "query", "via": "concept:search_query"},
{"command": "doc +create-from-template", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"},
{"command": "doc +create-from-template", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
{"command": "doc +media-download", "emitted": "file-id", "expect": "did-you-mean:ambiguous", "via": "guard:document-node-vs-attachment-resource-role"},
{"command": "doc +resource-update", "emitted": "url", "expect": "did-you-mean:ambiguous", "via": "guard:document-url-vs-image-url-role"},
{"command": "doc +share", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"}
{"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"}
]
}
}
+184 -91
View File
@@ -89,9 +89,9 @@ func RegisterRuntimeSchemaConstraints(canonicalPath string, constraints RuntimeS
}
// emptyPinnedMCPMetadata returns the retired pin shape with no tools.
// schema_mcp_metadata.json is deleted; Schema parameter assembly never loads
// or ranks MCP pin candidates. Optional Interface-registry validators may
// still pass this empty shape when they only need ContractFinal self-checks.
// schema_mcp_metadata.json is deleted; production assembly does not embed or
// load a pinned MCP snapshot. Test fixtures may still inject non-empty maps
// through schemaRegistryForTestWithMetadata.
func emptyPinnedMCPMetadata() embeddedMCPMetadata {
return embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}
}
@@ -217,6 +217,62 @@ func collectRuntimeSchemaEntriesFromBound(bound BoundCommandRegistry) ([]runtime
return entries, nil
}
func pinnedMCPMetadataForEntryFrom(entry runtimeSchemaEntry, agentMetadata agentMetadata, mcpMetadata embeddedMCPMetadata) (embeddedMCPToolMetadata, bool) {
// Optional test/diagnostic lookup only. Production mcpMetadata is empty;
// Contract/ParamDecl own interface facts. When a non-empty fixture is
// injected, ContractFinal Interface.Ref remaps CLI canonical names onto
// fixture keys (e.g. reply_personal_message → chat.send_personal_message).
if len(mcpMetadata.Tools) == 0 {
return embeddedMCPToolMetadata{}, false
}
if entry.Command != nil {
if final, ok := RuntimeContractFinal(entry.Command); ok && final.Interface != nil && final.Interface.Ref != nil {
if metadata, found := mcpMetadataForInterfaceRef(mcpMetadata, final.Interface.Ref.ProductID, final.Interface.Ref.RPCName); found {
return metadata, true
}
}
}
paths := []string{
entry.PrimaryCLIPath,
entry.CLIPath,
entry.ProductID + "." + entry.ToolName,
}
paths = append(paths, entry.Aliases...)
if toolMetadata, ok := lookupAgentToolMetadataFrom(agentMetadata, paths...); ok && toolMetadata.InterfaceRef != nil {
if metadata, found := mcpMetadataForInterfaceRef(mcpMetadata, toolMetadata.InterfaceRef.ProductID, toolMetadata.InterfaceRef.RPCName); found {
return metadata, true
}
}
for _, key := range []string{
entry.SourceProductID + "." + entry.ToolName,
entry.ProductID + "." + entry.ToolName,
} {
key = strings.Trim(key, ".")
if key == "" {
continue
}
if meta, ok := mcpMetadata.Tools[key]; ok {
return meta, true
}
}
return embeddedMCPToolMetadata{}, false
}
func mcpMetadataForInterfaceRef(mcpMetadata embeddedMCPMetadata, productID, rpcName string) (embeddedMCPToolMetadata, bool) {
productID = strings.TrimSpace(productID)
rpcName = strings.TrimSpace(rpcName)
key := strings.Trim(productID+"."+rpcName, ".")
if key == "" {
return embeddedMCPToolMetadata{}, false
}
metadata, exists := mcpMetadata.Tools[key]
if !exists {
return embeddedMCPToolMetadata{}, false
}
metadata.InterfaceRef = &embeddedMCPInterfaceRef{ProductID: productID, RPCName: rpcName}
return metadata, true
}
func runtimeSchemaAnnotations(cmd *cobra.Command) (productID, toolName, source string) {
if cmd == nil || cmd.Annotations == nil {
return "", "", ""
@@ -266,6 +322,7 @@ const (
runtimeSchemaRankDefault = 0
runtimeSchemaRankDerived = 50
runtimeSchemaRankInference = 100
runtimeSchemaRankMCP = 400
runtimeSchemaRankCobraHelp = 450
runtimeSchemaRankCobraDefault = 600
runtimeSchemaRankCobraContract = 610
@@ -275,7 +332,7 @@ const (
runtimeSchemaRankVersionedBinding = 650
// ParamDecl.Property (dws.schema.property) outranks residual versioned
// binding candidates (active bindings JSON is empty after Phase 2).
// Mapping exclusions stay highest so an explicit "no RPC property" review
// Mapping exclusions stay highest so an explicit "no MCP property" review
// cannot be overridden by a leaf ParamDecl that still carries a Property.
runtimeSchemaRankParamDeclProperty = 655
runtimeSchemaRankMappingExclusion = 660
@@ -283,6 +340,7 @@ const (
runtimeSchemaPrecedenceDefault = "default"
runtimeSchemaPrecedenceDerived = "derived_resolution"
runtimeSchemaPrecedenceInference = "inference"
runtimeSchemaPrecedenceMCP = "mcp_metadata"
runtimeSchemaPrecedenceCobraHelp = "cobra_help"
runtimeSchemaPrecedenceCobra = "cobra_contract"
runtimeSchemaPrecedenceNativeAnnotation = "native_annotation"
@@ -475,6 +533,8 @@ func runtimeSchemaSourcePriority(source string) (int, string) {
return runtimeSchemaRankCobraDefault, runtimeSchemaPrecedenceCobra
}
return runtimeSchemaRankCobraContract, runtimeSchemaPrecedenceCobra
case "mcp_metadata", "pinned_mcp_metadata":
return runtimeSchemaRankMCP, runtimeSchemaPrecedenceMCP
case "cobra_help":
return runtimeSchemaRankCobraHelp, runtimeSchemaPrecedenceCobraHelp
case "flag_name_inference", "usage_required_inference", "usage_format_inference":
@@ -515,9 +575,9 @@ func runtimeSchemaParameterMappingKey(canonicalPath, flagName string) string {
// runtimeSchemaParameterMappingCandidates resolves the two reviewed,
// versioned property-mapping inputs. An exclusion is an explicit statement
// that the CLI parameter is not a direct RPC/interface property: it therefore
// supplies a present empty candidate (rather than allowing name inference to
// survive) and keeps the review reason in provenance.
// that the CLI parameter is not a direct MCP property: it therefore supplies
// a present empty candidate (rather than allowing name inference to survive)
// and keeps the review reason in provenance.
func runtimeSchemaParameterMappingCandidates(snapshot schemaParameterBindingSnapshot, canonicalPath, flagName string) (runtimeSchemaFieldCandidate, runtimeSchemaFieldCandidate, error) {
binding := strings.TrimSpace(snapshot.Bindings[strings.TrimSpace(canonicalPath)][strings.TrimSpace(flagName)])
bindingCandidate := runtimeSchemaStringCandidate(binding, "versioned_parameter_binding")
@@ -545,24 +605,34 @@ func runtimeSchemaParameterMappingCandidates(snapshot schemaParameterBindingSnap
type runtimeParameterFieldContext struct {
flag *pflag.Flag
metadata RuntimeSchemaParameterMetadata
pinnedParam embeddedMCPParamMeta
hasPinned bool
paramType string
constraints RuntimeSchemaConstraints
property string
}
func (c runtimeParameterFieldContext) interfaceTypeCandidates() []runtimeSchemaFieldCandidate {
return []runtimeSchemaFieldCandidate{
candidates := []runtimeSchemaFieldCandidate{
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagTypeAnnotation), "native_annotation"),
runtimeSchemaStringCandidateAtRank(c.paramType, "cobra_flag_type", runtimeSchemaRankInference, "fallback"),
}
if c.hasPinned {
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.Type, "mcp_metadata"))
}
return append(candidates,
runtimeSchemaStringCandidateAtRank(c.paramType, "cobra_flag_type", runtimeSchemaRankInference, "fallback"),
)
}
func (c runtimeParameterFieldContext) descriptionCandidates() []runtimeSchemaFieldCandidate {
return []runtimeSchemaFieldCandidate{
candidates := []runtimeSchemaFieldCandidate{
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagDescriptionAnnotation), "native_annotation"),
runtimeSchemaStringCandidate(c.flag.Usage, "cobra_usage"),
runtimeSchemaCandidate("", true, "default"),
}
if c.hasPinned {
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.Description, "mcp_metadata"))
}
return append(candidates, runtimeSchemaCandidate("", true, "default"))
}
func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldCandidate {
@@ -579,7 +649,7 @@ func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldC
break
}
}
return []runtimeSchemaFieldCandidate{
candidates := []runtimeSchemaFieldCandidate{
constraintRequired,
runtimeSchemaCandidate(true, typedRequired, "typed_parameter_metadata"),
runtimeSchemaAnnotatedBoolCandidate(c.flag, runtimeSchemaFlagMetadataRequiredAnnotation, "typed_parameter_metadata"),
@@ -587,36 +657,50 @@ func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldC
runtimeSchemaCandidate(true, runtimeFlagCobraHardRequired(c.flag), "cobra_hard_required"),
runtimeSchemaCandidate(false, cobraDefaultOptional, "cobra_nonzero_default"),
runtimeSchemaCandidate(usageRequired, usageRequired, "usage_required_inference"),
runtimeSchemaCandidate(false, true, "default"),
}
if c.hasPinned && c.pinnedParam.Required != nil {
candidates = append(candidates, runtimeSchemaCandidate(*c.pinnedParam.Required, true, "mcp_metadata"))
}
return append(candidates, runtimeSchemaCandidate(false, true, "default"))
}
func (c runtimeParameterFieldContext) requiredWhenCandidates() []runtimeSchemaFieldCandidate {
return []runtimeSchemaFieldCandidate{
candidates := []runtimeSchemaFieldCandidate{
runtimeSchemaStringCandidate(c.metadata.RequiredWhen[c.flag.Name], "typed_parameter_metadata"),
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagMetadataRequiredWhenAnnotation), "typed_parameter_metadata"),
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagRequiredWhenAnnotation), "native_annotation"),
runtimeSchemaCandidate("", true, "default"),
}
if c.hasPinned {
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.RequiredWhen, "mcp_metadata"))
}
return append(candidates, runtimeSchemaCandidate("", true, "default"))
}
func (c runtimeParameterFieldContext) formatCandidates() []runtimeSchemaFieldCandidate {
return []runtimeSchemaFieldCandidate{
candidates := []runtimeSchemaFieldCandidate{
runtimeSchemaStringCandidate(c.metadata.Formats[c.flag.Name], "typed_parameter_metadata"),
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagMetadataFormatAnnotation), "typed_parameter_metadata"),
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, "x-cli-format"), "native_annotation"),
}
if c.hasPinned {
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.Format, "mcp_metadata"))
}
return append(candidates,
runtimeSchemaStringCandidate(inferredRuntimeFlagFormat(c.flag), "usage_format_inference"),
runtimeSchemaCandidate("", true, "default"),
}
)
}
func (c runtimeParameterFieldContext) enumCandidates() []runtimeSchemaFieldCandidate {
return []runtimeSchemaFieldCandidate{
candidates := []runtimeSchemaFieldCandidate{
runtimeSchemaEnumCandidate(c.metadata.Enums[c.flag.Name], "typed_parameter_metadata"),
runtimeSchemaEnumCandidate(runtimeFlagEnumAnnotation(c.flag, runtimeSchemaFlagMetadataEnumAnnotation), "typed_parameter_metadata"),
runtimeSchemaEnumCandidate(runtimeFlagEnum(c.flag), "native_annotation"),
runtimeSchemaCandidate([]string{}, true, "default"),
}
if c.hasPinned {
candidates = append(candidates, runtimeSchemaEnumCandidate(c.pinnedParam.Enum, "mcp_metadata"))
}
return append(candidates, runtimeSchemaCandidate([]string{}, true, "default"))
}
func (c runtimeParameterFieldContext) exampleCandidates() []runtimeSchemaFieldCandidate {
@@ -633,8 +717,7 @@ func (c runtimeParameterFieldContext) exampleCandidates() []runtimeSchemaFieldCa
// source may intentionally raise or lower type/mapping/description semantics.
// required is different: Cobra MarkFlagRequired is a hard floor that no
// lower-priority source may demote (see resolveRequiredProjection).
// MCP pin / mcp_metadata is not a candidate source.
func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, constraints RuntimeSchemaConstraints) ([]ParameterSpec, error) {
func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinnedParams map[string]embeddedMCPParamMeta, constraints RuntimeSchemaConstraints) ([]ParameterSpec, error) {
if cmd == nil {
return nil, nil
}
@@ -675,10 +758,18 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, cons
return
}
property, _ := propertyWinner.Value.(string)
// pinnedParams remains for test fixtures that inject MCP-shaped maps;
// production assembly always passes an empty map (pin retired).
pinnedParam, hasPinnedParam := embeddedMCPParamMeta{}, false
if len(pinnedParams) > 0 && strings.TrimSpace(property) != "" {
pinnedParam, hasPinnedParam = lookupPinnedMCPParam(pinnedParams, property, flag.Name)
}
paramType := runtimeFlagCLIType(flag)
fieldCtx := runtimeParameterFieldContext{
flag: flag,
metadata: metadata,
pinnedParam: pinnedParam,
hasPinned: hasPinnedParam,
paramType: paramType,
constraints: constraints,
property: property,
@@ -703,6 +794,10 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, cons
return
}
description, _ := descriptionWinner.Value.(string)
interfaceDescription := ""
if hasPinnedParam {
interfaceDescription = strings.TrimSpace(pinnedParam.Description)
}
// Required uses field-level safe merge: higher sources may raise required, but
// Cobra MarkFlagRequired cannot be projected away as optional.
@@ -743,6 +838,9 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, cons
runtimeSchemaCandidate(true, true, "cobra_hard_required"),
)
}
if interfaceDescription != "" && interfaceDescription != description {
parameter.InterfaceDescription = interfaceDescription
}
if interfaceType != "" && interfaceType != paramType {
parameter.InterfaceType = interfaceType
fieldProvenance["interface_type"] = runtimeSchemaFieldProvenance(interfaceTypeWinner)
@@ -757,6 +855,12 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, cons
if def := runtimeFlagDefault(flag); def != "" {
parameter.Default = runtimeSchemaJSONString(def)
}
if hasPinnedParam {
interfaceDefault := strings.TrimSpace(pinnedParam.Default)
if interfaceDefault != "" && interfaceDefault != runtimeFlagDefault(flag) {
parameter.InterfaceDefault = runtimeSchemaJSONString(interfaceDefault)
}
}
formatWinner, ok := resolveField("format", fieldCtx.formatCandidates())
if !ok {
return
@@ -887,6 +991,19 @@ func runtimeSchemaConstraintsEmpty(constraints RuntimeSchemaConstraints) bool {
return runtimeannotate.ConstraintsEmpty(constraints)
}
func lookupPinnedMCPParam(params map[string]embeddedMCPParamMeta, property, flagName string) (embeddedMCPParamMeta, bool) {
if len(params) == 0 {
return embeddedMCPParamMeta{}, false
}
if meta, ok := params[property]; ok {
return meta, true
}
if meta, ok := params[flagName]; ok {
return meta, true
}
return embeddedMCPParamMeta{}, false
}
func isGenericPayloadFlag(flag *pflag.Flag) bool {
if flag == nil {
return false
@@ -1025,12 +1142,6 @@ func inferredRuntimeFlagFormat(flag *pflag.Flag) string {
}
usage := strings.ToLower(strings.TrimSpace(flag.Usage))
if strings.Contains(usage, "iso-8601") || strings.Contains(usage, "rfc3339") {
// JSON Schema's date-time format means one RFC3339 value. Do not publish
// that narrower wire contract when the CLI also accepts local timestamps
// or date-only values alongside RFC3339.
if strings.Contains(usage, "yyyy-mm-dd") {
return ""
}
return "date-time"
}
if strings.Contains(usage, "a1") {
@@ -1045,87 +1156,69 @@ func strconvQuote(value string) string {
// ─── --compact mode ──────────────────────────────────────────────────────────
// schemaCompactPayloadKeys is the reviewed Agent-view allowlist. Keep this a
// positive list: a new full/audit field must not silently expand routine Agent
// context just because it was added to ToolSpec.ToPayload.
var schemaCompactPayloadKeys = map[string]bool{
// Navigation envelopes.
"kind": true, "level": true, "count": true, "tool_count": true,
"products": true, "product": true, "tools": true,
"id": true, "schema_path": true, "runtime": true,
// Leaf identity and execution semantics.
"canonical_path": true, "cli_path": true,
"agent_summary": true, "description": true,
"effect": true, "risk": true, "confirmation": true, "idempotency": true,
"interface_mode": true, "availability": true, "interface_reason": true,
"parameters": true, "constraints": true, "positionals": true, "dry_run": true,
"examples": true, "use_when": true, "avoid_when": true,
// schemaCompactStripKeys are top-level tool/product keys removed in --compact mode.
var schemaCompactStripKeys = map[string]bool{
// provenance / debug
"agent_metadata_source": true,
"agent_source_refs": true,
"agent_summary_source": true,
"effect_source": true,
"metadata_source": true,
"source": true,
"agent_metadata": true,
"field_provenance": true,
"reviewed": true,
// redundant with canonical_path / cli_path
"name": true,
"path": true,
"cli_name": true,
"primary_cli_path": true,
"is_alias": true,
"has_parameters": true,
"parameter_count": true,
"product_id": true,
"display": true,
"title": true,
"group": true,
"source_product_id": true,
"aliases": true,
"catalog_hash": true,
"surface_hash": true,
"workflow_refs": true,
"prerequisites": true,
"tips": true,
"interface_ref": true,
}
// schemaCompactParamKeys is the reviewed parameter allowlist for Agent command
// construction. RPC mapping and provenance fields intentionally remain in the
// full/audit view.
var schemaCompactParamKeys = map[string]bool{
"type": true, "description": true, "required": true,
"cli_required": true, "required_when": true,
"default": true, "interface_default": true, "example": true,
"format": true, "enum": true,
// schemaCompactParamStripKeys are per-parameter keys removed in --compact mode.
var schemaCompactParamStripKeys = map[string]bool{
"interface_description": true,
"interface_type": true,
"property": true,
"field_provenance": true,
}
// stripSchemaPayloadCompact projects a full Schema payload onto the reviewed
// Agent-view allowlist. Structural product/tool children are projected
// recursively; constraint, positional and dry-run values are already typed
// contract data and are retained verbatim.
// stripSchemaPayloadCompact walks a schema payload map and removes provenance,
// debug and redundant keys so that only agent-essential fields remain.
// It operates recursively on nested maps, slices, and parameter objects.
func stripSchemaPayloadCompact(payload map[string]any) map[string]any {
if payload == nil {
return nil
}
result := make(map[string]any, len(payload))
for k, v := range payload {
if !schemaCompactPayloadKeys[k] {
if schemaCompactStripKeys[k] {
continue
}
switch k {
case "parameters":
if k == "parameters" {
result[k] = stripSchemaParametersCompact(v)
case "product":
if product, ok := v.(map[string]any); ok {
result[k] = stripSchemaPayloadCompact(product)
} else {
result[k] = v
}
case "products", "tools":
result[k] = stripSchemaPayloadCollectionCompact(v)
default:
result[k] = v
continue
}
result[k] = stripSchemaValueCompact(v)
}
return result
}
func stripSchemaPayloadCollectionCompact(value any) any {
switch values := value.(type) {
case []map[string]any:
result := make([]map[string]any, len(values))
for i, item := range values {
result[i] = stripSchemaPayloadCompact(item)
}
return result
case []any:
result := make([]any, len(values))
for i, item := range values {
if payload, ok := item.(map[string]any); ok {
result[i] = stripSchemaPayloadCompact(payload)
} else {
result[i] = item
}
}
return result
default:
return value
}
}
func stripSchemaParametersCompact(value any) any {
parameters, ok := value.(map[string]any)
if !ok {
@@ -1179,7 +1272,7 @@ func stripSchemaValueCompact(v any) any {
func stripSchemaParamCompact(param map[string]any) map[string]any {
result := make(map[string]any, len(param))
for k, v := range param {
if !schemaCompactParamKeys[k] {
if schemaCompactParamStripKeys[k] {
continue
}
result[k] = v
@@ -11,6 +11,7 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
@@ -62,6 +63,39 @@ func TestCrossPlatformCoverageCollectRuntimeSchemaEntriesErrorsAndOrdering(t *te
}
func TestCrossPlatformCoverageRuntimeSchemaMetadataLookupEdges(t *testing.T) {
if _, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{}, agentMetadata{}, embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}); ok {
t.Fatal("empty lookup unexpectedly matched")
}
leaf := &cobra.Command{Use: "reply"}
contractfinal.RegisterRuntimeContractFinal(leaf, contract.ContractFinalPayload{
Identity: &contract.ToolIdentitySpec{
ProductID: "sample", Name: "run", CanonicalPath: "sample.run",
CLIPath: "sample run", PrimaryCLIPath: "sample run",
},
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeMCP,
Availability: contract.InterfaceAvailable,
Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "send_personal_message"},
},
})
t.Cleanup(func() { ClearRuntimeContractFinalForTest(leaf) })
mcp := embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{
"chat.send_personal_message": {
Parameters: map[string]embeddedMCPParamMeta{
"clawType": {Type: "string"},
},
},
}}
got, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{Command: leaf, ProductID: "chat", ToolName: "reply_personal_message"}, agentMetadata{}, mcp)
if !ok || got.Parameters["clawType"].Type != "string" {
t.Fatalf("ContractFinal Interface.Ref MCP remap = %#v ok=%v", got, ok)
}
if got.InterfaceRef == nil || got.InterfaceRef.RPCName != "send_personal_message" {
t.Fatalf("InterfaceRef = %#v", got.InterfaceRef)
}
for _, test := range []struct {
value any
want int
@@ -121,13 +155,13 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
cmd.Flags().String("value", "", "value")
flag := cmd.Flags().Lookup("value")
if specs, err := runtimeCommandParameterSpecs(nil, "sample.run", RuntimeSchemaConstraints{}); err != nil || specs != nil {
if specs, err := runtimeCommandParameterSpecs(nil, "sample.run", nil, RuntimeSchemaConstraints{}); err != nil || specs != nil {
t.Fatalf("nil command specs = %#v, err = %v", specs, err)
}
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
return schemaParameterBindingSnapshot{}, errors.New("load failed")
})
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "load failed") {
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "load failed") {
t.Fatalf("binding load error = %v", err)
}
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
@@ -145,7 +179,7 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
MappingExclusions: map[string]string{"sample.run --value": " "},
}, nil
})
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "mapping exclusion") {
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "mapping exclusion") {
t.Fatalf("mapping exclusion error = %v", err)
}
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
@@ -160,22 +194,22 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
}
return resolveRuntimeSchemaCandidate(field, candidates...)
})
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), target) {
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), target) {
t.Fatalf("%s resolution error = %v", target, err)
}
}
resolveRuntimeSchemaField = realResolver
if specs, err := runtimeCommandParameterSpecs(&cobra.Command{Use: "empty"}, "sample.empty", RuntimeSchemaConstraints{}); err != nil || specs != nil {
if specs, err := runtimeCommandParameterSpecs(&cobra.Command{Use: "empty"}, "sample.empty", nil, RuntimeSchemaConstraints{}); err != nil || specs != nil {
t.Fatalf("empty specs = %#v, err = %v", specs, err)
}
if payload, err := runtimeCommandParameters(nil, "", RuntimeSchemaConstraints{}); err != nil || payload != nil {
if payload, err := runtimeCommandParameters(nil, "", nil, RuntimeSchemaConstraints{}); err != nil || payload != nil {
t.Fatalf("empty payload = %#v, err = %v", payload, err)
}
testseam.Swap(t, &runtimeCommandParameterSpecsForPayload, func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
testseam.Swap(t, &runtimeCommandParameterSpecsForPayload, func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
return []ParameterSpec{{Name: "bad", Example: json.RawMessage("{")}}, nil
})
if _, err := runtimeCommandParameters(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "serialize Schema parameter") {
if _, err := runtimeCommandParameters(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "serialize Schema parameter") {
t.Fatalf("payload serialization error = %v", err)
}
@@ -184,21 +218,32 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
t.Fatalf("required annotation = %v/%v", required, present)
}
// Binding snapshot still supplies reviewed property mappings without MCP pin.
// Fixture MCP-shaped maps still participate when explicitly injected.
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
return schemaParameterBindingSnapshot{
Bindings: map[string]map[string]string{"sample.run": {"value": "clawType"}},
}, nil
})
specs, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{})
requiredTrue := true
specs, err := runtimeCommandParameterSpecs(cmd, "sample.run", map[string]embeddedMCPParamMeta{
"clawType": {
Type: "string",
Description: "fixture description",
Required: &requiredTrue,
Default: "fixture-default",
},
}, RuntimeSchemaConstraints{})
if err != nil {
t.Fatalf("parameter specs error = %v", err)
t.Fatalf("fixture pinned parameter specs error = %v", err)
}
if len(specs) != 1 || specs[0].Property != "clawType" {
t.Fatalf("parameter specs = %#v", specs)
if len(specs) != 1 || specs[0].Property != "clawType" || specs[0].InterfaceDescription != "fixture description" {
t.Fatalf("fixture pinned parameter specs = %#v", specs)
}
if prov := specs[0].FieldProvenance["property"]; prov.Source == "" {
t.Fatalf("property provenance missing: %#v", specs[0].FieldProvenance)
if len(specs[0].InterfaceDefault) == 0 {
t.Fatalf("fixture interface_default missing: %#v", specs[0])
}
if prov := specs[0].FieldProvenance["required"]; prov.Source == "" {
t.Fatalf("fixture required provenance missing: %#v", specs[0].FieldProvenance)
}
}
@@ -228,6 +273,9 @@ func TestCrossPlatformCoverageRuntimeSchemaPureHelperEdges(t *testing.T) {
if !reflect.DeepEqual(groups, [][]string{{"one"}}) {
t.Fatalf("normalized groups = %#v", groups)
}
if meta, ok := lookupPinnedMCPParam(map[string]embeddedMCPParamMeta{"flag": {Type: "string"}}, "property", "flag"); !ok || meta.Type != "string" {
t.Fatalf("flag fallback metadata = %#v/%v", meta, ok)
}
if isGenericPayloadFlag(nil) {
t.Fatal("nil flag cannot be a generic payload")
}
@@ -274,75 +322,4 @@ func TestCrossPlatformCoverageSchemaCompactProjectionEdges(t *testing.T) {
if _, exists := value["property"]; exists {
t.Fatalf("compact parameter value = %#v", value)
}
// Non-parameter nested maps fall through to payload compacting.
nested := stripSchemaValueCompact(map[string]any{"description": "keep", "provenance": "drop"}).(map[string]any)
if nested["description"] != "keep" {
t.Fatalf("nested non-param map = %#v", nested)
}
if _, exists := nested["provenance"]; exists {
t.Fatalf("nested non-param provenance should drop: %#v", nested)
}
// Type-only maps still count as parameter objects.
typedOnly := stripSchemaValueCompact(map[string]any{"type": "string", "property": "remote"}).(map[string]any)
if _, exists := typedOnly["property"]; exists {
t.Fatalf("type-only param value = %#v", typedOnly)
}
mapSlice := stripSchemaValueCompact([]map[string]any{{"description": "leaf", "provenance": "drop"}}).([]map[string]any)
if len(mapSlice) != 1 || mapSlice[0]["description"] != "leaf" {
t.Fatalf("value compact []map = %#v", mapSlice)
}
if _, exists := mapSlice[0]["provenance"]; exists {
t.Fatalf("value compact []map provenance should drop: %#v", mapSlice)
}
anySlice := stripSchemaValueCompact([]any{map[string]any{"description": "leaf", "provenance": "drop"}, "raw"}).([]any)
if len(anySlice) != 2 || anySlice[1] != "raw" {
t.Fatalf("value compact []any = %#v", anySlice)
}
payload := map[string]any{
"description": "calendar",
"provenance": map[string]any{"source": "drop"},
"parameters": parameters,
"product": map[string]any{"description": "calendar", "provenance": "drop"},
"products": []map[string]any{
{"description": "calendar", "provenance": "drop"},
},
"tools": []any{
map[string]any{"description": "leaf", "provenance": "drop"},
"skip-me",
},
"constraints": map[string]any{"require_one_of": []any{}},
}
stripped := stripSchemaPayloadCompact(payload)
if stripped["description"] != "calendar" {
t.Fatalf("compact description = %#v", stripped["description"])
}
if _, exists := stripped["provenance"]; exists {
t.Fatalf("compact should drop provenance: %#v", stripped)
}
if product, ok := stripped["product"].(map[string]any); !ok || product["description"] != "calendar" {
t.Fatalf("compact product = %#v", stripped["product"])
}
if _, exists := stripped["product"].(map[string]any)["provenance"]; exists {
t.Fatalf("nested product provenance should drop: %#v", stripped["product"])
}
if products, ok := stripped["products"].([]map[string]any); !ok || len(products) != 1 || products[0]["description"] != "calendar" {
t.Fatalf("compact products = %#v", stripped["products"])
}
if tools, ok := stripped["tools"].([]any); !ok || len(tools) != 2 {
t.Fatalf("compact tools = %#v", stripped["tools"])
}
if tool, ok := stripped["tools"].([]any)[0].(map[string]any); !ok || tool["description"] != "leaf" {
t.Fatalf("compact tools[0] = %#v", stripped["tools"].([]any)[0])
}
if stripped["tools"].([]any)[1] != "skip-me" {
t.Fatalf("compact tools[1] = %#v", stripped["tools"].([]any)[1])
}
// Non-map product values are retained verbatim.
if got := stripSchemaPayloadCompact(map[string]any{"product": "raw"}); got["product"] != "raw" {
t.Fatalf("non-map product = %#v", got["product"])
}
if got := stripSchemaPayloadCollectionCompact("raw"); got != "raw" {
t.Fatalf("non-collection compact = %#v", got)
}
}
+18 -9
View File
@@ -30,29 +30,38 @@ import (
// remains a precise reviewed exception for such a capability whose runtime
// preconditions cannot be exercised safely and deterministically in the
// isolated test process.
type AgentExampleMode = contract.ExampleDispositionMode
type AgentExampleMode string
const (
AgentExampleModeContract = contract.ExampleDispositionModeContract
AgentExampleModeDryRun = contract.ExampleDispositionModeDryRun
AgentExampleModeContractOnly = contract.ExampleDispositionModeContractOnly
AgentExampleModeContract AgentExampleMode = "contract"
AgentExampleModeDryRun AgentExampleMode = "dry_run"
AgentExampleModeContractOnly AgentExampleMode = "contract_only"
)
// AgentExampleReasonCode is a closed taxonomy for reviewed contract-only
// exceptions to an explicit dry-run capability.
type AgentExampleReasonCode = contract.ExampleDispositionReasonCode
type AgentExampleReasonCode string
const (
AgentExampleReasonLocalState = contract.ExampleDispositionReasonLocalState
AgentExampleReasonStatefulPreflight = contract.ExampleDispositionReasonStatefulPreflight
AgentExampleReasonLocalState AgentExampleReasonCode = "local_state"
AgentExampleReasonStatefulPreflight AgentExampleReasonCode = "stateful_preflight"
)
// AgentExampleDisposition narrows one exact example with an explicit
// typed dry-run capability to contract-only. Index is a pointer so a missing
// field cannot silently select example zero.
//
// Dispositions are authored on the owning ContractFinal Selection.
type AgentExampleDisposition = contract.ExampleDisposition
// Dispositions are authored as an in-test / future ContractFinal extension
// surface; production ContractFinal Selection currently does not declare them,
// so the delivery plan treats every example as default-typed (contract or
// dry_run from ToolSpec.DryRun).
type AgentExampleDisposition struct {
Index *int `json:"index"`
Mode AgentExampleMode `json:"mode"`
ReasonCode AgentExampleReasonCode `json:"reason_code"`
Reason string `json:"reason"`
Reviewed bool `json:"reviewed"`
}
// AgentExampleExecution is one resolved example and its effective test mode.
type AgentExampleExecution struct {
+19
View File
@@ -189,6 +189,25 @@ func cloneFieldCandidates(source []contract.FieldCandidateProvenance) []contract
return out
}
func lookupAgentToolMetadataFrom(source agentMetadata, paths ...string) (agentToolMetadata, bool) {
seen := map[string]bool{}
for _, path := range paths {
for _, candidate := range []string{
strings.TrimSpace(path),
strings.Join(splitSchemaPathTokens(path), " "),
} {
if candidate == "" || seen[candidate] {
continue
}
seen[candidate] = true
if metadata, ok := source.Tools[candidate]; ok {
return metadata, true
}
}
}
return agentToolMetadata{}, false
}
// agentMetadataSummaryFromProducts publishes Catalog-level Agent coverage from
// the assembled Schema surface (ContractFinal / ProductDecl). This keeps
// runtime delivery and CI dumps hash-aligned without requiring build-time
+122 -9
View File
@@ -20,6 +20,7 @@ import (
"testing/fstest"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
"github.com/spf13/cobra"
)
@@ -93,8 +94,9 @@ func TestRuntimeSchemaIncludesAgentMetadata(t *testing.T) {
// longer participates in assembly.
root := buildRuntimeSchemaTestRoot()
declareRuntimeSchemaTestRootDoc(t, root, nil)
mcpFixture := embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}
leaf, err := runtimeSchemaPayloadForTest(root, []string{"doc.create_document"})
leaf, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"doc.create_document"}, emptyAgentMetadata(), mcpFixture)
if err != nil {
t.Fatalf("runtimeSchemaPayloadForTest(leaf): %v", err)
}
@@ -108,7 +110,7 @@ func TestRuntimeSchemaIncludesAgentMetadata(t *testing.T) {
t.Fatalf("leaf examples = %#v", leaf["examples"])
}
catalog, err := runtimeSchemaPayloadForTest(root, nil)
catalog, err := runtimeSchemaPayloadForTestWithMetadata(root, nil, emptyAgentMetadata(), mcpFixture)
if err != nil {
t.Fatalf("runtimeSchemaPayloadForTest(catalog): %v", err)
}
@@ -134,7 +136,7 @@ func TestRuntimeSchemaIncludesAgentMetadata(t *testing.T) {
t.Fatalf("product summary must not include examples: %#v", tools[0])
}
registry, err := schemaRegistryForTest(root)
registry, err := schemaRegistryForTestWithMetadata(root, emptyAgentMetadata(), mcpFixture)
if err != nil {
t.Fatalf("schemaRegistryForTest(): %v", err)
}
@@ -160,7 +162,7 @@ func TestRuntimeSchemaAllPayloadContainsFullLeafParameters(t *testing.T) {
// exercises the production assembly path.
root := buildRuntimeSchemaTestRoot()
declareRuntimeSchemaTestRootDoc(t, root, nil)
registry, err := schemaRegistryForTest(root)
registry, err := schemaRegistryForTestWithMetadata(root, emptyAgentMetadata(), embeddedMCPMetadata{})
if err != nil {
t.Fatal(err)
}
@@ -201,8 +203,9 @@ func schemaTestInt(value any) int {
}
func TestRuntimeSchemaUsesVersionedInterfaceRef(t *testing.T) {
// interface_ref declares on the leaf ContractFinal; MCP pin is not a
// parameter candidate source.
// interface_ref declares on the leaf ContractFinal; the injected MCP
// fixture participates through the gated fixture lookup (remapped via the
// declared Interface.Ref).
root := buildRuntimeSchemaTestRoot()
declareRuntimeSchemaTestRootDoc(t, root, func(payload *contract.ContractFinalPayload) {
payload.Interface = &contract.InterfaceSpec{
@@ -212,8 +215,17 @@ func TestRuntimeSchemaUsesVersionedInterfaceRef(t *testing.T) {
Ref: &contract.InterfaceRefSpec{ProductID: "documents", RPCName: "create_doc_v2"},
}
})
mcpFixture := embeddedMCPMetadata{
Tools: map[string]embeddedMCPToolMetadata{
"documents.create_doc_v2": {
Parameters: map[string]embeddedMCPParamMeta{
"title": {Description: "MCP document title"},
},
},
},
}
payload, err := runtimeSchemaPayloadForTest(root, []string{"doc.create_document"})
payload, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"doc.create_document"}, emptyAgentMetadata(), mcpFixture)
if err != nil {
t.Fatal(err)
}
@@ -221,11 +233,112 @@ func TestRuntimeSchemaUsesVersionedInterfaceRef(t *testing.T) {
if ref["product_id"] != "documents" || ref["rpc_name"] != "create_doc_v2" {
t.Fatalf("interface_ref = %#v", payload["interface_ref"])
}
if payload["interface_mode"] != contract.InterfaceModeMCP {
t.Fatalf("interface_mode = %#v", payload["interface_mode"])
parameters, _ := payload["parameters"].(map[string]any)
title, _ := parameters["title"].(map[string]any)
if title["interface_description"] != "MCP document title" {
t.Fatalf("title metadata = %#v", title)
}
}
func TestMCPRequiredParticipatesInSourcePrecedence(t *testing.T) {
required := true
mcpFixture := embeddedMCPMetadata{
Tools: map[string]embeddedMCPToolMetadata{
"sample.list_items": {
Parameters: map[string]embeddedMCPParamMeta{
"limit": {Required: &required},
},
},
},
}
root := &cobra.Command{Use: "dws"}
list := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
list.Flags().Int("limit", 0, "optional page size")
AttachRuntimeSchema(list, "sample", "list_items", "test")
sample := &cobra.Command{Use: "sample"}
sample.AddCommand(list)
root.AddCommand(sample)
declareSampleListItemsLeaf(t, list)
payload, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"sample.list_items"}, emptyAgentMetadata(), mcpFixture)
if err != nil {
t.Fatal(err)
}
parameters, _ := payload["parameters"].(map[string]any)
limit, _ := parameters["limit"].(map[string]any)
if limit["required"] != true {
t.Fatalf("MCP required candidate did not win over the default: %#v", limit)
}
}
func TestMCPDefaultDoesNotOverrideCLIDefault(t *testing.T) {
mcpFixture := embeddedMCPMetadata{
Tools: map[string]embeddedMCPToolMetadata{
"sample.list_items": {
Parameters: map[string]embeddedMCPParamMeta{
"limit": {Default: "50"},
},
},
},
}
root := &cobra.Command{Use: "dws"}
list := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
list.Flags().Int("limit", 10, "optional page size")
AttachRuntimeSchema(list, "sample", "list_items", "test")
sample := &cobra.Command{Use: "sample"}
sample.AddCommand(list)
root.AddCommand(sample)
declareSampleListItemsLeaf(t, list)
payload, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"sample.list_items"}, emptyAgentMetadata(), mcpFixture)
if err != nil {
t.Fatal(err)
}
parameters, _ := payload["parameters"].(map[string]any)
limit, _ := parameters["limit"].(map[string]any)
if limit["default"] != "10" || limit["interface_default"] != "50" {
t.Fatalf("CLI and interface defaults were not separated: %#v", limit)
}
}
// declareSampleListItemsLeaf registers the ContractFinal / ProductDecl
// declarations for the synthetic sample.list_items leaf so MCP fixture tests
// assemble through the production path.
func declareSampleListItemsLeaf(t *testing.T, list *cobra.Command) {
t.Helper()
contractfinal.RegisterRuntimeContractFinal(list, contract.ContractFinalPayload{
Identity: &contract.ToolIdentitySpec{
ProductID: "sample", Name: "list_items", CanonicalPath: "sample.list_items",
CLIPath: "sample list", PrimaryCLIPath: "sample list",
},
Title: "List items",
Description: "List sample items",
Safety: &contract.SafetySpec{
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
},
Interface: &contract.InterfaceSpec{
Mode: "local", Availability: "available", Reason: "test local leaf",
},
Selection: &contract.SelectionSpec{
AgentSummary: "List sample items",
UseWhen: []string{"list sample items"},
AvoidWhen: []string{"not listing"},
},
})
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(list) })
contract.RegisterProductDecl(contract.ProductDecl{
ID: "sample",
Selection: contract.ProductSelectionDecl{
AgentSummary: "Sample product",
UseWhen: []string{"sample routing"},
AvoidWhen: []string{"not sample"},
},
})
t.Cleanup(func() { contract.ClearProductDeclForTest("sample") })
}
func findSchemaProduct(products []map[string]any, id string) map[string]any {
for _, product := range products {
if product["id"] == id {
-1
View File
@@ -178,7 +178,6 @@ func contractFinalToolSelection(command *cobra.Command) AgentToolSelection {
out.UseWhen = selection.UseWhen
out.AvoidWhen = selection.AvoidWhen
out.Examples = selection.Examples
out.ExampleDispositions = selection.ExampleDispositions
return out
}
+3 -7
View File
@@ -508,10 +508,6 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
if err != nil {
t.Fatal(err)
}
leaf["future_audit_field"] = "must not leak into Agent view"
for _, raw := range schemaMap(leaf["parameters"]) {
raw["future_mapping_field"] = "must not leak into Agent view"
}
stripped := stripSchemaPayloadCompact(leaf)
// Must keep agent-essential fields.
@@ -522,7 +518,7 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
}
// Must strip provenance / redundant fields.
for _, key := range []string{"agent_metadata_source", "agent_source_refs", "agent_summary_source", "effect_source", "metadata_source", "primary_cli_path", "parameter_count", "has_parameters", "interface_ref", "source", "title", "display", "future_audit_field"} {
for _, key := range []string{"agent_metadata_source", "agent_source_refs", "agent_summary_source", "effect_source", "metadata_source", "primary_cli_path", "parameter_count", "has_parameters", "interface_ref", "source", "title", "display"} {
if _, ok := stripped[key]; ok {
t.Fatalf("compact leaf still contains stripped key %q", key)
}
@@ -532,7 +528,7 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
if params, ok := stripped["parameters"].(map[string]any); ok {
for name, p := range params {
if pm, ok := p.(map[string]any); ok {
for _, stripped := range []string{"interface_description", "interface_type", "property", "future_mapping_field"} {
for _, stripped := range []string{"interface_description", "interface_type", "property"} {
if _, present := pm[stripped]; present {
t.Fatalf("compact param %q still contains %q", name, stripped)
}
@@ -1308,7 +1304,7 @@ func TestDeliveryCatalogContactParamDeclsMatchMergeBaseContract(t *testing.T) {
}
if want.interfaceType != "" {
prov := schemaMap(param["field_provenance"])["interface_type"]
if src, _ := prov["source"].(string); src != "native_annotation" {
if src, _ := prov["source"].(string); src != "native_annotation" && src != "mcp_metadata" {
t.Fatalf("%s --%s interface_type source = %#v", tc.path, flagName, prov)
}
}
+62 -1
View File
@@ -20,7 +20,7 @@ package cli
var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
{
ID: "cli-management",
Reason: "Local CLI lifecycle, authentication, configuration, and plugin-management commands are user-operated controls rather than stable Agent tools.",
Reason: "Local CLI lifecycle, authentication, configuration, recovery, and plugin-management commands are user-operated controls rather than stable Agent tools.",
Reviewed: true,
Commands: []string{
"api",
@@ -53,6 +53,9 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
"profile list",
"profile switch",
"profile use",
"recovery execute",
"recovery finalize",
"recovery plan",
"schema",
"skill get",
"skill install",
@@ -168,4 +171,62 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
"todo task remove-attachment",
},
},
{
ID: "chat-shortcuts-pending-schema-curation",
Reason: "These reviewed public Chat shortcuts remain executable and discoverable through the Shortcut catalog while their typed Schema selection and metadata records are curated; exact paths keep reverse completeness strict without hiding future shortcuts.",
Reviewed: true,
Commands: []string{
"chat +category-add-conversation",
"chat +category-list-conversations",
"chat +category-remove-conversation",
"chat +chat-add-bot",
"chat +chat-audit-join",
"chat +chat-create",
"chat +chat-list",
"chat +chat-get-by-id",
"chat +chat-members-get",
"chat +chat-members-list",
"chat +chat-mute-member",
"chat +chat-quit",
"chat +chat-remove-bot",
"chat +chat-role-remove",
"chat +chat-role-remove-user",
"chat +chat-transfer-owner",
"chat +chat-update",
"chat +chat-update-icon",
"chat +chat-update-settings",
"chat +conversation-clear-messages",
"chat +conversation-clear-red-point",
"chat +conversation-hide",
"chat +conversation-mark-read",
"chat +conversation-mark-unread",
"chat +conversation-mute",
"chat +conversation-set-top",
"chat +feed-group-query-item",
"chat +flag-cancel",
"chat +flag-create",
"chat +flag-list",
"chat +messages-add-emoji",
"chat +messages-add-text-emotion",
"chat +messages-batch-recall-by-bot",
"chat +messages-batch-send-by-bot",
"chat +messages-combine-forward",
"chat +messages-create-text-emotion",
"chat +messages-forward",
"chat +messages-forward-topic",
"chat +messages-list",
"chat +messages-recall",
"chat +messages-recall-by-bot",
"chat +messages-remove-emoji",
"chat +messages-remove-text-emotion",
"chat +messages-reply",
"chat +messages-resource-download",
"chat +messages-resource-url",
"chat +messages-send-by-bot",
"chat +messages-set-pin",
"chat +messages-set-top",
"chat +messages-unset-pin",
"chat +messages-unset-top",
},
},
}
@@ -123,11 +123,16 @@ func TestCrossPlatformCoverageAgentExampleRemainingBranches(t *testing.T) {
t.Run("disposition narrows dry_run capability", func(t *testing.T) {
bound, registry := crossPlatformAgentExampleFixture(t, func(_ *cobra.Command, payload *contract.ContractFinalPayload) {
payload.DryRun = &contract.DryRunSpec{PreviewKind: "plan"}
payload.Selection.ExampleDispositions = []contract.ExampleDisposition{{
Index: idx(0), Mode: contract.ExampleDispositionModeContractOnly, Reviewed: true,
Reason: "cannot dry-run safely", ReasonCode: contract.ExampleDispositionReasonStatefulPreflight,
}}
})
t.Cleanup(restoreSelection)
agentExampleSelectionFn = func(cmd *cobra.Command) AgentToolSelection {
selection := contractFinalToolSelection(cmd)
selection.ExampleDispositions = []AgentExampleDisposition{{
Index: idx(0), Mode: AgentExampleModeContractOnly, Reviewed: true,
Reason: "cannot dry-run safely", ReasonCode: AgentExampleReasonStatefulPreflight,
}}
return selection
}
plan, err := BuildAgentExampleExecutionPlan(bound, registry)
if err != nil {
t.Fatalf("plan error = %v", err)
@@ -141,12 +146,16 @@ func TestCrossPlatformCoverageAgentExampleRemainingBranches(t *testing.T) {
})
t.Run("disposition without dry_run capability fails", func(t *testing.T) {
bound, registry := crossPlatformAgentExampleFixture(t, func(_ *cobra.Command, payload *contract.ContractFinalPayload) {
payload.Selection.ExampleDispositions = []contract.ExampleDisposition{{
Index: idx(0), Mode: contract.ExampleDispositionModeContractOnly, Reviewed: true,
Reason: "no dry run", ReasonCode: contract.ExampleDispositionReasonLocalState,
bound, registry := crossPlatformAgentExampleFixture(t, nil)
t.Cleanup(restoreSelection)
agentExampleSelectionFn = func(cmd *cobra.Command) AgentToolSelection {
selection := contractFinalToolSelection(cmd)
selection.ExampleDispositions = []AgentExampleDisposition{{
Index: idx(0), Mode: AgentExampleModeContractOnly, Reviewed: true,
Reason: "no dry run", ReasonCode: AgentExampleReasonLocalState,
}}
})
return selection
}
_, err := BuildAgentExampleExecutionPlan(bound, registry)
if err == nil || !strings.Contains(err.Error(), "narrows no explicit dry_run") {
t.Fatalf("error = %v", err)
+2 -2
View File
@@ -137,8 +137,8 @@ func schemaToolSpecFromPayload(payload map[string]any) (ToolSpec, error) {
// runtimeCommandParameters is the compatibility wire adapter used only by
// tests; resolution happens in runtimeCommandParameterSpecs.
func runtimeCommandParameters(cmd *cobra.Command, canonicalPath string, constraints RuntimeSchemaConstraints) (map[string]any, error) {
specs, err := runtimeCommandParameterSpecsForPayload(cmd, canonicalPath, constraints)
func runtimeCommandParameters(cmd *cobra.Command, canonicalPath string, pinnedParams map[string]embeddedMCPParamMeta, constraints RuntimeSchemaConstraints) (map[string]any, error) {
specs, err := runtimeCommandParameterSpecsForPayload(cmd, canonicalPath, pinnedParams, constraints)
if err != nil {
return nil, err
}
@@ -969,16 +969,16 @@ func TestOverallCoverageGapDeliveryCompletenessAndDryRun(t *testing.T) {
func TestOverallCoverageGapRuntimeParamsAndAgentMetadata(t *testing.T) {
prevSpecs := runtimeCommandParameterSpecsForPayload
t.Cleanup(func() { runtimeCommandParameterSpecsForPayload = prevSpecs })
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
return nil, fmt.Errorf("specs boom")
}
if _, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", RuntimeSchemaConstraints{}); err == nil {
if _, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil {
t.Fatal("parameter specs error must surface")
}
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
return []ParameterSpec{{Name: "ok", Type: "string"}}, nil
}
payload, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", RuntimeSchemaConstraints{})
payload, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", nil, RuntimeSchemaConstraints{})
if err != nil || payload["ok"] == nil {
t.Fatalf("parameter payload = %#v err=%v", payload, err)
}
@@ -1070,6 +1070,24 @@ func TestOverallCoverageGapRuntimeParamsAndAgentMetadata(t *testing.T) {
}
func TestCrossPlatformCoverageOverallRegressionRecovery(t *testing.T) {
if _, ok := lookupPinnedMCPParam(nil, "property", "flag"); ok {
t.Fatal("nil pinned params must miss")
}
if _, ok := lookupPinnedMCPParam(map[string]embeddedMCPParamMeta{}, "property", "flag"); ok {
t.Fatal("empty pinned params must miss")
}
if _, ok := lookupPinnedMCPParam(map[string]embeddedMCPParamMeta{"other": {Type: "string"}}, "property", "flag"); ok {
t.Fatal("unmatched pinned params must miss")
}
if _, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{}, agentMetadata{}, embeddedMCPMetadata{}); ok {
t.Fatal("empty MCP metadata must not match")
}
if _, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{}, agentMetadata{}, embeddedMCPMetadata{
Tools: map[string]embeddedMCPToolMetadata{"other.key": {}},
}); ok {
t.Fatal("missing MCP metadata keys must not match")
}
left := runtimeSchemaStringCandidateAtPriority("same", true, "z-source", 5, "p")
right := runtimeSchemaStringCandidateAtPriority("same", true, "a-source", 5, "p")
winner, err := resolveRuntimeSchemaCandidate("source-order", left, right)
@@ -410,7 +410,7 @@ func TestRuntimeCommandParameterSpecsPreserveReviewedEmptyPropertyProvenance(t *
cmd := &cobra.Command{Use: "query"}
cmd.Flags().Bool("all", false, "fetch every page")
parameters, err := runtimeCommandParameterSpecs(cmd, "aitable.query_records", RuntimeSchemaConstraints{})
parameters, err := runtimeCommandParameterSpecs(cmd, "aitable.query_records", nil, RuntimeSchemaConstraints{})
if err != nil {
t.Fatal(err)
}

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